teecup/frontend/components/ny-runde/primitives.tsx

456 lines
14 KiB
TypeScript
Raw Normal View History

"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
}