import { CheckIcon, CircleAlertIcon, LockIcon } from "lucide-react"; import { RadioGroup } from "radix-ui"; import { createContext, useContext, useId, useMemo, type ReactNode } from "react"; import { useField } from "./field"; import { useSectionListFrame } from "./section-variant"; import { cn } from "../lib/cn"; /* ---------------------------------------------------------------------------- The selected highlight. One highlight for every selected state in the app: choice cards, template cards and picker rows. A 1px brand border and a faint brand fill; hover on an unselected surface is the same surface-2 fill as a list row. -------------------------------------------------------------------------- */ /** Border and fill of a selectable surface that isn't selected. */ export const SELECTABLE_SURFACE_CLASS = "border border-border bg-surface transition-colors duration-[120ms] hover:bg-surface-2"; /** The one selected highlight: brand border, brand fill at 5%. */ export const SELECTED_SURFACE_CLASS = "border-brand bg-brand/5 hover:bg-brand/8"; /** * Classes for any selectable surface (a card, a template, a picker row), so * every selected state in the app looks the same. */ export function selectableSurface(selected: boolean, className?: string): string { return cn(SELECTABLE_SURFACE_CLASS, selected && SELECTED_SURFACE_CLASS, className); } /** The small check badge that marks the selected card, in the top corner. */ export function SelectedCheck({ className }: { className?: string }) { return ( ); } /* ---------------------------------------------------------------------------- ChoiceCards -------------------------------------------------------------------------- */ /** * - `ring` (default): brand border, faint brand fill and a check in the corner. * - `radio`: neutral border; only the radio dot shows the selection. * - `list`: no cards, just radio, title and description rows. */ export type ChoiceCardsVariant = "ring" | "radio" | "list"; interface ChoiceCardsContextValue { variant: ChoiceCardsVariant; invalid: boolean; /** Flat rows split by hairlines: the options sit inside a section card. */ rows: boolean; } const ChoiceCardsContext = createContext({ variant: "ring", invalid: false, rows: false, }); export interface ChoiceCardsProps { /** The selected option. Use with `onValueChange`, or pass `defaultValue`. */ value?: string | undefined; defaultValue?: string | undefined; onValueChange?: ((value: string) => void) | undefined; variant?: ChoiceCardsVariant | undefined; /** The question, shown above the options: "Who can use it?". */ label?: ReactNode | undefined; /** One line under the label, when the question needs context. */ description?: ReactNode | undefined; /** Accessible name when there is no visible label. */ "aria-label"?: string | undefined; /** * `stack` (default) puts options under each other, as in dialogs. `grid` * puts them two across once the group is at least 560px wide. */ layout?: "stack" | "grid" | undefined; /** What went wrong and what to do: "Choose who can use it." */ error?: ReactNode | undefined; disabled?: boolean | undefined; required?: boolean | undefined; /** Form field name, for native form submission. */ name?: string | undefined; className?: string | undefined; children: ReactNode; } /** * 2-3 options whose consequences need a sentence each. Keyboard: Tab into the * group, arrow keys move and select, Space selects. */ export function ChoiceCards({ value, defaultValue, onValueChange, variant = "ring", label, description, "aria-label": ariaLabel, layout = "stack", error, disabled, required, name, className, children, }: ChoiceCardsProps) { const field = useField(); const labelId = useId(); const descriptionId = useId(); const errorId = useId(); // Inside a `Field group`, the field's label names the group and its hint or // error describes it. The group's own label and error win. const invalid = Boolean(error) || Boolean(field?.invalid); const groupDisabled = Boolean(disabled || field?.disabled); const labelledBy = label ? labelId : ariaLabel ? undefined : field?.labelId; const describedBy = [description ? descriptionId : null, error ? errorId : null, field?.describedBy] .filter(Boolean) .join(" ") || undefined; // Inside a settings section card nothing may draw its own box: the options // become flat radio rows split by the card's hairlines. const inCard = useSectionListFrame() === "inside"; const effectiveVariant: ChoiceCardsVariant = inCard ? "list" : variant; const context = useMemo( () => ({ variant: effectiveVariant, invalid, rows: inCard }), [effectiveVariant, invalid, inCard], ); return (
{label || description ? (
{label ? (

{label}

) : null} {description ? (

{description}

) : null}
) : null} {children} {error ? (

) : null}
); } export interface ChoiceCardProps { value: string; title: ReactNode; /** The consequence of picking it, in one sentence. */ description?: ReactNode | undefined; /** A 16px lucide icon. Shown on cards, dropped in the plain list. */ icon?: ReactNode | undefined; /** Quiet detail after the title, for example "3 permissions". */ meta?: ReactNode | undefined; disabled?: boolean | undefined; /** Why it can't be picked and who can change that. Shown under the description. */ disabledReason?: ReactNode | undefined; className?: string | undefined; } /** One option inside `ChoiceCards`. The whole card is the radio. */ export function ChoiceCard({ value, title, description, icon, meta, disabled, disabledReason, className, }: ChoiceCardProps) { const { variant, invalid, rows } = useContext(ChoiceCardsContext); const titleId = useId(); const descriptionId = useId(); const reasonId = useId(); const showReason = Boolean(disabled && disabledReason); const describedBy = [description ? descriptionId : null, showReason ? reasonId : null].filter(Boolean).join(" ") || undefined; const showIcon = Boolean(icon) && variant !== "list"; return ( {variant === "list" ? : null} {showIcon ? ( ) : null} {title} {meta ? {meta} : null} {description ? ( {description} ) : null} {showReason ? ( ) : null} {variant === "ring" ? ( ) : null} {variant === "radio" ? : null} ); } /** The radio circle used by the `radio` and `list` variants. */ function RadioDot({ invalid }: { invalid: boolean }) { return (