Skill v1.0.1
currentAutomated scan100/100+2 new
version: "1.0.1" name: gamut-forms description: Use this skill when implementing or auditing Gamut forms — FormGroup, ConnectedForm, ConnectedFormGroup, GridForm, react-hook-form wiring, labels, and accessible error/description regions. Pair with gamut-accessibility for non-form widgets and accessibility.mdc for universal HTML/ARIA rules.
Gamut forms
Canonical wiring for FormGroup, ConnectedForm, ConnectedFormGroup, GridForm, and field renderers. Source: packages/gamut/src/Form/, ConnectedForm/, GridForm/.
Universal label and primitive guidance: `accessibility.mdc` · overlay and composite patterns: `gamut-accessibility`.
Prefer connected layouts
For typical product forms, prefer GridForm (declarative fields, LayoutGrid, submit/cancel) or ConnectedForm with ConnectedFormGroup / useConnectedForm. Use raw FormGroup + atoms only when the layout is simple and you fully own id, htmlFor, invalid state, and any aria-describedby (see below).
Labels and controls
htmlFor / id pairing — universal rule in `accessibility.mdc` (Form label association). Form-specific notes:
FormGroupLabel→ controlid(or stablenamewhen that is your field’s id convention).- Checkbox, Select: same pairing; checkbox uses the visually hidden input pattern from
@codecademy/gamut-styleswhere applicable. - Radio: see `Radio` and `RadioGroup` below — its label/group wiring differs from a single Checkbox/Select field.
Radio and RadioGroup
packages/gamut/src/Form/inputs/Radio.tsx · packages/gamut/src/Form/inputs/RadioGroup.tsx · reference: packages/styleguide/src/lib/Atoms/FormInputs/Radio/Radio.mdx
No variant/size props — state is derived, not chosen
Radio does not take a consumer-facing variant or size prop. Its visual state is derived internally from the booleans you already pass:
error?: booleananddisabled?: boolean(fromBaseInputProps) feed aconditionalStyleState(error, disabled)helper that resolves to'error' | 'disabled' | undefined, which is then applied viavariant()(from@codecademy/gamut-styles) on both the input and its label (conditionalRadioInputStyles/conditionalRadioLabelStylesinForm/styles/Radio-styles.ts).errorwins overdisabledwhen both are true — do not pass both expecting a combined visual; pick the one that matches the actual field state.- There is no size scale — all Radio inputs render at a fixed 20×20 indicator. If a design calls for a different size, that's a design deviation to flag, not a prop to reach for.
Visually-hidden-input pattern
The real <input type="radio"> (RadioInput) is styled with screenReaderOnly from @codecademy/gamut-styles — it stays in the accessibility tree and keeps native keyboard/focus/change behavior, but is visually hidden. The circle indicator the user sees is rendered on the sibling RadioLabel's ::before (outer ring) and ::after (fill dot) pseudo-elements, driven by CSS sibling selectors keyed to the hidden input's :checked, :hover, and :focus state (InputSelectors.CHECKED_AFTER, HOVER_FOCUS_BEFORE, etc.).
Do not rebuild this with a className/inline-style override or a separate decorative <span> — that duplicates state the hidden input already owns and drifts out of sync on focus/hover/checked. If a design needs a different indicator look, change Radio-styles.ts, not the call site.
Group and label wiring
RadioGroup clones its Radio children and injects three things you should _not_ also pass on each child: name, onChange, and htmlFor (built from htmlForPrefix + child index, e.g. example-radio-0). Wire it as:
RadioGroupgetshtmlForPrefix,name, and (optionally)onChange/disabled/selected.- Each
Radiogets its ownlabel,value, and optionallyinfotip— notnameorhtmlFor, those come from the parent. - To label the group itself (not an individual option), wrap
RadioGroupinFormGroupand passinfotiptoFormGroup, not to aRadio—FormGrouplinks it viaaria-labelledbyto the group label automatically. IndividualRadios can still carry their owninfotipfor per-option context; the two are independent (see the worked example below).
Example — RadioGroup inside FormGroup, with mixed InfoTips
<FormGrouphtmlFor="plan-select"infotip={{ info: 'Applies to the whole group', placement: 'floating' }}label="Select a plan"><RadioGroup htmlForPrefix="plan-select" name="plan-select"><Radiolabel="Pro"value="pro"infotip={{ info: 'Includes priority support' }}/><Radio label="Basic" value="basic" /></RadioGroup></FormGroup>
For an error state on the group, pass error to each Radio (there is no group-level error prop on RadioGroup itself) and surface the message the same way as any other field — via FormGroup's error prop, not a second live region (see "Live regions — do not double up" below).
SelectDropdown vs Select
Use Select for standard single-select fields where bundle size matters and no special styling is needed. Use SelectDropdown when the design calls for any of:
- Styled dropdown menu (react-select appearance)
- Search / typeahead
- Multi-select with tags
- Creatable options
- Option icons, subtitles, right labels, abbreviations, or grouped options
SelectDropdown carries a larger JS dependency (react-select); don't reach for it as a default drop-in for Select.
For full SelectDropdown API detail — controlled vs uncontrolled patterns, creatable options, react-select action metadata — use `gamut-select-dropdown`. Generic FormGroup wiring (labels, errors, live regions) still applies as documented below.
FormGroup (baseline)
packages/gamut/src/Form/elements/FormGroup.tsx
description→FormGroupDescriptionwitharia-live="assertive".error(string) →FormErrorwitharia-live="polite"androle="alert".
Raw FormGroup does not set aria-describedby or aria-invalid on children. If you compose fields outside ConnectedFormGroup / GridForm, wire those yourself or accept that only the live regions above communicate errors/descriptions.
ConnectedFormGroup
packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx
- Passes
aria-describedby(error region id when shown) andaria-invalidon the rendered field component. FormError:aria-live="assertive"androle="alert"only whenisFirstError; otherwisearia-live="off"androle="status"so subsequent errors do not interrupt repeatedly.
GridForm
packages/gamut/src/GridForm/GridFormInputGroup/index.tsx · packages/gamut/src/GridForm/GridFormInputGroup/GridFormTextInput/index.tsx
- Composes
ConnectedForm,LayoutGrid,GridFormButtons, and field metadata.FormErroruses the same first-error assertive pattern asConnectedFormGroup(aria-liveassertive vs off,rolealert vs status). - Built-in text inputs set
aria-invalidand register with react-hook-form viaregister. Custom /custom/custom-grouprenderers must still expose correctid,label, and error surfacing consistent with this pattern.
Live regions — do not double up
FormGroup, ConnectedFormGroup, and GridForm already render FormError (and base FormGroup renders FormGroupDescription) with live-region attributes. Do not add a second aria-live wrapper for the same message stream.
Storybook
- Organisms / GridForm / About · Usage · Validation · Fields
- Organisms / ConnectedForm / ConnectedForm · ConnectedFormGroup
Example — baseline FormGroup
<FormGrouphtmlFor="email-input"description="Used for login"error={errors.email}><FormGroupLabel htmlFor="email-input">Email</FormGroupLabel><Input id="email-input" type="email" /></FormGroup>
When using ConnectedFormGroup or GridForm, prefer their docs and defaults over hand-rolling the above for every field.