import { createContext, forwardRef, useContext, useId, useLayoutEffect, useMemo, useRef, type ComponentProps, type ReactNode, } from "react"; import { CheckIcon, CircleAlertIcon, MinusIcon } from "lucide-react"; import { cn } from "../lib/cn"; /* ---------------------------------------------------------------------------- Field - one labelled control in a vertical form (brief 7.15). Label 14/500 bound to the control, an optional "Optional" marker, a 12px hint, and an inline error that replaces the hint and sets aria-invalid on the control. Controls inside a Field pick up their id, aria-describedby, aria-invalid and aria-required from context, so a form never needs to wire ids by hand. -------------------------------------------------------------------------- */ interface FieldContextValue { controlId: string; labelId: string; /** Ids of the visible hint or error, for aria-describedby. */ describedBy: string | undefined; invalid: boolean; required: boolean; disabled: boolean; } const FieldContext = createContext(null); /** The enclosing Field, or null. Use it to label custom group controls. */ export function useField(): FieldContextValue | null { return useContext(FieldContext); } /** Accessibility props for the control inside a Field. Explicit props win. */ export function useFieldControlProps(): { id?: string | undefined; "aria-describedby"?: string | undefined; "aria-invalid"?: true | undefined; "aria-required"?: true | undefined; disabled?: true | undefined; } { const field = useField(); if (!field) return {}; return { id: field.controlId, "aria-describedby": field.describedBy, "aria-invalid": field.invalid ? true : undefined, "aria-required": field.required ? true : undefined, disabled: field.disabled ? true : undefined, }; } export function Field({ label, hint, error, optional = false, required = false, disabled = false, aside, group = false, id, className, children, }: { /** 14/500, sentence case, names what is being entered. */ label: ReactNode; /** One 12px line under the control. Hidden while an error shows. */ hint?: ReactNode | undefined; /** Inline error: what is wrong and how to fix it. Sets aria-invalid. */ error?: ReactNode | undefined; /** Shows a quiet "Optional" marker after the label. */ optional?: boolean | undefined; required?: boolean | undefined; disabled?: boolean | undefined; /** Right-aligned next to the label, for example a character count. */ aside?: ReactNode | undefined; /** * For controls that are a group (segmented control, radio cards): the label * becomes the group's name through aria-labelledby instead of htmlFor. */ group?: boolean | undefined; /** Control id. Generated when omitted. */ id?: string | undefined; className?: string | undefined; children: ReactNode; }) { const generatedId = useId(); const controlId = id ?? `${generatedId}-control`; const labelId = `${generatedId}-label`; const hintId = `${generatedId}-hint`; const errorId = `${generatedId}-error`; const invalid = Boolean(error); const showHint = Boolean(hint) && !invalid; const describedBy = invalid ? errorId : showHint ? hintId : undefined; const labelContent = ( <> {label} {optional ? ( Optional ) : null} ); const context = useMemo( () => ({ controlId, labelId, describedBy, invalid, required, disabled }), [controlId, labelId, describedBy, invalid, required, disabled], ); return (
{group ? ( {labelContent} ) : ( )} {aside ?
{aside}
: null}
{children} {invalid ? (

) : showHint ? (

{hint}

) : null}
); } /** Vertical rhythm for a form: 24px between fields. */ export function FieldStack({ className, children }: { className?: string; children: ReactNode }) { return
{children}
; } /* ---------------------------------------------------------------------------- Controls. Token styled; they read their Field for ids and aria. `rounded-md!` keeps the 10px radius when the global focus outline applies. -------------------------------------------------------------------------- */ const controlBase = "block w-full min-w-0 rounded-md! border border-border bg-surface text-sm text-fg transition-colors duration-[120ms] outline-offset-2 placeholder:text-fg-subtle hover:border-border-strong focus-visible:border-brand disabled:cursor-not-allowed disabled:border-border disabled:bg-surface-2 disabled:text-fg-muted read-only:bg-surface-2 read-only:hover:border-border aria-invalid:border-danger aria-invalid:hover:border-danger pointer-coarse:text-base"; export const TextInput = forwardRef< HTMLInputElement, ComponentProps<"input"> & { /** JetBrains Mono, for names, IDs and code. */ mono?: boolean | undefined; /** Resource names are not a person's identity or a saved credential. */ suppressAutofill?: boolean | undefined; } >(function TextInput( { className, mono = false, suppressAutofill = false, type = "text", ...props }, ref, ) { const fieldProps = useFieldControlProps(); return ( ); }); export const TextArea = forwardRef< HTMLTextAreaElement, ComponentProps<"textarea"> & { mono?: boolean | undefined; } >(function TextArea({ className, mono = false, rows = 3, style, ...props }, ref) { const fieldProps = useFieldControlProps(); return (