teecup/frontend/components/ny-runde/primitives.tsx
Erol Haagenrud 9b7ff8e2ef Eclectic-format for org-individuelle turneringer (Del C) + tre UI-rettelser
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>
2026-08-14 11:12:25 +02:00

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
}