Skip to content

Latest commit

 

History

History
155 lines (106 loc) · 26.2 KB

input-components.md

File metadata and controls

155 lines (106 loc) · 26.2 KB

Input Component Rules

Rules for input components — components that can be attached to a <Form>. An input component is anything that calls useFieldProps and accepts a name prop, e.g. TextInput, Select, ComboBox, Checkbox, RadioGroup, Switch, Slider, DatePicker, FileInput, ListBox, Picker.

All input components live under src/components/fields/{ComponentName}/.

1. Prop contract

  • Extend FieldBaseProps<Value> (the control’s accepted model value, including supported empty values) from src/shared/index (it already includes FormBaseProps + FieldCoreProps). Never redeclare shared field props (label, name, isRequired, isDisabled, description, rules, …).
  • Name the props interface Cube{ComponentName}Props.
  • Mix in FieldLayoutStyleProps (form/field-layout.ts, tasty's position and dimension props plus controlWidth) and no other style-prop interface. An input's style props place and size its outer element (§3), and controlWidth the control; everything else goes through styles and the part props (inputStyles, triggerStyles, …).
  • Never give the control, or a part of it, a prop named like one of RESERVED_FIELD_PROPS (form/field-chrome.ts): wrapWithField reads those names off the input's props, so the chrome would render it too. The control's own version takes its part's prefix (triggerDescription, inputStyles) or a name the chrome does not own: tooltip is the control's tooltip, as on Select, Picker, Button and Item, and the label's badge is labelTooltip. SHARED_FIELD_PROPS (id, isDisabled, isRequired, isInvalid, isValid) are read by both, by design. Until the legacy tooltip warning goes, wrapWithField also reads tooltip to warn, so an input whose control takes tooltip passes tooltip: undefined to wrapWithField, as Select, Picker, FilterPicker, ColorPicker, Checkbox, Switch and the text inputs (TextInputBase) do. field prop names in form/wrapper.test.tsx checks that the wrapper reads nothing else but tooltip and that every input renders the reserved texts in its field, never in its control.

Validation props

  • Use isInvalid and isValid booleans. Never add or read validationState.
  • validationState is a deprecated compatibility prop. It is normalized into isInvalid/isValid by useFieldProps and stripped from the resolved props, so component bodies never see it.
  • Precedence (implemented once in resolveValidationProps):
    1. explicit isInvalid / isValid
    2. explicit validationState (logs a deprecation warning in dev)
    3. form-derived state (field errors, or showValid + a valid field status)
  • isInvalid wins over isValid when both resolve to true.
  • Pass isInvalid straight through to React Aria hooks — they accept it natively and have deprecated validationState themselves.

2. Hooks

useFieldProps is the single entry point. It already applies useProviderProps and useFormProps internally, so a component body needs one call:

function MyField(props: CubeMyFieldProps, ref) {
  props = useFieldProps(props, {
    defaultValidationTrigger: 'onBlur',
    valuePropsMapper: ({ value, onChange }) => ({ value: value ?? '', onChange }),
  });

  // props.id is set, props.labelProps.for matches it,
  // props.isInvalid / props.isValid are resolved booleans.
}

Rules:

  • Do not call useProviderProps or useFormProps manually in an input component.
  • Do not generate your own useId() and do not set labelProps.for — useFieldProps owns both.
  • Do not call useField directly. It is internal to useFieldProps and to the legacy <Field>.
  • If a component must read the raw props before context merging (e.g. Checkbox distinguishing its own props from group context), capture originalProps = props before the useFieldProps call.

useFieldProps params

  • defaultValidationTrigger — 'onChange' for toggles and selection controls (Checkbox, Switch, Radio, Select, ListBox, FileInput), 'onBlur' for free-text controls (TextInput, TextArea, NumberInput, DateInput).
  • valuePropsMapper — maps the form value onto the component's own value API. Required whenever the component does not use value/onChange verbatim (selectedKey/onSelectionChange, isSelected/onChange, selectedKeys, …).
  • unsafe__isDisabled — opts out of field wiring for the lifetime of the mount. Only for components that can be nested inside a group that owns the form connection (Checkbox inside CheckboxGroup). The value must be stable across renders.
  • showsValidateErrors — the control shows the errors of its validate under itself (TextInput, TextArea, PasswordInput, NumberInput), so a validate asks for the field chrome without a label.

Modes

useFieldProps calls the same hooks in every mode — including useField, the legacy backend's adapter — and the mode only decides which props come back:

Mode Condition What comes back
Legacy bound name is set, not inside the deprecated <Field>, not unsafe__isDisabled The field's value/onChange/onBlur and validation state merged over the props, with an incremental id derived from the form name and the field name (email, email_1, settings_email, …)
Modern bound A modern controller plus name, not inside <Field> or disabled Per-field value/error/status subscription, commit-time registration, and a name-prefixed React useId() id; no creator or unrelated-field subscription
Standalone no name The caller's own value/onChange untouched, plus an id from React's useId() and matching label props; useField runs inert and registers nothing
Inside <Field> / Form.Item useInsideLegacyField() The props as given: the wrapper already did the binding
Disabled unsafe__isDisabled The props as given

For the legacy backend, because every hook runs every time, an input may gain or lose name, or receive a form later, without a hook-order error. Gaining a name, or a form, binds like a first mount: a new field with the field-level defaultValue as value and baseline. The id is derived from the surrounding form's name prefix and the field name, so it changes with name (and with the prefix), not when a form arrives. A value typed while the input was standalone is not carried into the field, and React Aria logs its development warning about switching between uncontrolled and controlled — both expected. Losing the name releases the field. A form prop that changes registers the field with the new form and the old form keeps it while the input is mounted (legacy contract, row 4); losing the form prop while keeping the name leaves the field in that form too, and a form that comes back reuses it; unmounting releases the name from every form it was bound to. Do not add a hook to useFieldProps behind a condition and do not return before the hooks.

When a props interface extends a react-aria props type that carries the DOM form?: string attribute (text fields, number field, checkbox, switch, radio group, slider, date and time fields), keep the kit's form (the instance) on top: add 'form' to the Omit<Aria…Props, …> the interface already uses, or use Omit<Aria…Props, 'form'> before extending the Aria type directly. Adobe's consolidated types are checked in-repo, including with preserveSymlinks. The consumer-facing fixture typecheck/consumer/form-instance.test-d.tsx, compiled against dist/ by pnpm test:types:consumer, renders every form-attachable input with form={instance} and fails when the DOM attribute wins.

Components must apply the current id from props to the element the label points at. React Aria hooks seed their ids once (useId(props.id)), so a component that hands props.id only to the hook keeps the mount-time id after a rebind; TextInput gets this for free from useTextField, NumberInput merges { id: props.id } into inputProps and SliderBase sets id after spreading groupProps.

The form itself comes from the form prop when it is set, and from FormContext otherwise. That makes <TextInput name="email" form={form} /> a supported way to link an input to a form it is not nested in, and to override the surrounding form. Keep form in the props of every form-attachable component and always pass the whole props object to useFieldProps so this keeps working.

Form instances are branded (see Form/backend.ts). Form.useController() creates the modern controller; named inputs and custom controls using useFieldProps bind through use-field-binding.ts. The legacy adapter runs inert on this path. Modern registration and option updates happen in layout effects, never during render. A changed name/controller releases the old registration immediately; values remain retained by default. preserve={false} removes an inactive value after one microtask so Strict Mode effect replay can reconnect safely; reset can restore its baseline. Controller values/defaults, including explicit undefined and null, precede field defaults. Every duplicate input has its own stable React id and registration token. Only value, errors, or status changes rerender the input; metadata belongs in Form.useSelector or Form.Subscribe.

Form.Item remains legacy-only: modern custom controls use useFieldProps and wrapWithField directly. Do not call a controller getter during render to implement a reactive input. Modern roots expose their controller through useFormProps so named inputs cannot silently bind to an outer legacy form. Standalone inputs and explicit form={undefined} remain detached. Legacy roots and FormScopeMask mask modern context. The shared hook strips the controller from resolved input props before passing them to React Aria, whose form attribute is a DOM id. A legacy instance survives the hook, because legacy custom controls read it, so see DOM conventions before handing resolved props to a React Aria hook. Modern rules run after field events or explicit controller validation, with AbortSignal cancellation and errorPolicy ordering. Forward deps for external validator inputs and dependsOn for declared field dependencies; rulesKey opts into manual function versioning and skips source comparison; declarative constraints are always compared. deps alone does not disable source comparison. Validator reads cancel in-flight work but only declared dependsOn paths schedule dependent revalidation. Equivalent inline rules must not restart pending work. Root and action helpers use selector subscriptions; never read mutable legacy flags from a modern controller.

FormScopeMask (exported) scopes the inputs below it to a new form context: Radio/Checkbox groups render it around their options so a nested name never registers as an independent field. The groups also render a <Provider isDisabled isReadOnly> with their own resolved state, because each option is a field that reads the provider before the form context: without it, an enclosing disabled form beats a group's isDisabled={false} on every option. <Provider> skips undefined, so a group that sets nothing keeps the form's state. A wrapper that overrides FormContext for the same purpose must render FormScopeMask instead of a bare FormContext.Provider, because presentation props (labelPosition, idPrefix, …) also travel through a separate context that only the mask resets.

useFormProps stays a public export because wrappers outside the UI Kit call it to read the form context and then hand adjusted props to a nested input. Since it merges as { ...presentationContext, ...FormContext, ...props } (the two contexts carry the same presentation values under a legacy root), and useFieldProps applies it again, any prop the wrapper sets explicitly wins — but a deleted key falls back to the context value, and so does a provider state prop (isDisabled, isReadOnly, isRequired, isInvalid, isValid) set to undefined, which useProviderProps treats as unset. To detach a nested input from the form, pass form={undefined} (or null) explicitly, or omit name; destructuring form away is not enough.

3. Field chrome

  • Wrap the control with wrapWithField(control, props) as the return value. It renders a FieldWrapper (label, description, message, necessity indicator) when the field has something to show, and the bare control otherwise (rendersFieldChrome in form/wrapper.tsx). An explicit isBare decides first: true omits field layout and text slots, preserving any supplied label as a visually hidden accessible label with the merged label id/for, and false renders the chrome. Bare labels contain no interactive decorations. Unset, a non-empty label renders the chrome, as does the deprecated forceField.
  • useFieldProps asks for the chrome with isBare={false} (withFieldChrome in form/field-chrome.ts, which keeps an explicit isBare) on a field bound to a form, whose validation errors need a place, or whose caller passes description, errorMessage, message, fieldStyles, fieldProps or isHidden (asksForFieldChrome). SearchInput and SearchComboBox, which skip useFieldProps, ask the same way. TagInput asks with withFieldChrome itself for its refusals. TextInput, TextArea, PasswordInput and NumberInput pass showsValidateErrors, so a validate asks too; other controls do not show its errors.
  • Never decide wrapper presence from message content. A prop counts whatever it holds, undefined, null and '' too, so a message that comes and goes never moves the control in or out of the wrapper: React remounts a re-parented control, and it loses focus and its draft mid-keystroke. Read the caller's own props, since a form binding adds props of its own. label is the one exception: it counts by content, so an empty or undefined label is no label. A child of the legacy <Field> counts as bound when the <Field> gives it a name; its message props come and go with the error, so they decide nothing. A nameless legacy Form.Item already owns its child's chrome, so its child stays bare. Parent-owned controls (nested lists, date/time segments, table checkboxes and compact selectors) pass isBare explicitly, after any spread of the parent's resolved props, which can carry isBare={false}; existing grouped Checkbox/Radio rendering paths stay inline.
  • errorMessage takes precedence when it is defined, including explicit null/false/empty suppression; otherwise fall back to deprecated message. Keep description independent. Do not change the legacy or modern binding adapters to implement presentation precedence.
  • The field's ref is its control's: attach it, or the object ref behind its imperative handle, to the control's own root element, so it is the same element with or without the chrome and never null on a bare field. The chrome takes no ref. labelRef reaches the label, visible or visually hidden, and fieldProps={{ ref }} the wrapper. The deprecated wrapWithField(control, ref, props) ignores its ref.
  • wrapWithField destructures the wrapper props itself. Pass the full props object; do not hand-pick keys and do not blank out form.
  • Shorthands are merged by the wrapper: fieldStyles over fieldProps.styles (as style layers, so a null reset survives) and labelStyles → labelProps.styles. Do not merge them in the component. fieldProps.mods adds to the mods the wrapper computes.
  • Route the style props with splitFieldLayout(props, options) (form/field-layout.ts), called on the props passed to wrapWithField (after useFieldProps or withFieldChrome). Render the control with the styles it returns and pass { ...props, ...field } to wrapWithField. Bare, the style props join the control's styles. With the chrome they go to the wrapper, over fieldStyles, and a wrapper they size gets fill-width / fill-height: its input area becomes a one-column grid, so the control stretches unless its own CSS fixes or caps its size. fieldStyles never sets them. styles always stays on the control, and controlWidth (declared on FieldLayoutStyleProps) sets its width over styles, with or without the chrome. Options: hasChrome: false on a path that never wraps (a grouped Checkbox), fillHeightOnFlex for a control that grows with its flex line when bare (lists, text areas), defaultStyles for the control's own styles under styles, and overStyles for a prop that styles the same element over styles (wrapperStyles on the date pickers), so controlWidth still wins over it. The field layout style props matrix in form/wrapper.test.tsx runs over every input.
  • The id belongs on the interactive control, never on the field wrapper.
  • Put a React Aria hook's group props (radioGroupProps, groupProps) on the control's own element, not in fieldProps: a field rendered bare has no wrapper, and the role, name, keyboard and focus handlers go with it. RadioGroup, CheckboxGroup, ColorSwatchGroup and SliderBase do this.

4. Base components are presentational

TextInputBase, DateInputBase and similar *Base components render markup only. They must not call useFieldProps, useFormProps, useProviderProps or useField. Form wiring belongs to the public component that renders the base (TextInput, TextArea, PasswordInput, NumberInput, CommandTextArea). Wiring a field in both the wrapper and the base registers the field twice.

5. Search controls are not form fields

SearchInput and SearchComboBox look like inputs but are not form-attachable. They drive local UI filtering, so they never register with a form. They call useProviderProps + useValidationProps instead of useFieldProps, which gives them the shared validation props and provider defaults without registering a field. Do not add useFieldProps to them.

6. Shared validation helpers

Import from src/components/form/validation:

  • getValidationMods({ isInvalid, isValid }) → { invalid, valid } for tasty mods.
  • getValidationTheme(theme, { isInvalid, isValid }) → 'danger' / 'success' / the passed theme, for trigger buttons and items.
  • <ValidationIndicator isInvalid isValid isLoading /> — renders the data-element="State" suffix block (validation icon or loading spinner). Use hasValidationIndicator() to decide whether a data-element="Suffix" container is needed at all.
  • resolveValidationProps(props) / useValidationProps(props) — normalization. Only useFieldProps and components outside the field pipeline should need these.

Do not hand-write { invalid: …, valid: … } mods or validation-icon markup.

Validation support matrix

Every input component renders both states. When you add a new one, copy the reference that matches its shape instead of inventing a new treatment. Mirror the danger token the component already uses with its success counterpart (#danger → #success, #danger-text.50 → #success-text.50) rather than picking a new color.

  • Input chrome — valid / invalid border on INPUT_WRAPPER_STYLES plus a <ValidationIndicator> suffix. Reference: TextInputBase. Used by TextInput, TextArea, PasswordInput, NumberInput, CommandTextArea, SearchInput, SearchComboBox, ComboBox, TagInput, and — through DateInputBase — DateInput, TimeInput, DatePicker, DateRangePicker, DateRangeSeparatedPicker. FileInput reuses the same border tokens and indicator on its Action-based control.
  • Trigger button — getValidationTheme for the danger theme plus getValidationIcon appended to the trigger's suffix. Reference: Select. Used by Picker and FilterPicker.
  • Listbox border — valid / invalid border on the list container. Reference: ListBox. Used by FilterListBox. ListBox items additionally map the state onto an item theme via getValidationTheme(…, { includeValid: true }).
  • Control fill and border — getValidationMods on the control element with matching fill / border / color entries. Reference: Checkbox. Used by Switch and Radio. CheckboxGroup and RadioGroup own no chrome of their own; they publish the state through FormContext and the items render it. Button-shaped radios go through getValidationTheme('default', …, { includeValid: true }).
  • Track and thumb fill — Slider and RangeSlider. The state is resolved inside SliderBase, so it reaches SliderThumb / SliderTrack through SliderBaseChildArguments, not through the outer props.
  • Delegated to inner inputs — TextInputMapper forwards isInvalid / isValid to its KeyComponent and ValueComponent.

HueSlider is not form-attachable (it never calls useFieldProps) and has no validation state.

7. DOM conventions

  • Set data-input-type on the interactive element (textinput, checkbox, datetimeinput, …).
  • Default the qa prop of the control to the component name: qa={qa || 'Checkbox'}.
  • Use ComponentBaseProps (or ComponentBasePropsWithoutChildren) for a field that owns its root, and use filterComponentBaseProps for generic DOM forwarding. Keep className and style for integrations. Declare theme or title separately when the component gives the prop a defined meaning.
  • Keep the kit's form (the form instance) out of every React Aria hook that forwards form to a native element as a DOM form id: useTextField, useCheckbox / useCheckboxGroupItem, useSwitch, useCheckboxGroup, useRadioGroup (both group hooks hand it to every item, ColorSwatchGroup included) and useSelect (for HiddenSelect). The date and time field hooks forward it too, to hidden inputs the kit does not render today, and useSliderThumb forwards its own form to the range input the kit does render: the instance stays off it only because Slider and RangeSlider build each SliderThumb from an explicit prop list without form. Destructure form out of the props the hook receives, or pass form: undefined. Otherwise a legacy instance renders as form="[object Object]", which detaches the input from its <form>, so Enter no longer submits it. explicit-form-prop.test.tsx checks every form-attachable input for a leaked form attribute under both backends, and field-accessibility.test.tsx checks that a screen reader hears each one's description and error and that aria-label alone names it. Both run over FORM_ATTACHABLE_INPUTS in src/test/form-attachable-inputs.tsx: add new inputs there, and a separate case for controls that only mount in a popover. React Aria before 3.48 also had a FocusScope failure for a lone unchecked radio in a dialog form; the upgraded dependency includes that upstream fix.
  • Take the control's styles from splitFieldLayout (§3), not extractStyles.
  • With autoFocus, also set data-autofocus on the element that takes focus, or on a wrapper whose first tabbable element does, and only then, so a consumer's own mark survives. This holds for actions (useAction) and CommandMenu too. A hideOnClose dialog mounts its content once, hidden, and focuses the last marked element on every open; DialogContainer.test.tsx covers each kind of marked control.

8. Deliverables per input component

Every input component ships:

  • ComponentName.tsx — implementation following the rules above
  • index.tsx — re-export, wired into the category barrel and src/index.ts
  • ComponentName.stories.tsx — shared argTypes from src/stories/FormFieldArgs.ts (use VALIDATION_ARGS rather than re-declaring isInvalid / isValid); size the whole field with style props (width) in meta args, and the control alone with controlWidth; existing fieldStyles sizing still works; export a single Validation story rendering the valid and invalid case together (see storybook.md)
  • ComponentName.docs.mdx — links Supports all [Field properties](/docs/getting-started-field-properties--docs) instead of duplicating field props (see documentation.md)
  • ComponentName.test.tsx — uses renderWithForm for form integration and renderWithRoot otherwise (see tests.md)

After changing an input component's API, run pnpm audit-docs --component=ComponentName.

Typed modern descriptors

field={form.field(path, options)} carries controller/path/options into the shared hook, including nested tuple paths. Construction is pure; registration stays in the commit effect. useFieldProps resolves descriptor identity/options and strips field, deps, and dependsOn before DOM forwarding. Its return type omits them too, and drops the modern controller from form's type, since the controller is stripped as well: a form declared as FormController<…> comes back undefined, and CubeFormInstance | FormController<…> comes back CubeFormInstance | undefined. A form typed any, as in FieldBaseProps, stays any and is not checked. So a custom control that needs the controller declares form in an interface that extends FieldBaseProps (an intersection FieldBaseProps & { form?: … } is any again) and reads it from its own props (typecheck/consumer/use-field-props-result.test-d.tsx). Keep the base value API correct (booleans for toggles, numbers for numeric controls, selection keys for selectors). The descriptor generic describes accepted model values, not only emitted changes: include null | undefined when the binding handles missing values. Normalize only for display; never write the fallback into the form on mount. Add nullable-model consumer fixtures and preserve nulls through submission/reset in runtime tests. Do not add another registration path or read controller getters during render. Existing name bindings and legacy rule props keep their compatibility contract.

Tuple field names

Modern inputs accept name={readonlyTuple} without a descriptor. The shared binding retains the path for store access and converts it to an escaped string for DOM names/ids; do not coerce arrays with String() or .join() in a component. useFieldProps returns a string name, including standalone/masked inputs, so custom controls can safely forward resolved props. Native React Aria name types must not narrow public field names back to string: omit name alongside form, or explicitly redeclare the kit's name where the Aria interface is extended directly. Legacy bindings accept string names only.