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}/.
- Extend
FieldBasePropsfromsrc/shared/index(it already includesFormBaseProps+FieldCoreProps). Never redeclare shared field props (label,name,isRequired,isDisabled,description,rules, …). - Name the props interface
Cube{ComponentName}Props. - Mix in tasty style-prop interfaces (
ContainerStyleProps,OuterStyleProps, …) as needed.
- Use
isInvalidandisValidbooleans. Never add or readvalidationState. validationStateis a deprecated compatibility prop. It is normalized intoisInvalid/isValidbyuseFieldPropsand stripped from the resolved props, so component bodies never see it.- Precedence (implemented once in
resolveValidationProps):- explicit
isInvalid/isValid - explicit
validationState(logs a deprecation warning in dev) - form-derived state (field errors, or
showValid+ a valid field status)
- explicit
isInvalidwins overisValidwhen both resolve totrue.- Pass
isInvalidstraight through to React Aria hooks — they accept it natively and have deprecatedvalidationStatethemselves.
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
useProviderPropsoruseFormPropsmanually in an input component. - Do not generate your own
useId()and do not setlabelProps.for—useFieldPropsowns both. - Do not call
useFielddirectly. It is internal touseFieldPropsand to the legacy<Field>. - If a component must read the raw props before context merging (e.g.
Checkboxdistinguishing its own props from group context), captureoriginalProps = propsbefore theuseFieldPropscall.
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 usevalue/onChangeverbatim (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 (CheckboxinsideCheckboxGroup). The value must be stable across renders.
useFieldProps behaves differently depending on name:
| Mode | Condition | Behaviour |
|---|---|---|
| Form-connected | name is set |
Calls useField: registers the field, owns value/onChange/onBlur, derives validation state, generates an incremental id from the field name (email, email_1, …) |
| Standalone | no name |
Does not call useField. Preserves the caller's value/onChange and only generates an id via React's useId() |
Never call useField for standalone fields — the extra state management breaks controlled components.
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.
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 { ...context, ...props }, and useFieldProps
applies it again, any prop the wrapper sets explicitly wins — but a deleted key falls back to the context
value. To detach a nested input from the form, pass form={undefined} (or null) explicitly, or omit name;
destructuring form away is not enough.
- Wrap the control with
wrapWithField(control, domRef, props)as the return value. It renders aFieldWrapper(label, description, message, necessity indicator) whenlabelorforceFieldis set, and returns the bare control otherwise. wrapWithFielddestructures the wrapper props itself. Pass the fullpropsobject; do not hand-pick keys and do not blank outform.- Shorthands are merged by the wrapper:
fieldStyles→fieldProps.styles,labelStyles→labelProps.styles. Do not merge them in the component. - The
idbelongs on the interactive control, never on the field wrapper.
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.
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.
Import from src/components/form/validation:
getValidationMods({ isInvalid, isValid })→{ invalid, valid }for tastymods.getValidationTheme(theme, { isInvalid, isValid })→'danger'/'success'/ the passed theme, for trigger buttons and items.<ValidationIndicator isInvalid isValid isLoading />— renders thedata-element="State"suffix block (validation icon or loading spinner). UsehasValidationIndicator()to decide whether adata-element="Suffix"container is needed at all.resolveValidationProps(props)/useValidationProps(props)— normalization. OnlyuseFieldPropsand components outside the field pipeline should need these.
Do not hand-write { invalid: …, valid: … } mods or validation-icon markup.
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/invalidborder onINPUT_WRAPPER_STYLESplus a<ValidationIndicator>suffix. Reference:TextInputBase. Used byTextInput,TextArea,PasswordInput,NumberInput,CommandTextArea,SearchInput,SearchComboBox,ComboBox, and — throughDateInputBase—DateInput,TimeInput,DatePicker,DateRangePicker,DateRangeSeparatedPicker.FileInputreuses the same border tokens and indicator on itsAction-based control. - Trigger button —
getValidationThemefor the danger theme plusgetValidationIconappended to the trigger'ssuffix. Reference:Select. Used byPickerandFilterPicker. - Listbox border —
valid/invalidborder on the list container. Reference:ListBox. Used byFilterListBox.ListBoxitems additionally map the state onto an item theme viagetValidationTheme(…, { includeValid: true }). - Control fill and border —
getValidationModson the control element with matchingfill/border/colorentries. Reference:Checkbox. Used bySwitchandRadio.CheckboxGroupandRadioGroupown no chrome of their own; they publish the state throughFormContextand the items render it. Button-shaped radios go throughgetValidationTheme('default', …, { includeValid: true }). - Track and thumb fill —
SliderandRangeSlider. The state is resolved insideSliderBase, so it reachesSliderThumb/SliderTrackthroughSliderBaseChildArguments, not through the outer props. - Delegated to inner inputs —
TextInputMapperforwardsisInvalid/isValidto itsKeyComponentandValueComponent.
HueSlider is not form-attachable (it never calls useFieldProps) and has no validation state.
- Set
data-input-typeon the interactive element (textinput,checkbox,datetimeinput, …). - Default the
qaprop of the control to the component name:qa={qa || 'Checkbox'}. - Spread through
filterBaseProps(otherProps)so style props never reach the DOM. - Use
extractStyles(props, STYLE_PROP_LIST)for the root styles.
Every input component ships:
ComponentName.tsx— implementation following the rules aboveindex.tsx— re-export, wired into the category barrel andsrc/index.tsComponentName.stories.tsx— shared argTypes fromsrc/stories/FormFieldArgs.ts(useVALIDATION_ARGSrather than re-declaringisInvalid/isValid); set a defaultwidthin meta args; export a singleValidationstory rendering the valid and invalid case together (see storybook.md)ComponentName.docs.mdx— linksSupports all [Field properties](/docs/getting-started-field-properties--docs)instead of duplicating field props (see documentation.md)ComponentName.test.tsx— usesrenderWithFormfor form integration andrenderWithRoototherwise (see tests.md)
After changing an input component's API, run pnpm audit-docs --component=ComponentName.