{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"form-field","type":"registry:ui","title":"FormField","description":"Wraps a control with a label, help/error text and id wiring, so a screen reader gets the full picture without manual aria plumbing","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["forms"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/label.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/form-field/FormField.tsx","content":"\"use client\";\n\nimport { forwardRef, useMemo, useRef } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { FieldProvider, createFieldState } from \"../../internals/field\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { runTransition } from \"../../internals/motion/animate\";\nimport type { TransitionRun } from \"../../internals/motion/animate\";\nimport { preset } from \"../../internals/motion/transitions\";\nimport { prefersReducedMotion } from \"../../internals/motion/anchored\";\nimport { DURATIONS } from \"../../internals/motion/tokens\";\nimport { Label } from \"../label/index\";\nimport \"./form-field.css\";\n\n/**\n * FormField wraps a single control with a label, help/error text and the id\n * plumbing the field context publishes — see `internals/field.ts` for the\n * FieldContext contract every control in this wave reads through\n * `useField()`.\n */\nexport interface FormFieldProps {\n\t/**\n\t * Label text — the common case. For label content beyond plain text,\n\t * render a `Label` yourself as part of `children` instead: it resolves\n\t * `htmlFor` and `required` from this same FormField's context on its own,\n\t * so there is nothing extra to wire.\n\t */\n\tlabel?: string;\n\t/** Help text under the control, replaced by `error` while the field is invalid. */\n\tdescription?: string;\n\t/** Error text. Setting it marks the field invalid and replaces the help text. */\n\terror?: string;\n\t/**\n\t * Marks the field as passing validation — a decorative checkmark next to\n\t * the help text, plus `valid` on the field context for a control to draw\n\t * its own success look. `error` always wins if both are set. See the\n\t * README for why this stays decorative rather than growing its own\n\t * message the way `error` does.\n\t */\n\tvalid?: boolean;\n\t/** Marks the field required: the label gets an asterisk and the control gets `aria-required`. */\n\trequired?: boolean;\n\t/** Disables the field: reaches the wrapped control through context. */\n\tdisabled?: boolean;\n\t/** Opts out of the generated id. */\n\tid?: string;\n\t/** The control. */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\n// Both message paragraphs and the valid glyph arrive the same way: a small\n// grow-and-fade, so a message that appears under a field the user is typing\n// in reads as \"this just changed\" instead of as a layout jolt.\n//\n// An ENTRANCE only, never a two-way transition. The error and the\n// description are the two branches of ONE conditional, and each carries the\n// id that `aria-describedby` points at — an exit would leave a paragraph on\n// screen for 150ms after the control had already stopped describing it,\n// which is an accessibility hazard rather than a nicety. Entering only, the\n// outgoing branch is gone in the same commit the incoming one lands, so the\n// id wiring and the pixels never disagree. That is also why this component\n// runs `runTransition` by hand instead of reaching for `usePresence`: there\n// is no exit leg to own, and nothing here may ever delay an unmount.\n//\n// Module scope, not per instance: `preset()` is a pure factory returning a\n// pure function, so one instance serves every field.\nconst pop = preset(\"scale\");\n\n/** Which message paragraph, if any, is currently rendered. */\ntype MessageBranch = \"error\" | \"description\" | null;\n\n/**\n * A single form control's label, help/error text and id plumbing.\n *\n * The root element arrives through the ref channel, per PORTING.md — the\n * Svelte source declares `ref = $bindable(null)`. Rest props are not spread:\n * the Svelte source takes no rest props either.\n */\nexport const FormField = forwardRef<HTMLDivElement, FormFieldProps>(function FormField(\n\t{\n\t\tlabel,\n\t\tdescription,\n\t\terror,\n\t\tvalid = false,\n\t\trequired = false,\n\t\tdisabled = false,\n\t\tid,\n\t\tchildren,\n\t\tclassName,\n\t},\n\tref\n) {\n\t// A single per-instance seed, suffixed for the control and both message\n\t// ids — a page with ten fields has no collisions, from a single generator\n\t// call. `useFancyId()` (React's `useId()`) is the counterpart of the\n\t// Svelte side's `$props.id()`: one seed, N collision-free suffixes across\n\t// a page, identical in the server HTML and in the hydration render.\n\t// `uid()` is deliberately NOT used — it throws outside the browser by\n\t// design, and a FormField that only renders once JS has hydrated is not\n\t// on the table for a form primitive.\n\t//\n\t// `base` is that seed, or the caller's own `id` when given — either way,\n\t// every id this field hands out is a suffix of the same value, so a\n\t// caller who supplies `id=\"email\"` can predict `email-description` and\n\t// `email-error` too. That matters beyond tidiness: a control that isn't\n\t// context-aware still has a documented, stable way to wire itself up by\n\t// hand.\n\tconst uid = useFancyId();\n\tconst base = id ?? uid;\n\tconst controlId = id ?? `${uid}-control`;\n\tconst descriptionId = `${base}-description`;\n\tconst errorId = `${base}-error`;\n\t// Undefined, not a generated id with nothing pointing at it, while no\n\t// label is rendered — labelId exists specifically so a control's\n\t// aria-labelledby never targets an id with no element behind it, the same\n\t// rule describedBy already follows for help/error.\n\tconst labelId = label ? `${base}-label` : undefined;\n\n\tconst hasError = !!error;\n\t// Error replaces help text rather than stacking under it — the mockup\n\t// never shows both at once, and stale help sitting under a live error\n\t// would just be noise competing with the message that actually matters.\n\tconst hasDescription = !hasError && !!description;\n\t// `createFieldState` itself also enforces \"error wins\" on `field.valid` —\n\t// this local copy only needs to gate FormField's own decorative glyph\n\t// below, but is derived the same way so the two never disagree.\n\tconst showValid = valid && !hasError;\n\n\t// A field-by-field dependency array, never the options object, so the\n\t// published identity is stable across unrelated parent re-renders and\n\t// changes exactly when a consumer must re-render.\n\tconst field = useMemo(\n\t\t() =>\n\t\t\tcreateFieldState({\n\t\t\t\tcontrolId,\n\t\t\t\tlabelId,\n\t\t\t\tdescriptionId,\n\t\t\t\terrorId,\n\t\t\t\thasDescription,\n\t\t\t\thasError,\n\t\t\t\tvalid,\n\t\t\t\trequired,\n\t\t\t\tdisabled,\n\t\t\t}),\n\t\t[\n\t\t\tcontrolId,\n\t\t\tlabelId,\n\t\t\tdescriptionId,\n\t\t\terrorId,\n\t\t\thasDescription,\n\t\t\thasError,\n\t\t\tvalid,\n\t\t\trequired,\n\t\t\tdisabled,\n\t\t]\n\t);\n\n\tconst branch: MessageBranch = hasError ? \"error\" : hasDescription ? \"description\" : null;\n\n\tconst messageRef = useRef<HTMLParagraphElement | null>(null);\n\tconst glyphRef = useRef<HTMLSpanElement | null>(null);\n\tconst firstPassRef = useRef(true);\n\tconst previousBranchRef = useRef<MessageBranch>(null);\n\t// The glyph leg is gated on `showValid` FLIPPING, not on it being true, so\n\t// a second run of this effect over the same DOM — which is exactly what a\n\t// StrictMode mount rehearsal is — cannot pop a checkmark on a field that\n\t// merely rendered already-valid.\n\tconst previousValidRef = useRef(false);\n\n\t// A layout effect, per the effect-phase policy: intros start pre-paint,\n\t// and a passive effect would paint one frame at rest first.\n\t//\n\t// `prefersReducedMotion()` is called here, at the instant the leg starts,\n\t// rather than kept as state: the preference is read then, never at\n\t// construction and never during SSR. `duration: 0` makes `runTransition`\n\t// finish synchronously and never touch `element.animate()`.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tconst first = firstPassRef.current;\n\t\tfirstPassRef.current = false;\n\t\tconst previousBranch = previousBranchRef.current;\n\t\tpreviousBranchRef.current = branch;\n\t\tconst previousValid = previousValidRef.current;\n\t\tpreviousValidRef.current = showValid;\n\n\t\t// A field that renders with its message already present gets no\n\t\t// entrance: a local intro never plays on the first run of the block\n\t\t// that owns it, and only a real change should perform.\n\t\tif (first) return;\n\n\t\t// The handle is kept and aborted on finish, which drops the\n\t\t// `fill: forwards` the run leaves behind so the element falls back to\n\t\t// its resting style — the visible end state by construction. The same\n\t\t// handle is aborted from the cleanup, so a leg still in flight when the\n\t\t// branch swaps (or the field unmounts) does not keep running on a\n\t\t// paragraph that has left the DOM. On the reduced-motion duration-0\n\t\t// path `onFinish` fires before the assignment, and `run` is still\n\t\t// unset: aborting nothing is the right no-op there.\n\t\tlet run: TransitionRun | undefined;\n\n\t\tif (branch !== null && branch !== previousBranch) {\n\t\t\tconst el = messageRef.current;\n\t\t\tif (el) {\n\t\t\t\trun = runTransition(\n\t\t\t\t\tel,\n\t\t\t\t\tpop(el, { duration: prefersReducedMotion() ? 0 : DURATIONS.fast }, {\n\t\t\t\t\t\tdirection: \"in\",\n\t\t\t\t\t}),\n\t\t\t\t\t1,\n\t\t\t\t\tundefined,\n\t\t\t\t\t() => run?.abort()\n\t\t\t\t);\n\t\t\t}\n\t\t\t// The glyph's own conditional is created in the same pass as the\n\t\t\t// paragraph that owns it, so it skips a local intro here — which\n\t\t\t// is exactly right for a field that renders already-valid.\n\t\t\treturn () => run?.abort();\n\t\t}\n\n\t\t// A glyph-scale beat, not a message-scale one: it is one character\n\t\t// wide, so it gets `micro` (80ms) rather than the paragraph's 150ms.\n\t\t// It only animates when `valid` FLIPS while the help text is ALREADY\n\t\t// on screen — the flip, not the current value, so re-running this\n\t\t// effect over unchanged props is silent.\n\t\tif (\n\t\t\tbranch === \"description\" &&\n\t\t\tpreviousBranch === \"description\" &&\n\t\t\tshowValid &&\n\t\t\t!previousValid\n\t\t) {\n\t\t\tconst el = glyphRef.current;\n\t\t\tif (el) {\n\t\t\t\trun = runTransition(\n\t\t\t\t\tel,\n\t\t\t\t\tpop(el, { duration: prefersReducedMotion() ? 0 : DURATIONS.micro }, {\n\t\t\t\t\t\tdirection: \"in\",\n\t\t\t\t\t}),\n\t\t\t\t\t1,\n\t\t\t\t\tundefined,\n\t\t\t\t\t() => run?.abort()\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\treturn () => run?.abort();\n\t}, [branch, showValid]);\n\n\treturn (\n\t\t<FieldProvider value={field}>\n\t\t\t<div ref={ref} className={cn(\"ft-form-field flex flex-col gap-1.5\", className)}>\n\t\t\t\t{label ? (\n\t\t\t\t\t// No htmlFor/required passed explicitly: Label reads both\n\t\t\t\t\t// straight off this same context, which is the entire point of\n\t\t\t\t\t// publishing it.\n\t\t\t\t\t<Label>{label}</Label>\n\t\t\t\t) : null}\n\n\t\t\t\t{children}\n\n\t\t\t\t{/* Keyed on the branch so a swap replaces the paragraph rather\n\t\t\t\t    than mutating one in place — the outgoing message and its id\n\t\t\t\t    leave together, in the same commit the incoming one lands. */}\n\t\t\t\t{branch === \"error\" ? (\n\t\t\t\t\t<p\n\t\t\t\t\t\tkey=\"error\"\n\t\t\t\t\t\tref={messageRef}\n\t\t\t\t\t\tid={errorId}\n\t\t\t\t\t\t// An error that appears while focus is already inside the\n\t\t\t\t\t\t// control is a change to described-by content, which a\n\t\t\t\t\t\t// screen reader is not required to re-announce — the user\n\t\t\t\t\t\t// would see the field turn invalid and hear nothing. The\n\t\t\t\t\t\t// branch is keyed, so a new message is a fresh mount and\n\t\t\t\t\t\t// `role=\"alert\"` announces it without extra bookkeeping.\n\t\t\t\t\t\t// The description paragraph stays silent: it is read on\n\t\t\t\t\t\t// focus, and announcing help text as it arrives would talk\n\t\t\t\t\t\t// over the user.\n\t\t\t\t\t\trole=\"alert\"\n\t\t\t\t\t\tclassName=\"ft-form-field-message text-destructive flex items-center gap-1.5 text-[12px]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<span aria-hidden=\"true\">✕</span> {error}\n\t\t\t\t\t</p>\n\t\t\t\t) : branch === \"description\" ? (\n\t\t\t\t\t<p\n\t\t\t\t\t\tkey=\"description\"\n\t\t\t\t\t\tref={messageRef}\n\t\t\t\t\t\tid={descriptionId}\n\t\t\t\t\t\tclassName=\"ft-form-field-message text-muted-foreground flex items-center gap-1.5 text-[12px]\"\n\t\t\t\t\t>\n\t\t\t\t\t\t{showValid ? (\n\t\t\t\t\t\t\t// Decorative reinforcement of the help text next to it,\n\t\t\t\t\t\t\t// not a replacement for it — see the README for why this\n\t\t\t\t\t\t\t// doesn't grow its own message the way the error state\n\t\t\t\t\t\t\t// does.\n\t\t\t\t\t\t\t<span aria-hidden=\"true\" className=\"ft-form-field-valid-glyph\" ref={glyphRef}>\n\t\t\t\t\t\t\t\t✓\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t) : null}{\" \"}\n\t\t\t\t\t\t{description}\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t</FieldProvider>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/form-field/FormField.tsx"},{"path":"react/src/components/form-field/form-field.css","content":"/* Ported verbatim from the Svelte source's <style> block. The rule was\n   already anchored on the component's own `ft-form-field-*` class name, so no\n   port-added anchor was needed. */\n\n/*\n * `--ft-status-done` is the family's \"operation landed\"/success vocabulary\n * — the same token CopyButton, ToolCall, AgentPlan, ContextRing and others\n * read. Reusing it (fallback hue included, not a fresh literal) means a\n * validated field sitting near any of those on the same page reads as one\n * palette, and retinting the token once moves every success surface in\n * the library together, this one included.\n */\n.ft-form-field-valid-glyph {\n\tcolor: var(--ft-status-done, light-dark(oklch(0.5 0.14 145), oklch(0.72 0.15 145)));\n}\n","type":"registry:ui","target":"@ui/fancy/components/form-field/form-field.css"},{"path":"react/src/components/form-field/index.ts","content":"\"use client\";\n\nexport { FormField } from \"./FormField\";\nexport type { FormFieldProps } from \"./FormField\";\n","type":"registry:ui","target":"@ui/fancy/components/form-field/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/form-field?framework=react"}}