@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.
- package/agent-tools/commands/gamut-review.md +8 -8
- package/agent-tools/guidelines/components/overview.md +15 -7
- package/agent-tools/guidelines/foundations/spacing.md +78 -37
- package/agent-tools/guidelines/foundations/typography.md +70 -37
- package/agent-tools/guidelines/overview.md +21 -19
- package/agent-tools/guidelines/setup.md +57 -18
- package/agent-tools/rules/accessibility.mdc +21 -11
- package/agent-tools/skills/gamut-accessibility/SKILL.md +99 -124
- package/agent-tools/skills/gamut-forms/SKILL.md +84 -0
- package/agent-tools/skills/gamut-system-props/SKILL.md +56 -26
- package/agent-tools/skills/gamut-testing/SKILL.md +102 -62
- package/agent-tools/skills/gamut-typography/SKILL.md +34 -82
- package/package.json +6 -6
|
@@ -1,65 +1,57 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gamut-accessibility
|
|
3
|
-
description:
|
|
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` —
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
95
|
+
### ToolTip (detail)
|
|
119
96
|
|
|
120
|
-
|
|
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
|
-
###
|
|
99
|
+
### SkipToContent (detail)
|
|
123
100
|
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
| Status updates, non-critical notifications |
|
|
183
|
-
|
|
|
184
|
-
| Frequently updating counts or progress
|
|
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
|
-
|
|
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
|
|
195
|
-
-
|
|
196
|
-
-
|
|
197
|
-
- **Labelling vs describing**:
|
|
198
|
-
- **Required fields**: use
|
|
199
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
|
231
|
-
|
|
|
232
|
-
|
|
|
233
|
-
|
|
|
234
|
-
|
|
|
235
|
-
| Tooltip as the only accessible name for a control
|
|
236
|
-
|
|
|
237
|
-
|
|
|
238
|
-
|
|
|
239
|
-
| Multiple
|
|
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 Gamut’s 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.
|