@codecademy/gamut 68.6.1-alpha.d52035.0 → 68.6.1-alpha.df4bce.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,65 +1,57 @@
1
1
  ---
2
2
  name: gamut-accessibility
3
- description: Use this skill when implementing or auditing accessibility in Gamut UIs interactive widgets, forms, focus and keyboard flows, live regions, ARIA, or contrast including fixes for screen readers, WCAG issues, or Gamut + React Aria patterns.
3
+ description: Deep Gamut accessibility reference (component matrix, overlays, tips, live regions, checklists). Form wiring and validation UX live in **`gamut-forms`**. Universal HTML/ARIA/focus/color rules: always-loaded **`accessibility.mdc`** read that first; this skill does not duplicate them.
4
4
  ---
5
5
 
6
6
  # Gamut Accessibility
7
7
 
8
- Source: `@codecademy/gamut` — interactive components wrap `react-aria-components`, `@react-aria/interactions`, and `react-focus-on`.
8
+ Source: `@codecademy/gamut` — **`react-aria-components`** is used only in **[`packages/gamut/src/Tabs/`](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Tabs/)** (`Tabs.tsx`, `TabList.tsx`, `Tab.tsx`, `TabPanel.tsx`). **`react-focus-on`** powers **`FocusTrap`** ([`packages/gamut/src/FocusTrap/index.tsx`](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/FocusTrap/index.tsx)), used by overlays such as **`Overlay`** ([`packages/gamut/src/Overlay/index.tsx`](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Overlay/index.tsx)) and **`Popover`**. Other widgets (e.g. **`Menu`**, **`DatePicker`**) implement keyboard and ARIA in Gamut code — verify behavior in Storybook and source, do not assume React Aria.
9
9
 
10
- ---
11
-
12
- ## General rules
13
-
14
- Use ARIA sparingly and only when it's the best option available.
15
-
16
- ### Prefer HTML over ARIA
17
-
18
- Unnecessary ARIA can cause harm. If a native HTML element or attribute with the semantics and behavior you need already exists, use it. Reach for ARIA only when native HTML is genuinely insufficient for the pattern.
19
-
20
- ### A Role is a Promise
21
-
22
- ARIA roles modify the accessibility tree and _imply_ behavior. Always ensure that the implied keyboard behavior, focusability, and interactivity exists when a role is used.
23
-
24
- ### ARIA can both cloak and enhance
10
+ **Product-oriented button variants and props:** [`guidelines/components/buttons.md`](../../guidelines/components/buttons.md)
25
11
 
26
- ARIA can augment native semantics (`aria-pressed` on a `<button>`) or override them entirely (`role="menuitem"` on an `<a>`). Both capabilities are powerful and dangerous. Override only when native HTML genuinely doesn't fit the pattern; when augmenting, don't contradict the native semantics.
27
-
28
-
29
- ### Align accessible names with visible copy
30
-
31
- Prefer wiring names through visible text and native `<label>` / control text / `alt` over using `aria-label`. Point `aria-labelledby` at the visible heading or label that should define the name if it's not possible to name elements from their content. Use bare `aria-label` when there is no suitable visible label.
32
-
33
- ### Treat missing visible labels as a design smell
34
-
35
- When there is no visible text for a nameable element, consider this a sign that the content design could be improved, but not a requirement that it is changed. This is not an accessibility violation.
12
+ ---
36
13
 
37
- ```html
38
- <!-- smell: this list has no conceptual name, so we have to create one using ARIA -->
39
- <ul aria-label="List heading">
40
- <li>...</li>
41
- </ul>
14
+ ## Universal rules
42
15
 
43
- <!-- better: the list's name is visible and can be used for its accessible name -->
44
- <h2 id="list-name">List heading</h2>
45
- <ul aria-labelledby="list-name">
46
- <li>...</li>
47
- </ul>
48
- ```
16
+ Prefer native HTML, minimal ARIA, correct roles, visible names, focus visibility, semantic color / `ColorMode`, and Gamut primitives see the always-loaded **Gamut Accessibility Rules**: [`accessibility.mdc`](../../rules/accessibility.mdc). This skill adds **Gamut component behavior** and audit detail below.
49
17
 
50
18
  ---
51
19
 
52
20
  ## How Gamut handles accessibility
53
21
 
54
- Gamut wraps React Aria for most interactive widget primitives. Roving tabindex, keyboard event handling, and ARIA attribute management are implemented for you in `<Tabs>`, `<Dialog>`, `<Modal>`, and form components. The developer's responsibilities are: supply accessible names, wire labels to inputs, and avoid overriding what the library already provides.
22
+ **Tabs** use **`react-aria-components`** (see `packages/gamut/src/Tabs/*.tsx`) for roving tabindex and keyboard navigation. **Overlays** (e.g. **`Overlay`**, **`Popover`**) use **`FocusTrap`** **`react-focus-on`** for focus containment and Escape/outside close. **Other** interactive components (**`Menu`**, **`DatePicker`**, **`Modal`**, **`Dialog`**, etc.) rely on **in-repo implementations** supply **accessible names**, **wire labels to controls**, and **avoid duplicating** what a component already sets (`aria-live`, `aria-describedby`, tabindex, etc.); confirm in source when auditing.
55
23
 
56
24
  ---
57
25
 
58
- ## Component reference
59
-
60
- ### Button / IconButton
61
-
62
- `<Button>` renders a semantic `<button>` no `role` needed. For icon-only buttons use `<IconButton>` with a `tip` prop; `tip` becomes the button's `aria-label`. Do not use `<div onClick>` or `<a>` without `href` for actions.
26
+ ## Component reference (index)
27
+
28
+ There is **no** exported `<Button>` use **`FillButton`**, **`TextButton`**, **`StrokeButton`**, **`CTAButton`**, and **`IconButton`** (shared **`ButtonProps`** type). Prefer these over `<div onClick>` or `<span role="button">`.
29
+
30
+ **Forms****`FormGroup`**, **`ConnectedForm`** / **`ConnectedFormGroup`**, **`GridForm`**, field atoms (**`Select`**, **`Checkbox`**, **`Radio`**), validation, **`aria-live`** / **`aria-describedby`**: canonical reference is **[`gamut-forms`](../gamut-forms/SKILL.md)**.
31
+
32
+ | Component(s) | Handled in library | App / author responsibilities |
33
+ | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
34
+ | **FillButton**, **TextButton**, **StrokeButton**, **CTAButton** | Render `<button>` (or `<a>` when `href` is set); native click + keyboard activation | Visible text or `href` purpose; follow [`buttons.md`](../../guidelines/components/buttons.md) for variants and `disabled` vs `aria-disabled`. |
35
+ | **IconButton** | `tip` feeds the accessible name for icon-only controls | Always pass **`tip`** when the button has no visible text. |
36
+ | **Dialog** | `Overlay` (shroud, Escape, focus), `role="dialog"`, `aria-modal`, close control with configurable **`closeButtonProps.tip`** | Provide a clear **`title`** (and meaningful body copy). Confirm naming with [Molecules / Modals / Dialog](https://gamut.codecademy.com/?path=/docs-molecules-modals-dialog--docs). |
37
+ | **Modal** | Same overlay/focus stack; optional **`aria-label`**; multi-**`views`** support | **`title`** / view titles; pass **`aria-label`** when there is no visible title string. [Molecules / Modals / Modal](https://gamut.codecademy.com/?path=/docs-molecules-modals-modal--docs). |
38
+ | **Alert** | Default **`aria-live="polite"`**, **`role="status"`** | Use **`aria-live="assertive"`** only for urgent interruptions; do not nest inside another live region. |
39
+ | **Tabs**, **Tab**, **TabList**, **TabPanel** | **`react-aria-components`** in `packages/gamut/src/Tabs/` — roving tabindex, arrows, Home/End | Name each tab; Tab moves into the active panel per APG. |
40
+ | **Forms** | See **Forms** above | **[`gamut-forms`](../gamut-forms/SKILL.md)** |
41
+ | **DatePicker** + **DatePickerInput** | Segmented input + calendar behavior inside **`FormGroup`** | Provide **`label`** / **`name`** / **`form`** as for any input; keep **`DatePickerInput`** inside **`DatePicker`**. When embedded in **`FormGroup`** / **`GridForm`**, follow **[`gamut-forms`](../gamut-forms/SKILL.md)**. [Organisms / DatePicker](https://gamut.codecademy.com/?path=/docs-organisms-datepicker--docs). |
42
+ | **Menu**, **MenuItem**, **MenuSeparator** | List + **`MenuProvider`** (keyboard / roles depend on variant) | Label **`Menu`** / menubar per pattern; follow Storybook [Molecules / Menu](https://gamut.codecademy.com/?path=/docs-molecules-menu--docs). |
43
+ | **Popover** | **`FocusTrap`** when open (unless **`skipFocusTrap`**), positioning | **`onRequestClose`**, meaningful **`role`** when needed; do not trap focus unnecessarily when **`skipFocusTrap`**. |
44
+ | **Flyout** | **`Overlay`**, **`Drawer`**, visible **`title`**, close **`IconButton`** with **`tip={closeLabel}`** | Pass **`title`** and **`closeLabel`**; name panel content. |
45
+ | **Drawer** | Focuses container when **`expanded`**, **`tabIndex={-1}`** on shell | Drawer is a surface, not a full dialog — supply headings/labels inside for screen readers. |
46
+ | **Disclosure** | **`DisclosureButton`** drives expand/collapse | Provide **`heading`** / structure so the control’s purpose is clear. |
47
+ | **Toggle** | **`ToggleLabel`** + **`htmlFor`** wired to control **`id`** | With no visible **`label`**, pass **`ariaLabel`** (or use `as="button"` pattern per props). |
48
+ | **ToolTip** | Floating mode renders a screen-reader **`role="tooltip"`** branch with **`id`** | Pass the same **`id`** to the trigger’s **`aria-describedby`** when you rely on the tooltip as supplementary description (see component **`id`** JSDoc). |
49
+ | **InfoTip** | — | **`ariaLabel`** or **`ariaLabelledby`** (camelCase) — no automatic fallback. |
50
+ | **PreviewTip** | **`Anchor`**-based preview, focus-driven content | **`linkDescription`** and visible anchor text; do not use the preview as the sole name for an unrelated control. |
51
+ | **SkipToContent** | Skip link behavior | Place early in the tab order; **`href`** target **`id`** must exist on main content. |
52
+ | **Toast** + **Toaster** | **`Toaster`** wraps the stack in **`aria-live="polite"`** | Keep messages concise; avoid stacking many simultaneous assertive announcements. |
53
+ | **Pagination** | Page / control buttons | Ensure current page and actions are perceivable (labels / `aria-current` patterns per Storybook). [Molecules / Pagination](https://gamut.codecademy.com/?path=/docs-molecules-pagination--docs). |
54
+ | **FocusTrap** | Escape, outside click, **`allowPageInteraction`** | Return focus to trigger on close for custom overlays. |
63
55
 
64
56
  ```tsx
65
57
  // correct
@@ -69,77 +61,59 @@ Gamut wraps React Aria for most interactive widget primitives. Roving tabindex,
69
61
  <div onClick={handleDelete}><DeleteIcon /></div>
70
62
  ```
71
63
 
72
- ### Dialog / Modal
64
+ ### Dialog / Modal (detail)
73
65
 
74
- Both use `<FocusTrap>` (`react-focus-on`) internally. Focus locks to the dialog on open and returns to the trigger on close. Escape closes the dialog automatically.
66
+ Both use **`Overlay`** and **`FocusTrap`** (`react-focus-on`) patterns: focus moves into the surface, **Escape** closes (when enabled), focus should return to the trigger on close.
75
67
 
76
- Always provide an accessible name. **Prefer `aria-labelledby`** when a visible heading or title defines the dialog name; use **`aria-label`** only when there is no suitable visible title string.
68
+ Prefer a **visible title** so the dialog has a clear name; on **`Modal`**, pass **`aria-label`** when there is no suitable visible title string. Close control: **`IconButton`** with **`closeButtonProps.tip`** (defaults documented in source).
77
69
 
78
70
  ```tsx
79
- <Dialog aria-labelledby="confirm-title">
80
- <h2 id="confirm-title">Confirm deletion</h2>
81
- </Dialog>
71
+ <Dialog
72
+ title="Confirm deletion"
73
+ confirmCta={{ children: 'Delete', onClick: handleDelete }}
74
+ onRequestClose={handleClose}
75
+ isOpen={open}
76
+ />
82
77
  ```
83
78
 
84
- ### Alert
85
-
86
- Renders with `aria-live="polite"` and `role="status"` by default. Override with `aria-live="assertive"` only for time-sensitive errors requiring immediate interruption. Do not nest `<Alert>` inside another live region.
87
-
88
- ### Tabs
89
-
90
- Built on `react-aria-components`. Follows the [ARIA Tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/): arrow keys navigate tabs, Tab moves focus into the active panel, Home/End jump to first/last tab. The tablist is a composite — only the active tab is in the tab sequence (roving tabindex). No manual `aria-selected` or keyboard handling needed.
79
+ ### Alert (detail)
91
80
 
92
- ### Forms
93
-
94
- `<FormGroup>` + `<FormGroupLabel>` + input element is the complete accessible pattern:
95
-
96
- ```tsx
97
- <FormGroup htmlFor="email-input" description="Used for login" error={errors.email}>
98
- <FormGroupLabel htmlFor="email-input">Email</FormGroupLabel>
99
- <Input id="email-input" type="email" />
100
- </FormGroup>
101
- ```
81
+ Renders with **`aria-live="polite"`** and **`role="status"`** by default. Override with **`aria-live="assertive"`** only for time-sensitive errors requiring immediate interruption. Do not nest **`<Alert>`** inside another live region.
102
82
 
103
- - `htmlFor` on `<FormGroupLabel>` and `id` on the input must match
104
- - `error` prop on `<FormGroup>` renders into an `aria-live="assertive"` region automatically
105
- - `description` prop renders into an `aria-live="polite"` region automatically
106
- - Do not add `aria-describedby` or `aria-errormessage` manually — `<FormGroup>` manages these
83
+ ### Tabs (detail)
107
84
 
108
- **Checkbox / Radio**: `htmlFor` is required; the input is visually hidden via `screenReaderOnly` from `@codecademy/gamut-styles` but remains in the accessibility tree.
85
+ Built on **`react-aria-components`**. Follows the [ARIA Tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/): arrow keys navigate tabs, Tab moves focus into the active panel, Home/End jump to first/last tab. The tablist is a composite only the active tab is in the tab sequence (roving tabindex). No manual **`aria-selected`** or keyboard handling needed.
109
86
 
110
- ### InfoTip
87
+ ### InfoTip (example)
111
88
 
112
- Unlike `<IconButton>`, `<InfoTip>` has no automatic label fallback. Always provide `ariaLabel` or `ariaLabelledby` (camelCase props).
89
+ **`<InfoTip>`** needs **`ariaLabel`** or **`ariaLabelledby`** see also the always-loaded rules.
113
90
 
114
91
  ```tsx
115
92
  <InfoTip ariaLabel="More information about billing" />
116
93
  ```
117
94
 
118
- ### SkipToContent
95
+ ### ToolTip (detail)
119
96
 
120
- Include `<SkipToContent>` as the first focusable element in the page shell. The main content region must have a matching `id` for the skip link to target.
97
+ When **`placement="floating"`**, the component renders a screen-reader-only branch with **`role="tooltip"`** and an optional **`id`**. Pass the **same `id`** to the described element’s **`aria-describedby`** so assistive tech associates the tooltip copy with the trigger. Inline placement uses the wrapper differently see [Molecules / Tips / ToolTip](https://gamut.codecademy.com/?path=/docs-molecules-tips-tooltip--docs).
121
98
 
122
- ### Text (screen-reader-only)
99
+ ### SkipToContent (detail)
123
100
 
124
- `<Text screenreader>` is the supported pattern for visually hidden but announced content. `<HiddenText>` is deprecated.
125
-
126
- ```tsx
127
- <Text screenreader>Loading results…</Text>
128
- ```
101
+ Include **`<SkipToContent>`** as the first focusable element in the page shell. The main content region must expose a matching **`id`** for the skip target.
129
102
 
130
103
  ---
131
104
 
132
105
  ## Focus management
133
106
 
134
- `<FocusTrap>` is available for custom overlay patterns not covered by `<Dialog>` or `<Modal>`.
107
+ **`<FocusTrap>`** is for custom overlay patterns not covered by **`Dialog`** / **`Modal`**.
135
108
 
136
109
  Key props:
137
- - `active` — enable/disable the trap dynamically
138
- - `onEscapeKey` — close handler
139
- - `onClickOutside` — dismiss on outside click
140
- - `allowPageInteraction` — permit scrolling outside the trap without closing
141
110
 
142
- Always return focus to the trigger on close. React Aria components do this automatically; custom implementations must store a ref to the trigger and call `.focus()` on unmount.
111
+ - **`active`** enable/disable the trap dynamically
112
+ - **`onEscapeKey`** — close handler
113
+ - **`onClickOutside`** — dismiss on outside click
114
+ - **`allowPageInteraction`** — permit scrolling outside the trap without closing
115
+
116
+ Always return focus to the trigger on close. **`react-focus-on`** (via **`FocusTrap`**) and overlay flows handle much of this for dialogs/popovers; **`Tabs`** inherit focus behavior from **`react-aria-components`**. Custom surfaces must store a ref to the trigger and call **`.focus()`** on close when the library does not.
143
117
 
144
118
  ---
145
119
 
@@ -148,19 +122,20 @@ Always return focus to the trigger on close. React Aria components do this autom
148
122
  ARIA composite roles (`listbox`, `menu`, `tree`, `grid`, `tablist`) use **managed focus**: only one element in the composite is in the tab sequence at a time. Tab moves focus to the next element outside the composite; arrow keys move focus within it.
149
123
 
150
124
  Implementation pattern — roving tabindex:
151
- - Set `tabIndex={0}` on the currently active item
152
- - Set `tabIndex={-1}` on all other items
153
- - On arrow key, update which item holds `tabIndex={0}` and call `.focus()` on it
154
125
 
155
- Gamut's `<Tabs>` implements this via React Aria. If you build a custom composite widget, you must implement roving tabindex manually. A flat `tabIndex={0}` on every item is wrong — it puts every item in the sequential tab order, which is not the composite pattern.
126
+ - Set **`tabIndex={0}`** on the currently active item
127
+ - Set **`tabIndex={-1}`** on all other items
128
+ - On arrow key, update which item holds **`tabIndex={0}`** and call **`.focus()`** on it
129
+
130
+ **`Tabs`** (`react-aria-components`) implement roving tabindex for the tablist pattern. **`Menu`** and other composites implement focus in Gamut — if you build a **custom** composite, implement roving tabindex yourself. A flat **`tabIndex={0}`** on every item is wrong — it puts every item in the sequential tab order.
156
131
 
157
132
  ---
158
133
 
159
134
  ## Device-independent events
160
135
 
161
- Use `click` for activation, not `mousedown`. `click` fires for both pointer and keyboard (Space/Enter on native buttons and links). `mousedown` is pointer-only and silently breaks keyboard access.
136
+ Use **`click`** for activation, not **`mousedown`**. **`click`** follows pointer activation; native **`<button>`** (and similar controls) also fire **`click`** from keyboard (Space and Enter). A focused **`<a href>`** is usually activated with **Enter**, which fires **`click`** — Space often scrolls the page instead of activating the link. **`mousedown`** does not represent keyboard activation, so relying on it alone breaks keyboard-only use.
162
137
 
163
- For custom elements with `role="button"`, `click` alone is not sufficient it only fires on keyboard when the element is a native `<button>` or `<a href>`. You must also handle `keydown` for Space and Enter explicitly:
138
+ For custom elements with **`role="button"`**, do not assume the browser will synthesize **`click`** from the keyboard the way it does for native interactive elements (**`<button>`**, **`<a href>`**, and other built-ins). Handle **`keydown`** for Space and Enter explicitly:
164
139
 
165
140
  ```tsx
166
141
  const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -171,40 +146,40 @@ const handleKeyDown = (e: React.KeyboardEvent) => {
171
146
  };
172
147
  ```
173
148
 
174
- This is another reason to use `<Button>` it handles this correctly and you don't.
149
+ Prefer Gamut **`*Button`** components (or **`Anchor`** with a real **`href`**) so you do not reimplement this.
175
150
 
176
151
  ---
177
152
 
178
153
  ## Live regions
179
154
 
180
- | Scenario | Pattern |
181
- |---|---|
182
- | Status updates, non-critical notifications | `aria-live="polite"` |
183
- | Form validation errors on submit | `aria-live="assertive"` |
184
- | Frequently updating counts or progress | `aria-live="polite"` + `aria-atomic="true"` |
155
+ | Scenario | Pattern |
156
+ | ------------------------------------------ | --------------------------------------------------- |
157
+ | Status updates, non-critical notifications | **`aria-live="polite"`** |
158
+ | Urgent global interruptions | **`aria-live="assertive"`** (use sparingly) |
159
+ | Frequently updating counts or progress | **`aria-live="polite"`** + **`aria-atomic="true"`** |
160
+
161
+ Form-bound **`aria-live`** and **`FormError`** patterns: see **Forms** above (do not assume assertive on every field error).
185
162
 
186
163
  Inject live regions into the DOM before they need to announce. A region added simultaneously with its first announcement may be ignored by some assistive technologies.
187
164
 
188
- `<FormGroup>` already uses `aria-live="assertive"` for the `error` prop. Don't elevate unrelated inline errors to assertive — reserve it for interruptions the user didn't directly trigger.
165
+ Do not elevate unrelated inline errors to **`assertive`** — reserve assertive for urgent interruptions the user did not directly trigger.
189
166
 
190
167
  ---
191
168
 
192
169
  ## ARIA authoring rules
193
170
 
194
- - **No redundant roles**: don't set `role="button"` on `<button>` or `role="heading"` on `<h2>`
195
- - **aria-hidden cascades**: placing `aria-hidden="true"` on a parent removes the entire subtree from the accessibility tree, including focusable descendants — never put it on an ancestor of a focusable element
196
- - **role="presentation" and aria-hidden on focusable elements**: both are prohibited on elements that can receive focus — they remove semantics while leaving the element keyboard-reachable, producing an operable but unnamed control
197
- - **Labelling vs describing**: `aria-label` / `aria-labelledby` name the control. `aria-describedby` provides supplementary context. Both can coexist on the same element
198
- - **Required fields**: use `aria-required="true"` or the HTML `required` attribute. Visual asterisks must have an explanatory text string visible on the page; the asterisk glyph itself should carry `aria-hidden="true"``<FormGroupLabel>` already handles this
199
- - **display:none vs aria-hidden**: elements with `display:none` are already removed from the accessibility tree; adding `aria-hidden` is redundant. Use `aria-hidden="true"` only when an element is visually present but should be hidden from assistive technology
171
+ - **No redundant roles**: don't set **`role="button"`** on **`<button>`** or **`role="heading"`** on **`<h2>`**
172
+ - **`aria-hidden` cascades**: placing **`aria-hidden="true"`** on a parent removes the entire subtree from the accessibility tree, including focusable descendants — never put it on an ancestor of a focusable element
173
+ - **`role="presentation"`** and **`aria-hidden`** on focusable elements: both are prohibited on elements that can receive focus — they remove semantics while leaving the element keyboard-reachable, producing an operable but unnamed control
174
+ - **Labelling vs describing**: **`aria-label`** / **`aria-labelledby`** name the control. **`aria-describedby`** provides supplementary context. Both can coexist on the same element
175
+ - **Required fields**: use **`aria-required="true"`** or the HTML **`required`** attribute. Visual asterisks must have an explanatory text string visible on the page; the asterisk glyph itself should carry **`aria-hidden="true"`****`<FormGroupLabel>`** already handles this
176
+ - **`display:none` vs `aria-hidden`**: elements with **`display:none`** are already removed from the accessibility tree; adding **`aria-hidden`** is redundant. Use **`aria-hidden="true"`** only when an element is visually present but should be hidden from assistive technology
200
177
 
201
178
  ---
202
179
 
203
- ## Color and contrast
204
-
205
- Gamut's `ColorMode` and semantic color tokens are designed to meet WCAG AA (4.5:1 for normal text, 3:1 for large text and non-text UI components). Hardcoding hex values bypasses this guarantee and breaks in dark mode. See the `gamut-color-mode` skill for semantic token usage.
180
+ ## Color and contrast (non-text)
206
181
 
207
- Non-text contrast (focus indicators, input borders, icon-only controls) requires a minimum 3:1 ratio against adjacent colors per WCAG 1.4.11.
182
+ Semantic tokens, **`ColorMode`**, and **`<Background>`** are covered in the always-loaded **`accessibility.mdc`** rule and the **`gamut-color-mode`** skill. Here: non-text contrast (focus rings, input borders, icon affordances) should meet **~3:1** vs adjacent colors where WCAG **1.4.11** applies — validate in your layout.
208
183
 
209
184
  ---
210
185
 
@@ -215,7 +190,7 @@ Non-text contrast (focus indicators, input borders, icon-only controls) requires
215
190
  - [ ] Dialogs trap focus correctly; Escape closes; focus returns to the trigger
216
191
  - [ ] Composite widgets (tabs, menus, listboxes) use arrow keys internally, not Tab
217
192
  - [ ] All form inputs have programmatically associated labels (not placeholder-only)
218
- - [ ] Form errors are announced via live region
193
+ - [ ] Form errors surface through the library’s **`FormError`** / live-region patterns (**Forms** above)
219
194
  - [ ] Icon-only controls have accessible names
220
195
  - [ ] No content relies solely on color to convey meaning
221
196
  - [ ] Screen reader matrix: VoiceOver + Safari (iOS), VoiceOver + Chrome (macOS), NVDA + Chrome (Windows)
@@ -225,15 +200,15 @@ Non-text contrast (focus indicators, input borders, icon-only controls) requires
225
200
 
226
201
  ## Common anti-patterns
227
202
 
228
- | Anti-pattern | Fix |
229
- |---|---|
230
- | `<div onClick={…}>` for actions | `<Button>` |
231
- | `placeholder` as the only label | `<FormGroupLabel>` with matching `htmlFor`/`id` |
232
- | `aria-label` on a `<div>` with no role | Add a meaningful `role` or use a semantic element |
233
- | `role="button"` without Space/Enter handlers | Use `<Button>`, or add `keydown` handler |
234
- | `tabIndex={0}` on every item in a composite | Roving tabindex: `0` on active item, `-1` on rest |
235
- | Tooltip as the only accessible name for a control | Set `aria-label` directly on the control as well |
236
- | `aria-hidden="true"` on a focusable element | Also remove from tab order (`tabIndex={-1}`) or restructure |
237
- | `mousedown` for activation | Use `click` |
238
- | `outline: none` without a replacement | Use Gamut's built-in focus styles |
239
- | Multiple `aria-live` regions for the same content stream | One region per logical stream; reuse it across updates |
203
+ | Anti-pattern | Fix |
204
+ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- |
205
+ | **`<div onClick={…}>`** for actions | **`FillButton`**, **`TextButton`**, **`StrokeButton`**, **`CTAButton`**, or **`IconButton`** (with **`tip`**) |
206
+ | **`placeholder`** as the only label | **`FormGroupLabel`** with matching **`htmlFor`** / **`id`** |
207
+ | **`aria-label`** on a **`<div>`** with no role | Add a meaningful **`role`** or use a semantic element |
208
+ | **`role="button"`** without Space/Enter handlers | Use a Gamut **`*Button`**, **`Anchor`** with **`href`**, or add **`keydown`** |
209
+ | **`tabIndex={0}`** on every item in a composite | Roving tabindex: **`0`** on active item, **`-1`** on rest |
210
+ | Tooltip as the only accessible name for a control | Set **`aria-label`** (or visible text) on the control as well |
211
+ | **`aria-hidden="true"`** on a focusable element | Also remove from tab order (**`tabIndex={-1}`**) or restructure |
212
+ | **`mousedown`** for activation | Use **`click`** |
213
+ | **`outline: none`** without a replacement | Use Gamuts built-in focus styles |
214
+ | Multiple **`aria-live`** regions for the same content stream | One region per logical stream; reuse it across updates |
@@ -0,0 +1,84 @@
1
+ ---
2
+ name: gamut-forms
3
+ description: 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.
4
+ ---
5
+
6
+ # Gamut forms
7
+
8
+ Canonical wiring for **`FormGroup`**, **`ConnectedForm`**, **`ConnectedFormGroup`**, **`GridForm`**, and field renderers. Source: **`packages/gamut/src/Form/`**, **`ConnectedForm/`**, **`GridForm/`**.
9
+
10
+ Universal label and primitive guidance: **[`accessibility.mdc`](../../rules/accessibility.mdc)** · overlay and composite patterns: **[`gamut-accessibility`](../gamut-accessibility/SKILL.md)**.
11
+
12
+ ---
13
+
14
+ ## Prefer connected layouts
15
+
16
+ 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).
17
+
18
+ ---
19
+
20
+ ## Labels and controls
21
+
22
+ **`htmlFor`** / **`id`** pairing — universal rule in **[`accessibility.mdc`](../../rules/accessibility.mdc)** (Form label association). Form-specific notes:
23
+
24
+ - **`FormGroupLabel`** → control **`id`** (or stable **`name`** when that is your field’s id convention).
25
+ - **Checkbox**, **Radio**, **Select**: same pairing; checkbox/radio use the visually hidden input pattern from **`@codecademy/gamut-styles`** where applicable.
26
+
27
+ ---
28
+
29
+ ## `FormGroup` (baseline)
30
+
31
+ [`FormGroup.tsx`](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/Form/elements/FormGroup.tsx)
32
+
33
+ - **`description`** → **`FormGroupDescription`** with **`aria-live="assertive"`**.
34
+ - **`error`** (string) → **`FormError`** with **`aria-live="polite"`** and **`role="alert"`**.
35
+
36
+ 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.
37
+
38
+ ---
39
+
40
+ ## `ConnectedFormGroup`
41
+
42
+ [`ConnectedFormGroup.tsx`](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/ConnectedForm/ConnectedFormGroup.tsx)
43
+
44
+ - Passes **`aria-describedby`** (error region id when shown) and **`aria-invalid`** on the rendered field component.
45
+ - **`FormError`**: **`aria-live="assertive"`** and **`role="alert"`** only when **`isFirstError`**; otherwise **`aria-live="off"`** and **`role="status"`** so subsequent errors do not interrupt repeatedly.
46
+
47
+ ---
48
+
49
+ ## `GridForm`
50
+
51
+ [`GridFormInputGroup/index.tsx`](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/GridForm/GridFormInputGroup/index.tsx) · [`GridFormTextInput`](https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/GridForm/GridFormInputGroup/GridFormTextInput/index.tsx)
52
+
53
+ - Composes **`ConnectedForm`**, **`LayoutGrid`**, **`GridFormButtons`**, and field metadata. **`FormError`** uses the same **first-error assertive** pattern as **`ConnectedFormGroup`** (**`aria-live`** assertive vs off, **`role`** alert vs status).
54
+ - Built-in text inputs set **`aria-invalid`** and register with **react-hook-form** via **`register`**. Custom / **`custom`** / **`custom-group`** renderers must still expose correct **`id`**, **`label`**, and error surfacing consistent with this pattern.
55
+
56
+ ---
57
+
58
+ ## Live regions — do not double up
59
+
60
+ **`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.
61
+
62
+ ---
63
+
64
+ ## Storybook
65
+
66
+ - [Organisms / GridForm / About](https://gamut.codecademy.com/?path=/docs-organisms-gridform-about--docs) · [Usage](https://gamut.codecademy.com/?path=/docs-organisms-gridform-usage--docs) · [Validation](https://gamut.codecademy.com/?path=/docs-organisms-gridform-validation--docs) · [Fields](https://gamut.codecademy.com/?path=/docs-organisms-gridform-fields--docs)
67
+ - [Organisms / ConnectedForm / ConnectedForm](https://gamut.codecademy.com/?path=/docs-organisms-connectedform-connectedform--docs) · [ConnectedFormGroup](https://gamut.codecademy.com/?path=/docs-organisms-connectedform-connectedformgroup--docs)
68
+
69
+ ---
70
+
71
+ ## Example — baseline `FormGroup`
72
+
73
+ ```tsx
74
+ <FormGroup
75
+ htmlFor="email-input"
76
+ description="Used for login"
77
+ error={errors.email}
78
+ >
79
+ <FormGroupLabel htmlFor="email-input">Email</FormGroupLabel>
80
+ <Input id="email-input" type="email" />
81
+ </FormGroup>
82
+ ```
83
+
84
+ When using **`ConnectedFormGroup`** or **`GridForm`**, prefer their docs and defaults over hand-rolling the above for every field.