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 ? (
{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 ? (
{icon}
) : null}
{title}
{meta ? {meta} : null}
{description ? (
{description}
) : null}
{showReason ? (
{disabledReason}
) : null}
{variant === "ring" ? (
) : null}
{variant === "radio" ? : null}
);
}
/** The radio circle used by the `radio` and `list` variants. */
function RadioDot({ invalid }: { invalid: boolean }) {
return (
);
}
/** Placeholder while the options load, in the same geometry as the cards. */
export function ChoiceCardsSkeleton({
count = 2,
variant = "ring",
label,
className,
}: {
count?: number | undefined;
variant?: ChoiceCardsVariant | undefined;
/** Keep the real question visible while the options load. */
label?: ReactNode | undefined;
className?: string | undefined;
}) {
return (