Del C (ADR-068, migrasjon 072, siste del av den tredelte utvidelsen som startet med Flaggturnering GPS/kart, se ADR-066/067): nytt format eclectic_gross/eclectic_net/eclectic_stableford -- beste resultat per hull på tvers av en turnerings egne runder, krever samme bane (avvist tydelig ved rundeopprettelse ellers). Regnes ut ved lesing, ingen nye tabeller. Bevisst avvik fra opprinnelig plan: integrert som en ny gren i eksisterende individual-leaderboard-endepunkt fremfor et nytt eget endepunkt -- se ADR-068 for begrunnelsen. Tre ikke-relaterte, brukerrapporterte UI-rettelser tatt med i samme runde: avstandsindikatoren brukte "grønn"/"Midt" i stedet for riktige golf-uttrykk "green"/"senter", og "Oppdateres live"-badgen fjernet. "Antall hull"-bryteren i Ny runde-veiviseren fikk samme grønne aksent-valgt-stil som resten av samme skjerm (delt Segmented-primitiv). Se ARCHITECTURE_DECISIONS.md (ADR-068) og CHANGELOG.md (punkt 84) for full begrunnelse og verifiseringslogg. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
455 lines
14 KiB
TypeScript
455 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import { Check, Minus, Plus } from "lucide-react"
|
|
import type React from "react"
|
|
import { forwardRef, useEffect, useId, useRef, useState } from "react"
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Debounce hook */
|
|
/* ------------------------------------------------------------------ */
|
|
export function useDebouncedValue<T>(value: T, delay = 250): T {
|
|
const [debounced, setDebounced] = useState(value)
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setDebounced(value), delay)
|
|
return () => clearTimeout(t)
|
|
}, [value, delay])
|
|
return debounced
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Field wrapper */
|
|
/* ------------------------------------------------------------------ */
|
|
export function Field({
|
|
label,
|
|
hint,
|
|
error,
|
|
optional,
|
|
htmlFor,
|
|
children,
|
|
className,
|
|
}: {
|
|
label?: string
|
|
hint?: string
|
|
error?: string | null
|
|
optional?: boolean
|
|
htmlFor?: string
|
|
children: React.ReactNode
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<div className={cn("flex flex-col gap-1.5", className)}>
|
|
{label ? (
|
|
<label
|
|
htmlFor={htmlFor}
|
|
className="text-[13px] font-medium tracking-tight text-[var(--nr-ink)]"
|
|
>
|
|
{label}
|
|
{optional ? <span className="ml-1 font-normal text-[var(--nr-faint)]">(valgfritt)</span> : null}
|
|
</label>
|
|
) : null}
|
|
{children}
|
|
{hint && !error ? <p className="text-xs leading-relaxed text-[var(--nr-muted)]">{hint}</p> : null}
|
|
{error ? (
|
|
<p role="alert" className="text-xs leading-relaxed text-[var(--nr-danger)]">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Text input / textarea / native select */
|
|
/* ------------------------------------------------------------------ */
|
|
const controlBase =
|
|
"w-full rounded-lg border bg-[var(--nr-surface-2)] px-3 text-[16px] text-[var(--nr-ink)] outline-none transition-colors placeholder:text-[var(--nr-faint)] focus-visible:border-[var(--nr-accent)] focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40 disabled:opacity-50"
|
|
|
|
export const TextInput = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement> & { invalid?: boolean }>(
|
|
({ className, invalid, ...props }, ref) => (
|
|
<input
|
|
ref={ref}
|
|
className={cn(
|
|
controlBase,
|
|
"h-11",
|
|
invalid ? "border-[var(--nr-danger)]" : "border-[var(--nr-border)]",
|
|
className,
|
|
)}
|
|
aria-invalid={invalid || undefined}
|
|
{...props}
|
|
/>
|
|
),
|
|
)
|
|
TextInput.displayName = "TextInput"
|
|
|
|
export const TextArea = ({
|
|
className,
|
|
...props
|
|
}: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => (
|
|
<textarea className={cn(controlBase, "min-h-[44px] py-2.5 border-[var(--nr-border)]", className)} {...props} />
|
|
)
|
|
|
|
export const NativeSelect = ({
|
|
className,
|
|
invalid,
|
|
children,
|
|
...props
|
|
}: React.SelectHTMLAttributes<HTMLSelectElement> & { invalid?: boolean }) => (
|
|
<div className="relative">
|
|
<select
|
|
className={cn(
|
|
controlBase,
|
|
"h-11 appearance-none pr-9",
|
|
invalid ? "border-[var(--nr-danger)]" : "border-[var(--nr-border)]",
|
|
className,
|
|
)}
|
|
aria-invalid={invalid || undefined}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</select>
|
|
<svg
|
|
aria-hidden="true"
|
|
viewBox="0 0 20 20"
|
|
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-muted)]"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M6 8l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</div>
|
|
)
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Choice card (single or multi select) */
|
|
/* ------------------------------------------------------------------ */
|
|
export function ChoiceCard({
|
|
selected,
|
|
onSelect,
|
|
title,
|
|
description,
|
|
icon,
|
|
disabled,
|
|
className,
|
|
}: {
|
|
selected: boolean
|
|
onSelect: () => void
|
|
title: React.ReactNode
|
|
description?: React.ReactNode
|
|
icon?: React.ReactNode
|
|
disabled?: boolean
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onSelect}
|
|
disabled={disabled}
|
|
aria-pressed={selected}
|
|
className={cn(
|
|
"group relative flex w-full items-start gap-3 rounded-xl border p-4 text-left transition-all min-h-11",
|
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40",
|
|
selected
|
|
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)]"
|
|
: "border-[var(--nr-border)] bg-[var(--nr-surface)] hover:border-[var(--nr-border-strong)]",
|
|
disabled && "cursor-not-allowed opacity-50",
|
|
className,
|
|
)}
|
|
>
|
|
{icon ? (
|
|
<span
|
|
className={cn(
|
|
"mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg",
|
|
selected ? "bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]" : "bg-[var(--nr-surface-2)] text-[var(--nr-muted)]",
|
|
)}
|
|
>
|
|
{icon}
|
|
</span>
|
|
) : null}
|
|
<span className="flex min-w-0 flex-col gap-0.5">
|
|
<span className="text-sm font-semibold text-[var(--nr-ink)]">{title}</span>
|
|
{description ? (
|
|
<span className="text-xs leading-relaxed text-[var(--nr-muted)]">{description}</span>
|
|
) : null}
|
|
</span>
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"ml-auto mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border transition-colors",
|
|
selected ? "border-[var(--nr-accent)] bg-[var(--nr-accent)] text-[var(--nr-accent-ink)]" : "border-[var(--nr-border-strong)]",
|
|
)}
|
|
>
|
|
{selected ? <Check className="h-3.5 w-3.5" strokeWidth={3} /> : null}
|
|
</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Segmented control */
|
|
/* ------------------------------------------------------------------ */
|
|
export function Segmented<T extends string>({
|
|
value,
|
|
onChange,
|
|
options,
|
|
label,
|
|
}: {
|
|
value: T
|
|
onChange: (v: T) => void
|
|
options: { value: T; label: string }[]
|
|
label?: string
|
|
}) {
|
|
return (
|
|
<div
|
|
role="radiogroup"
|
|
aria-label={label}
|
|
className="inline-flex w-full rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface-2)] p-1"
|
|
>
|
|
{options.map((o) => {
|
|
const active = o.value === value
|
|
return (
|
|
<button
|
|
key={o.value}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={active}
|
|
onClick={() => onChange(o.value)}
|
|
className={cn(
|
|
"min-h-9 flex-1 rounded-md border px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--nr-accent)]",
|
|
active
|
|
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]"
|
|
: "border-transparent text-[var(--nr-muted)] hover:text-[var(--nr-ink)]",
|
|
)}
|
|
>
|
|
{o.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Toggle switch */
|
|
/* ------------------------------------------------------------------ */
|
|
export function Toggle({
|
|
checked,
|
|
onChange,
|
|
label,
|
|
description,
|
|
}: {
|
|
checked: boolean
|
|
onChange: (v: boolean) => void
|
|
label: string
|
|
description?: string
|
|
}) {
|
|
const id = useId()
|
|
return (
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex flex-col gap-0.5">
|
|
<label htmlFor={id} className="text-sm font-medium text-[var(--nr-ink)]">
|
|
{label}
|
|
</label>
|
|
{description ? <span className="text-xs text-[var(--nr-muted)]">{description}</span> : null}
|
|
</div>
|
|
<button
|
|
id={id}
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
onClick={() => onChange(!checked)}
|
|
className={cn(
|
|
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40",
|
|
checked ? "bg-[var(--nr-accent)]" : "bg-[var(--nr-border-strong)]",
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform",
|
|
checked ? "translate-x-[22px]" : "translate-x-0.5",
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Stepper (numeric, +/-) */
|
|
/* ------------------------------------------------------------------ */
|
|
export function Stepper({
|
|
value,
|
|
onChange,
|
|
min = 0,
|
|
max = 100,
|
|
step = 5,
|
|
snap = false,
|
|
suffix,
|
|
}: {
|
|
value: number
|
|
onChange: (v: number) => void
|
|
min?: number
|
|
max?: number
|
|
step?: number
|
|
snap?: boolean
|
|
suffix?: string
|
|
}) {
|
|
const [text, setText] = useState(String(value))
|
|
useEffect(() => setText(String(value)), [value])
|
|
|
|
const clamp = (n: number) => Math.min(max, Math.max(min, n))
|
|
|
|
function commit() {
|
|
const parsed = Number.parseInt(text, 10)
|
|
if (Number.isNaN(parsed)) {
|
|
setText(String(value))
|
|
return
|
|
}
|
|
let next = clamp(parsed)
|
|
if (snap) next = clamp(Math.round(next / step) * step)
|
|
onChange(next)
|
|
setText(String(next))
|
|
}
|
|
|
|
return (
|
|
<div className="inline-flex items-stretch overflow-hidden rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)]">
|
|
<button
|
|
type="button"
|
|
aria-label="Reduser"
|
|
onClick={() => onChange(clamp(value - step))}
|
|
disabled={value <= min}
|
|
className="flex h-11 w-11 items-center justify-center text-[var(--nr-muted)] transition-colors hover:bg-[var(--nr-surface-2)] disabled:opacity-40"
|
|
>
|
|
<Minus className="h-4 w-4" strokeWidth={2.5} />
|
|
</button>
|
|
<div className="flex items-center border-x border-[var(--nr-border)] px-1">
|
|
<input
|
|
inputMode="numeric"
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value.replace(/[^\d]/g, ""))}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur()
|
|
}}
|
|
className="h-11 w-12 bg-transparent text-center text-[16px] font-semibold tabular-nums text-[var(--nr-ink)] outline-none"
|
|
aria-label="Verdi"
|
|
/>
|
|
{suffix ? <span className="pr-1 text-sm text-[var(--nr-muted)]">{suffix}</span> : null}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="Øk"
|
|
onClick={() => onChange(clamp(value + step))}
|
|
disabled={value >= max}
|
|
className="flex h-11 w-11 items-center justify-center text-[var(--nr-muted)] transition-colors hover:bg-[var(--nr-surface-2)] disabled:opacity-40"
|
|
>
|
|
<Plus className="h-4 w-4" strokeWidth={2.5} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Small pill / badge */
|
|
/* ------------------------------------------------------------------ */
|
|
export function Pill({
|
|
children,
|
|
tone = "neutral",
|
|
className,
|
|
}: {
|
|
children: React.ReactNode
|
|
tone?: "neutral" | "accent" | "ok" | "danger"
|
|
className?: string
|
|
}) {
|
|
const tones: Record<string, string> = {
|
|
neutral: "bg-[var(--nr-surface-2)] text-[var(--nr-muted)] border-[var(--nr-border)]",
|
|
accent: "bg-[var(--nr-accent-soft)] text-[var(--nr-accent)] border-[var(--nr-accent-ring)]",
|
|
ok: "bg-[var(--nr-ok-soft)] text-[var(--nr-ok)] border-[var(--nr-ok)]/30",
|
|
danger: "bg-[var(--nr-danger-soft)] text-[var(--nr-danger)] border-[var(--nr-danger)]/30",
|
|
}
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
|
tones[tone],
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Toggle-button grid item (used for formats / numeric picks) */
|
|
/* ------------------------------------------------------------------ */
|
|
export function ToggleButton({
|
|
active,
|
|
onClick,
|
|
children,
|
|
className,
|
|
}: {
|
|
active: boolean
|
|
onClick: () => void
|
|
children: React.ReactNode
|
|
className?: string
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-pressed={active}
|
|
onClick={onClick}
|
|
className={cn(
|
|
"min-h-11 rounded-lg border px-3 py-2 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40",
|
|
active
|
|
? "border-[var(--nr-accent)] bg-[var(--nr-accent-soft)] text-[var(--nr-accent)]"
|
|
: "border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-ink)] hover:border-[var(--nr-border-strong)]",
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Avatar initials */
|
|
/* ------------------------------------------------------------------ */
|
|
export function initials(first: string, last?: string) {
|
|
const a = first?.trim()?.[0] ?? "?"
|
|
const b = last?.trim()?.[0] ?? ""
|
|
return (a + b).toUpperCase()
|
|
}
|
|
|
|
export function Avatar({ first, last, className }: { first: string; last?: string; className?: string }) {
|
|
return (
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--nr-surface-2)] text-xs font-semibold text-[var(--nr-muted)] ring-1 ring-inset ring-[var(--nr-border)]",
|
|
className,
|
|
)}
|
|
>
|
|
{initials(first, last)}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/** Card container used throughout the wizard body. */
|
|
export function Panel({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
return (
|
|
<div className={cn("rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)] p-4 sm:p-5", className)}>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Keep a ref-focus helper for autofocus fields on mount. */
|
|
export function useAutoFocus<T extends HTMLElement>(active = true) {
|
|
const ref = useRef<T>(null)
|
|
useEffect(() => {
|
|
if (active) ref.current?.focus()
|
|
}, [active])
|
|
return ref
|
|
}
|