456 lines
14 KiB
TypeScript
456 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 px-3 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--nr-accent)]",
|
||
|
|
active
|
||
|
|
? "bg-[var(--nr-surface)] text-[var(--nr-ink)] shadow-sm"
|
||
|
|
: "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
|
||
|
|
}
|