import { createContext, useCallback, useContext, useId, useLayoutEffect, useMemo, useState, type ComponentProps, type ReactElement, type ReactNode, } from "react"; import { ArrowRightIcon, ChevronRightIcon, CircleAlertIcon } from "lucide-react"; import { cn } from "../lib/cn"; import { ReasonTooltip } from "./disabled-reason"; /* ---------------------------------------------------------------------------- Field wiring. A SettingRow owns the label and description; the one control inside it (Switch, SegmentedControl, or anything that calls `useSettingRowField`) picks up the ids so it is named and described without hand-written aria attributes. The label only points at the control (`htmlFor`) when a labelable control takes `controlId` through `useSettingRowControl`. Radio groups, buttons and menus are named with `aria-labelledby` instead, so the label never renames them or points at nothing. -------------------------------------------------------------------------- */ export interface SettingRowField { /** Id for a labelable control (switch, input, native select). Take it through `useSettingRowControl`. */ controlId: string; /** Id of the visible label, for `aria-labelledby`. */ labelId: string; /** Space-separated ids of the description, hint and error, for `aria-describedby`. */ describedBy: string | undefined; /** True when the row shows an error. */ invalid: boolean; /** Points the label at `controlId`; returns the release. Use `useSettingRowControl` instead. */ claimLabel: () => () => void; } const SettingRowFieldContext = createContext(null); /** The label and description ids of the enclosing SettingRow, or null outside one. */ export function useSettingRowField(): SettingRowField | null { return useContext(SettingRowFieldContext); } /** * For a labelable control that renders `id={field.controlId}`: the row's * label then points at it, so clicking the label focuses or toggles it. * Pass `false` when the control keeps its own id. */ export function useSettingRowControl(takesId = true): SettingRowField | null { const field = useContext(SettingRowFieldContext); const claim = field?.claimLabel; useLayoutEffect(() => (takesId && claim ? claim() : undefined), [claim, takesId]); return field; } /** Joins aria id lists, dropping empty entries. */ export function joinIds(...ids: Array): string | undefined { const joined = ids.filter(Boolean).join(" "); return joined.length > 0 ? joined : undefined; } /* ---------------------------------------------------------------------------- Disabled reasons. The app-wide ReasonTooltip (hover, keyboard focus and tap), so every "why is this off" reads the same. The control stays focusable (`aria-disabled`) so keyboard and screen reader users can reach it and hear why; clicks on it open the reason instead of acting. -------------------------------------------------------------------------- */ export function DisabledReasonTooltip({ reason, side = "top", children, }: { /** Why the control is unavailable and who can fix it. Renders nothing extra when empty. */ reason?: ReactNode | undefined; side?: "top" | "right" | "bottom" | "left" | undefined; /** One focusable element. Its clicks are swallowed while the reason shows. */ children: ReactElement; }) { if (reason === undefined || reason === null || reason === false) return children; return ( {children} ); } /* ---------------------------------------------------------------------------- SettingRow -------------------------------------------------------------------------- */ /** * - `control-right` (default): text left, the control in a fixed right column so * every control on the page lines up. Wide controls drop under the text when * the row is narrower than 640px; switches stay on the right. * - `control-left`: list style. Switches sit before the label; wider controls * go under the description. * - `stacked`: the control always sits under the text, like a form field. */ export type SettingRowVariant = "control-right" | "control-left" | "stacked"; /** * - `compact`: a switch or a small button. Never wraps under the text in * `control-right`. * - `select`: a 240px column; the control fills it (full width when stacked). * - `auto`: the control's own width, for segmented controls. Options stretch * to the full width when stacked. * * Rows use the 32px control size (`size="sm"` on selects and segmented * controls), so every control in a list shares one height. */ export type SettingRowControlWidth = "compact" | "select" | "auto"; const SettingRowDepthContext = createContext(0); /** Control left: a switch-wide leading column (36px, or the control's width), then the text. */ const LEFT_COLUMNS = "grid-cols-[minmax(2.25rem,auto)_minmax(0,1fr)] gap-x-3"; export interface SettingRowProps extends Omit, "title" | "children"> { /** 14/500, sentence case. Wraps; never truncates. */ label: ReactNode; /** 12/18 muted, one or two sentences. */ description?: ReactNode | undefined; /** Exactly one control. */ control?: ReactNode | undefined; controlWidth?: SettingRowControlWidth | undefined; variant?: SettingRowVariant | undefined; /** A quiet line under the description, usually a `SettingRowLink` that fixes an unavailable state. */ hint?: ReactNode | undefined; /** What went wrong and what to do. Linked to the control with `aria-describedby`. */ error?: ReactNode | undefined; /** * Dependent rows (more SettingRows) that apply only while this row's setting * is on: a sub-selection indented under this row, with no hairline between * the parent and its children or between the children, and no guide line. * They keep the ordinary row type (title, description, control on the * right) with tighter spacing, and the next hairline comes after the whole * group. Render them only while the parent is on. */ children?: ReactNode | undefined; } export function SettingRow({ label, description, control, controlWidth = "compact", variant = "control-right", hint, error, children, className, ...props }: SettingRowProps) { const depth = useContext(SettingRowDepthContext); const baseId = useId(); const controlId = `${baseId}-control`; const labelId = `${baseId}-label`; const descriptionId = `${baseId}-description`; const hintId = `${baseId}-hint`; const errorId = `${baseId}-error`; const hasDescription = description !== undefined && description !== null; const hasHint = hint !== undefined && hint !== null && hint !== false; const hasError = error !== undefined && error !== null && error !== false; // How many controls took `controlId`; the label points at the control only then. const [labelTargets, setLabelTargets] = useState(0); const claimLabel = useCallback(() => { setLabelTargets((count) => count + 1); return () => setLabelTargets((count) => count - 1); }, []); const field = useMemo( () => ({ controlId, labelId, describedBy: joinIds(hasDescription && descriptionId, hasHint && hintId, hasError && errorId), invalid: hasError, claimLabel, }), [ claimLabel, controlId, descriptionId, errorId, hasDescription, hasError, hasHint, hintId, labelId, ], ); const nested = depth > 0; const compact = controlWidth === "compact"; const hasControl = control !== undefined && control !== null && control !== false; // Where the control goes for this variant and width. const placement: "right" | "left" | "below" | "responsive" = !hasControl ? "right" : variant === "stacked" ? "below" : variant === "control-left" ? compact ? "left" : "below" : compact ? "right" : "responsive"; // Control left keeps one text column: rows without a leading switch leave its // column empty, so every label on the list starts at the same edge. const gutter = variant === "control-left" && placement !== "left" && !nested; // With the control under the text (stacked), the hint and error follow the control. const trailingNotes = placement === "below"; const notes = hasHint || hasError ? (
{hasHint ? (
{hint}
) : null} {hasError ? (

) : null}
) : null; const text = (
{hasDescription ? (

{description}

) : null} {trailingNotes ? null : notes}
); const controlSlot = hasControl ? (
{control}
) : null; return (
{placement === "left" ? ( <> {controlSlot} {text} ) : ( <> {text} {controlSlot} {trailingNotes ? notes : null} )}
{children ? (
{children}
) : null}
); } /** Rows of one section, split by hairlines. The Section variant decides whether they sit in a card. */ export function SettingRowGroup({ className, ...props }: ComponentProps<"div">) { return (
); } /** * A setting that lives on its own page ("Allowed models", "Models it can * serve"). The whole row is the button: label and description on the left, * the current value in muted text and a chevron on the right, and the row * hover of a list row. Use it instead of an "Edit" or "Change" button that * only opens a page. */ export function SettingNavRow({ label, description, value, onOpen, href, disabled = false, disabledReason, className, ...props }: Omit, "children"> & { label: ReactNode; description?: ReactNode | undefined; /** The current value, short: "All models", "3 models". */ value?: ReactNode | undefined; onOpen?: (() => void) | undefined; href?: string | undefined; disabled?: boolean | undefined; /** Why the page can't be opened, and who can fix it. Replaces the description. */ disabledReason?: ReactNode | undefined; }) { const baseId = useId(); const labelId = `${baseId}-label`; const descriptionId = `${baseId}-description`; const valueId = `${baseId}-value`; const secondary = disabled && disabledReason ? disabledReason : description; const hasValue = value !== undefined && value !== null && value !== false && value !== ""; const inner = ( <> {label} {secondary ? ( {secondary} ) : null} {hasValue ? ( {value} ) : null} {disabled ? null : ( ); const rowClass = cn( "-mx-3 grid min-h-14 w-[calc(100%+1.5rem)] min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-6 rounded-[10px] px-3 py-3 text-left", !disabled && "cursor-pointer transition-colors duration-[120ms] hover:bg-surface-2 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand/55 pointer-coarse:min-h-14", ); const labelledBy = joinIds(labelId, hasValue && valueId); const describedBy = secondary ? descriptionId : undefined; return (
{disabled ? (
{inner}
) : href ? ( {inner} ) : ( )}
); } /** * A quiet destructive action at the end of a settings list ("Turn off * Codex"): danger text that confirms in a dialog, with one muted line under * it. Not a filled button, and never a ⋯ menu floating above the rows. */ export function SettingDangerRow({ label, description, disabled = false, onClick, className, }: { label: ReactNode; description?: ReactNode | undefined; disabled?: boolean | undefined; onClick: () => void; className?: string | undefined; }) { const descriptionId = useId(); return (
{description ? (

{description}

) : null}
); } /** * The quiet "fix it" link under a row description: "Connect AI Gateway". * Renders an anchor when `href` is set, otherwise a button. */ export function SettingRowLink({ href, onClick, children, className, disabled = false, }: { href?: string | undefined; onClick?: (() => void) | undefined; children: ReactNode; className?: string | undefined; disabled?: boolean | undefined; }) { const classes = cn( "relative inline-flex min-h-6 items-center gap-1 rounded-sm text-xs font-medium og-link pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2.5", className, ); const content = ( <> {children}