@a11ypros/a11y-ui-components 1.0.0 → 1.0.1
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/.storybook/custom.css +69 -0
- package/.storybook/main.ts +46 -0
- package/.storybook/manager.ts +26 -0
- package/.storybook/package.json +6 -0
- package/.storybook/preview.tsx +31 -0
- package/.storybook/public/logo.png +0 -0
- package/.storybook/vite.config.ts +24 -0
- package/.storybook/welcome.mdx +97 -0
- package/DEPLOYMENT.md +154 -0
- package/README.md +227 -0
- package/apps/web/app/(docs)/audit/audit.css +269 -0
- package/apps/web/app/(docs)/audit/page.tsx +271 -0
- package/apps/web/app/(docs)/components/button/page.tsx +49 -0
- package/apps/web/app/(docs)/components/form/page.tsx +92 -0
- package/apps/web/app/(docs)/components/link/page.tsx +31 -0
- package/apps/web/app/(docs)/components/modal/page.tsx +41 -0
- package/apps/web/app/(docs)/components/page.tsx +37 -0
- package/apps/web/app/(docs)/components/table/page.tsx +54 -0
- package/apps/web/app/(docs)/components/tabs/page.tsx +61 -0
- package/apps/web/app/(docs)/components/toast/page.tsx +51 -0
- package/apps/web/app/api/audit/route.ts +128 -0
- package/apps/web/app/favicon.ico +0 -0
- package/apps/web/app/layout.tsx +20 -0
- package/apps/web/app/page.tsx +17 -0
- package/apps/web/app/styles/globals.css +5 -0
- package/apps/web/next-env.d.ts +5 -0
- package/apps/web/next.config.js +21 -0
- package/apps/web/package.json +28 -0
- package/apps/web/public/_headers +17 -0
- package/apps/web/public/_redirects +31 -0
- package/apps/web/public/logo.png +0 -0
- package/apps/web/tsconfig.json +29 -0
- package/netlify/functions/audit.ts +163 -0
- package/netlify.toml +37 -0
- package/package.json +30 -58
- package/packages/design-system/README.md +252 -0
- package/packages/design-system/package.json +68 -0
- package/packages/design-system/scripts/copy-css.js +63 -0
- package/packages/design-system/src/components/Button/Button.stories.tsx +228 -0
- package/packages/design-system/src/components/Button/Button.tsx +137 -0
- package/packages/design-system/src/components/Button/index.ts +3 -0
- package/packages/design-system/src/components/DataTable/DataTable.stories.tsx +211 -0
- package/packages/design-system/src/components/DataTable/DataTable.tsx +293 -0
- package/packages/design-system/src/components/DataTable/index.ts +3 -0
- package/packages/design-system/src/components/Form/Checkbox.stories.tsx +252 -0
- package/packages/design-system/src/components/Form/Checkbox.tsx +114 -0
- package/packages/design-system/src/components/Form/Fieldset.stories.tsx +210 -0
- package/packages/design-system/src/components/Form/Fieldset.tsx +71 -0
- package/packages/design-system/src/components/Form/Input.stories.tsx +164 -0
- package/packages/design-system/src/components/Form/Input.tsx +113 -0
- package/packages/design-system/src/components/Form/Label.tsx +56 -0
- package/packages/design-system/src/components/Form/Radio.stories.tsx +265 -0
- package/packages/design-system/src/components/Form/Radio.tsx +147 -0
- package/packages/design-system/src/components/Form/Select.stories.tsx +295 -0
- package/packages/design-system/src/components/Form/Select.tsx +160 -0
- package/packages/design-system/src/components/Form/Textarea.stories.tsx +253 -0
- package/packages/design-system/src/components/Form/Textarea.tsx +145 -0
- package/packages/design-system/src/components/Form/index.ts +8 -0
- package/packages/design-system/src/components/Link/Link.stories.tsx +128 -0
- package/packages/design-system/src/components/Link/Link.tsx +117 -0
- package/packages/design-system/src/components/Link/index.ts +3 -0
- package/packages/design-system/src/components/Modal/Modal.stories.tsx +165 -0
- package/packages/design-system/src/components/Modal/Modal.tsx +202 -0
- package/packages/design-system/src/components/Modal/index.ts +3 -0
- package/packages/design-system/src/components/Tabs/Tabs.stories.tsx +213 -0
- package/packages/design-system/src/components/Tabs/Tabs.tsx +248 -0
- package/packages/design-system/src/components/Tabs/index.ts +3 -0
- package/packages/design-system/src/components/Toast/Toast.stories.tsx +153 -0
- package/packages/design-system/src/components/Toast/Toast.tsx +175 -0
- package/packages/design-system/src/components/Toast/ToastProvider.tsx +73 -0
- package/packages/design-system/src/components/Toast/index.ts +5 -0
- package/packages/design-system/src/hooks/useAriaLive.ts +51 -0
- package/packages/design-system/src/hooks/useFocusReturn.ts +40 -0
- package/packages/design-system/src/hooks/useFocusTrap.ts +82 -0
- package/{dist/index.js → packages/design-system/src/index.ts} +4 -0
- package/packages/design-system/src/styles/index.ts +3 -0
- package/packages/design-system/src/tokens/breakpoints.ts +28 -0
- package/packages/design-system/src/tokens/colors.ts +98 -0
- package/packages/design-system/src/tokens/index.ts +6 -0
- package/packages/design-system/src/tokens/motion.ts +41 -0
- package/packages/design-system/src/tokens/spacing.ts +24 -0
- package/packages/design-system/src/tokens/theme.ts +19 -0
- package/packages/design-system/src/tokens/typography.ts +64 -0
- package/packages/design-system/src/utils/aria.ts +108 -0
- package/packages/design-system/src/utils/focus.ts +87 -0
- package/packages/design-system/src/utils/index.ts +4 -0
- package/packages/design-system/src/utils/keyboard.ts +77 -0
- package/packages/design-system/tsconfig.json +17 -0
- package/public/logo.png +0 -0
- package/scripts/fix-storybook-paths.js +53 -0
- package/tsconfig.json +20 -0
- package/dist/components/Button/Button.d.ts +0 -37
- package/dist/components/Button/Button.d.ts.map +0 -1
- package/dist/components/Button/Button.js +0 -52
- package/dist/components/Button/index.d.ts +0 -3
- package/dist/components/Button/index.d.ts.map +0 -1
- package/dist/components/Button/index.js +0 -1
- package/dist/components/DataTable/DataTable.d.ts +0 -71
- package/dist/components/DataTable/DataTable.d.ts.map +0 -1
- package/dist/components/DataTable/DataTable.js +0 -122
- package/dist/components/DataTable/index.d.ts +0 -3
- package/dist/components/DataTable/index.d.ts.map +0 -1
- package/dist/components/DataTable/index.js +0 -1
- package/dist/components/Form/Checkbox.d.ts +0 -36
- package/dist/components/Form/Checkbox.d.ts.map +0 -1
- package/dist/components/Form/Checkbox.js +0 -39
- package/dist/components/Form/Fieldset.d.ts +0 -33
- package/dist/components/Form/Fieldset.d.ts.map +0 -1
- package/dist/components/Form/Fieldset.js +0 -34
- package/dist/components/Form/Input.d.ts +0 -37
- package/dist/components/Form/Input.d.ts.map +0 -1
- package/dist/components/Form/Input.js +0 -41
- package/dist/components/Form/Label.d.ts +0 -30
- package/dist/components/Form/Label.d.ts.map +0 -1
- package/dist/components/Form/Label.js +0 -30
- package/dist/components/Form/Radio.d.ts +0 -53
- package/dist/components/Form/Radio.d.ts.map +0 -1
- package/dist/components/Form/Radio.js +0 -39
- package/dist/components/Form/Select.d.ts +0 -51
- package/dist/components/Form/Select.d.ts.map +0 -1
- package/dist/components/Form/Select.js +0 -49
- package/dist/components/Form/Textarea.d.ts +0 -44
- package/dist/components/Form/Textarea.d.ts.map +0 -1
- package/dist/components/Form/Textarea.js +0 -43
- package/dist/components/Form/index.d.ts +0 -8
- package/dist/components/Form/index.d.ts.map +0 -1
- package/dist/components/Form/index.js +0 -7
- package/dist/components/Link/Link.d.ts +0 -34
- package/dist/components/Link/Link.d.ts.map +0 -1
- package/dist/components/Link/Link.js +0 -48
- package/dist/components/Link/index.d.ts +0 -3
- package/dist/components/Link/index.d.ts.map +0 -1
- package/dist/components/Link/index.js +0 -1
- package/dist/components/Modal/Modal.d.ts +0 -64
- package/dist/components/Modal/Modal.d.ts.map +0 -1
- package/dist/components/Modal/Modal.js +0 -108
- package/dist/components/Modal/index.d.ts +0 -3
- package/dist/components/Modal/index.d.ts.map +0 -1
- package/dist/components/Modal/index.js +0 -1
- package/dist/components/Tabs/Tabs.d.ts +0 -63
- package/dist/components/Tabs/Tabs.d.ts.map +0 -1
- package/dist/components/Tabs/Tabs.js +0 -134
- package/dist/components/Tabs/index.d.ts +0 -3
- package/dist/components/Tabs/index.d.ts.map +0 -1
- package/dist/components/Tabs/index.js +0 -1
- package/dist/components/Toast/Toast.d.ts +0 -59
- package/dist/components/Toast/Toast.d.ts.map +0 -1
- package/dist/components/Toast/Toast.js +0 -91
- package/dist/components/Toast/ToastProvider.d.ts +0 -22
- package/dist/components/Toast/ToastProvider.d.ts.map +0 -1
- package/dist/components/Toast/ToastProvider.js +0 -33
- package/dist/components/Toast/index.d.ts +0 -5
- package/dist/components/Toast/index.d.ts.map +0 -1
- package/dist/components/Toast/index.js +0 -2
- package/dist/hooks/useAriaLive.d.ts +0 -9
- package/dist/hooks/useAriaLive.d.ts.map +0 -1
- package/dist/hooks/useAriaLive.js +0 -39
- package/dist/hooks/useFocusReturn.d.ts +0 -9
- package/dist/hooks/useFocusReturn.d.ts.map +0 -1
- package/dist/hooks/useFocusReturn.js +0 -33
- package/dist/hooks/useFocusTrap.d.ts +0 -9
- package/dist/hooks/useFocusTrap.d.ts.map +0 -1
- package/dist/hooks/useFocusTrap.js +0 -68
- package/dist/index.d.ts +0 -22
- package/dist/index.d.ts.map +0 -1
- package/dist/styles/index.d.ts +0 -3
- package/dist/styles/index.d.ts.map +0 -1
- package/dist/styles/index.js +0 -1
- package/dist/tokens/breakpoints.d.ts +0 -25
- package/dist/tokens/breakpoints.d.ts.map +0 -1
- package/dist/tokens/breakpoints.js +0 -23
- package/dist/tokens/colors.d.ts +0 -81
- package/dist/tokens/colors.d.ts.map +0 -1
- package/dist/tokens/colors.js +0 -86
- package/dist/tokens/index.d.ts +0 -6
- package/dist/tokens/index.d.ts.map +0 -1
- package/dist/tokens/index.js +0 -5
- package/dist/tokens/motion.d.ts +0 -30
- package/dist/tokens/motion.d.ts.map +0 -1
- package/dist/tokens/motion.js +0 -34
- package/dist/tokens/spacing.d.ts +0 -22
- package/dist/tokens/spacing.d.ts.map +0 -1
- package/dist/tokens/spacing.js +0 -20
- package/dist/tokens/theme.d.ts +0 -159
- package/dist/tokens/theme.d.ts.map +0 -1
- package/dist/tokens/theme.js +0 -15
- package/dist/tokens/typography.d.ts +0 -45
- package/dist/tokens/typography.d.ts.map +0 -1
- package/dist/tokens/typography.js +0 -56
- package/dist/utils/aria.d.ts +0 -60
- package/dist/utils/aria.d.ts.map +0 -1
- package/dist/utils/aria.js +0 -86
- package/dist/utils/focus.d.ts +0 -30
- package/dist/utils/focus.d.ts.map +0 -1
- package/dist/utils/focus.js +0 -80
- package/dist/utils/index.d.ts +0 -4
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -3
- package/dist/utils/keyboard.d.ts +0 -38
- package/dist/utils/keyboard.d.ts.map +0 -1
- package/dist/utils/keyboard.js +0 -59
- /package/{dist → packages/design-system/src}/components/Button/Button.css +0 -0
- /package/{dist → packages/design-system/src}/components/DataTable/DataTable.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Checkbox.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Fieldset.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Input.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Label.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Radio.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Select.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Textarea.css +0 -0
- /package/{dist → packages/design-system/src}/components/Link/Link.css +0 -0
- /package/{dist → packages/design-system/src}/components/Modal/Modal.css +0 -0
- /package/{dist → packages/design-system/src}/components/Tabs/Tabs.css +0 -0
- /package/{dist → packages/design-system/src}/components/Toast/Toast.css +0 -0
- /package/{dist → packages/design-system/src}/components/Toast/ToastProvider.css +0 -0
- /package/{dist → packages/design-system/src}/styles/components.css +0 -0
- /package/{dist → packages/design-system/src}/styles/global.css +0 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { Radio } from './Radio'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* # Radio Component
|
|
7
|
+
*
|
|
8
|
+
* An accessible radio group component for selecting a single option from multiple choices.
|
|
9
|
+
* Uses proper fieldset/legend structure and ARIA attributes following WAI-ARIA best practices.
|
|
10
|
+
*
|
|
11
|
+
* ## Usage
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* import { Radio } from '@a11ypros/a11y-ui-components'
|
|
15
|
+
*
|
|
16
|
+
* function MyComponent() {
|
|
17
|
+
* const [value, setValue] = useState('')
|
|
18
|
+
*
|
|
19
|
+
* return (
|
|
20
|
+
* <Radio
|
|
21
|
+
* name="payment"
|
|
22
|
+
* label="Payment method"
|
|
23
|
+
* value={value}
|
|
24
|
+
* onChange={(e) => setValue(e.target.value)}
|
|
25
|
+
* options={[
|
|
26
|
+
* { value: 'credit', label: 'Credit card' },
|
|
27
|
+
* { value: 'paypal', label: 'PayPal' },
|
|
28
|
+
* { value: 'bank', label: 'Bank transfer' },
|
|
29
|
+
* ]}
|
|
30
|
+
* />
|
|
31
|
+
* )
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* ## Features
|
|
36
|
+
*
|
|
37
|
+
* - **Radio group**: Properly groups radio buttons with shared \`name\` attribute
|
|
38
|
+
* - **Fieldset structure**: Uses semantic fieldset/legend for grouping
|
|
39
|
+
* - **Error handling**: Error messages are announced to screen readers via ARIA
|
|
40
|
+
* - **Helper text**: Optional helper text for additional context
|
|
41
|
+
* - **Keyboard navigation**: Arrow keys navigate between options
|
|
42
|
+
*
|
|
43
|
+
* ## Accessibility
|
|
44
|
+
*
|
|
45
|
+
* ### WCAG 2.1/2.2 Compliance
|
|
46
|
+
*
|
|
47
|
+
* - **1.3.1 Info and Relationships**: Proper fieldset/legend structure for grouping
|
|
48
|
+
* - **2.1.1 Keyboard**: Full keyboard navigation with arrow keys
|
|
49
|
+
* - **2.5.3 Label in Name**: Label text matches accessible name
|
|
50
|
+
* - **4.1.2 Name, Role, Value**: Proper ARIA attributes including \`aria-required\`, \`aria-invalid\`
|
|
51
|
+
*
|
|
52
|
+
* ### Keyboard Interactions
|
|
53
|
+
*
|
|
54
|
+
* | Key | Action |
|
|
55
|
+
* |-----|--------|
|
|
56
|
+
* | **Tab** | Moves focus to the radio group |
|
|
57
|
+
* | **Shift+Tab** | Moves focus away from the radio group |
|
|
58
|
+
* | **Arrow Up/Left** | Move to previous option |
|
|
59
|
+
* | **Arrow Down/Right** | Move to next option |
|
|
60
|
+
* | **Space** | Select focused option |
|
|
61
|
+
*
|
|
62
|
+
* ### Screen Reader Support
|
|
63
|
+
*
|
|
64
|
+
* - Fieldset legend is announced when entering the group
|
|
65
|
+
* - Each option's label is announced when focused
|
|
66
|
+
* - Selected state is announced ("selected" or "not selected")
|
|
67
|
+
* - Required state is announced ("required")
|
|
68
|
+
* - Error messages are announced when present
|
|
69
|
+
* - Helper text is announced via \`aria-describedby\`
|
|
70
|
+
*
|
|
71
|
+
* ### Focus Management
|
|
72
|
+
*
|
|
73
|
+
* - Focus indicators use 2px solid outline with 2px offset
|
|
74
|
+
* - Focus styles respect \`prefers-reduced-motion\` media query
|
|
75
|
+
* - Error state has distinct focus styling
|
|
76
|
+
* - Arrow keys move focus between radio options
|
|
77
|
+
*
|
|
78
|
+
* ## Best Practices
|
|
79
|
+
*
|
|
80
|
+
* 1. **Always provide a label**: Required for accessibility and usability
|
|
81
|
+
* 2. **Use for single choice**: Radio buttons are for selecting one option from multiple choices
|
|
82
|
+
* 3. **Group with fieldset**: Component automatically uses fieldset/legend structure
|
|
83
|
+
* 4. **Provide helpful error messages**: Be specific about what needs to be selected
|
|
84
|
+
* 5. **Use descriptive option labels**: Make it clear what each option means
|
|
85
|
+
*
|
|
86
|
+
* ## Common Pitfalls
|
|
87
|
+
*
|
|
88
|
+
* - Missing label (screen readers can't identify the radio group purpose)
|
|
89
|
+
* - Using radio for multiple selections (use Checkbox for independent choices)
|
|
90
|
+
* - Not providing a default selection (can confuse users)
|
|
91
|
+
* - Vague error messages (be specific about validation failures)
|
|
92
|
+
* - Too many options (consider using Select for 5+ options)
|
|
93
|
+
*
|
|
94
|
+
* @component
|
|
95
|
+
* @example
|
|
96
|
+
* ```tsx
|
|
97
|
+
* <Radio
|
|
98
|
+
* name="size"
|
|
99
|
+
* label="T-shirt size"
|
|
100
|
+
* value={selectedSize}
|
|
101
|
+
* onChange={(e) => setSelectedSize(e.target.value)}
|
|
102
|
+
* options={[
|
|
103
|
+
* { value: 's', label: 'Small' },
|
|
104
|
+
* { value: 'm', label: 'Medium' },
|
|
105
|
+
* { value: 'l', label: 'Large' },
|
|
106
|
+
* ]}
|
|
107
|
+
* />
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
const meta: Meta<typeof Radio> = {
|
|
111
|
+
title: 'Components/Form/Radio',
|
|
112
|
+
component: Radio,
|
|
113
|
+
tags: ['autodocs'],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default meta
|
|
117
|
+
type Story = StoryObj<typeof Radio>
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Standard radio group with multiple options. Users can select one option
|
|
121
|
+
* using arrow keys or by clicking. The component uses proper fieldset/legend structure.
|
|
122
|
+
*/
|
|
123
|
+
export const Default: Story = {
|
|
124
|
+
render: () => {
|
|
125
|
+
const [value, setValue] = useState('')
|
|
126
|
+
return (
|
|
127
|
+
<Radio
|
|
128
|
+
name="radio-default"
|
|
129
|
+
label="Choose a size"
|
|
130
|
+
value={value}
|
|
131
|
+
onChange={(e) => setValue(e.target.value)}
|
|
132
|
+
options={[
|
|
133
|
+
{ value: 'small', label: 'Small' },
|
|
134
|
+
{ value: 'medium', label: 'Medium' },
|
|
135
|
+
{ value: 'large', label: 'Large' },
|
|
136
|
+
]}
|
|
137
|
+
/>
|
|
138
|
+
)
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Radio group with error message. Error messages are announced to screen readers
|
|
144
|
+
* and displayed visually below the group. The group is marked as invalid.
|
|
145
|
+
*/
|
|
146
|
+
export const WithError: Story = {
|
|
147
|
+
render: () => {
|
|
148
|
+
const [value, setValue] = useState('')
|
|
149
|
+
return (
|
|
150
|
+
<Radio
|
|
151
|
+
name="radio-error"
|
|
152
|
+
label="Select a payment method"
|
|
153
|
+
value={value}
|
|
154
|
+
onChange={(e) => setValue(e.target.value)}
|
|
155
|
+
error="Please select a payment method"
|
|
156
|
+
options={[
|
|
157
|
+
{ value: 'credit', label: 'Credit card' },
|
|
158
|
+
{ value: 'paypal', label: 'PayPal' },
|
|
159
|
+
{ value: 'bank', label: 'Bank transfer' },
|
|
160
|
+
]}
|
|
161
|
+
/>
|
|
162
|
+
)
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Radio group with helper text. Helper text provides additional context or guidance
|
|
168
|
+
* and is associated with the group via \`aria-describedby\`.
|
|
169
|
+
*/
|
|
170
|
+
export const WithHelperText: Story = {
|
|
171
|
+
render: () => {
|
|
172
|
+
const [value, setValue] = useState('')
|
|
173
|
+
return (
|
|
174
|
+
<Radio
|
|
175
|
+
name="radio-helper"
|
|
176
|
+
label="Delivery speed"
|
|
177
|
+
value={value}
|
|
178
|
+
onChange={(e) => setValue(e.target.value)}
|
|
179
|
+
helperText="Standard delivery takes 3-5 business days"
|
|
180
|
+
options={[
|
|
181
|
+
{ value: 'standard', label: 'Standard (Free)' },
|
|
182
|
+
{ value: 'express', label: 'Express ($10)' },
|
|
183
|
+
{ value: 'overnight', label: 'Overnight ($25)' },
|
|
184
|
+
]}
|
|
185
|
+
/>
|
|
186
|
+
)
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Required radio group. Required groups are marked with \`aria-required="true"\`
|
|
192
|
+
* and display a visual indicator (asterisk) next to the label.
|
|
193
|
+
*/
|
|
194
|
+
export const Required: Story = {
|
|
195
|
+
render: () => {
|
|
196
|
+
const [value, setValue] = useState('')
|
|
197
|
+
return (
|
|
198
|
+
<Radio
|
|
199
|
+
name="radio-required"
|
|
200
|
+
label="Select an option"
|
|
201
|
+
value={value}
|
|
202
|
+
onChange={(e) => setValue(e.target.value)}
|
|
203
|
+
required
|
|
204
|
+
options={[
|
|
205
|
+
{ value: 'option1', label: 'Option 1' },
|
|
206
|
+
{ value: 'option2', label: 'Option 2' },
|
|
207
|
+
{ value: 'option3', label: 'Option 3' },
|
|
208
|
+
]}
|
|
209
|
+
/>
|
|
210
|
+
)
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Radio group with a pre-selected option. Shows the component with a default value selected.
|
|
216
|
+
*/
|
|
217
|
+
export const WithDefaultValue: Story = {
|
|
218
|
+
render: () => {
|
|
219
|
+
const [value, setValue] = useState('medium')
|
|
220
|
+
return (
|
|
221
|
+
<Radio
|
|
222
|
+
name="radio-default-value"
|
|
223
|
+
label="Choose a size"
|
|
224
|
+
value={value}
|
|
225
|
+
onChange={(e) => setValue(e.target.value)}
|
|
226
|
+
options={[
|
|
227
|
+
{ value: 'small', label: 'Small' },
|
|
228
|
+
{ value: 'medium', label: 'Medium' },
|
|
229
|
+
{ value: 'large', label: 'Large' },
|
|
230
|
+
]}
|
|
231
|
+
/>
|
|
232
|
+
)
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Radio group with disabled options. Individual options can be disabled
|
|
238
|
+
* while keeping others enabled.
|
|
239
|
+
*/
|
|
240
|
+
export const WithDisabledOptions: Story = {
|
|
241
|
+
render: () => {
|
|
242
|
+
const [value, setValue] = useState('')
|
|
243
|
+
return (
|
|
244
|
+
<Radio
|
|
245
|
+
name="radio-disabled"
|
|
246
|
+
label="Select plan"
|
|
247
|
+
value={value}
|
|
248
|
+
onChange={(e) => setValue(e.target.value)}
|
|
249
|
+
options={[
|
|
250
|
+
{ value: 'basic', label: 'Basic Plan' },
|
|
251
|
+
{ value: 'pro', label: 'Pro Plan' },
|
|
252
|
+
{ value: 'enterprise', label: 'Enterprise Plan', disabled: true },
|
|
253
|
+
]}
|
|
254
|
+
/>
|
|
255
|
+
)
|
|
256
|
+
},
|
|
257
|
+
parameters: {
|
|
258
|
+
docs: {
|
|
259
|
+
description: {
|
|
260
|
+
story: 'Some options can be disabled while keeping others selectable. Disabled options are announced as "disabled" by screen readers.',
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { combineAriaDescribedBy } from '../../utils/aria'
|
|
5
|
+
import './Radio.css'
|
|
6
|
+
|
|
7
|
+
export interface RadioOption {
|
|
8
|
+
value: string
|
|
9
|
+
label: string
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RadioProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
14
|
+
/**
|
|
15
|
+
* Options for the radio group
|
|
16
|
+
*/
|
|
17
|
+
options: RadioOption[]
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Name attribute for the radio group (required)
|
|
21
|
+
*/
|
|
22
|
+
name: string
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Label for the radio group
|
|
26
|
+
*/
|
|
27
|
+
label?: string
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Error message to display
|
|
31
|
+
*/
|
|
32
|
+
error?: string
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Helper text to display
|
|
36
|
+
*/
|
|
37
|
+
helperText?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Accessible Radio component (radio group)
|
|
42
|
+
*
|
|
43
|
+
* WCAG Compliance:
|
|
44
|
+
* - 1.3.1 Info and Relationships: Proper fieldset/legend structure
|
|
45
|
+
* - 2.1.1 Keyboard: Full keyboard navigation
|
|
46
|
+
* - 4.1.2 Name, Role, Value: Proper ARIA attributes
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```tsx
|
|
50
|
+
* <Radio
|
|
51
|
+
* name="size"
|
|
52
|
+
* label="Size"
|
|
53
|
+
* options={[
|
|
54
|
+
* { value: 's', label: 'Small' },
|
|
55
|
+
* { value: 'm', label: 'Medium' },
|
|
56
|
+
* ]}
|
|
57
|
+
* value={selected}
|
|
58
|
+
* onChange={handleChange}
|
|
59
|
+
* />
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export const Radio = React.forwardRef<HTMLInputElement, RadioProps>(
|
|
63
|
+
(
|
|
64
|
+
{
|
|
65
|
+
name,
|
|
66
|
+
options,
|
|
67
|
+
label,
|
|
68
|
+
error,
|
|
69
|
+
helperText,
|
|
70
|
+
className = '',
|
|
71
|
+
value,
|
|
72
|
+
onChange,
|
|
73
|
+
disabled,
|
|
74
|
+
required,
|
|
75
|
+
'aria-describedby': ariaDescribedBy,
|
|
76
|
+
...props
|
|
77
|
+
},
|
|
78
|
+
ref
|
|
79
|
+
) => {
|
|
80
|
+
const groupId = React.useId()
|
|
81
|
+
const errorId = error ? `radio-${groupId}-error` : undefined
|
|
82
|
+
const helperId = helperText ? `radio-${groupId}-helper` : undefined
|
|
83
|
+
|
|
84
|
+
const describedBy = combineAriaDescribedBy(
|
|
85
|
+
ariaDescribedBy,
|
|
86
|
+
errorId,
|
|
87
|
+
helperId
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div className="form-radio-wrapper">
|
|
92
|
+
{label && (
|
|
93
|
+
<div className="form-radio-label" role="group" aria-labelledby={label ? `radio-label-${groupId}` : undefined}>
|
|
94
|
+
<span id={`radio-label-${groupId}`} className="form-label">
|
|
95
|
+
{label}
|
|
96
|
+
{required && (
|
|
97
|
+
<span className="form-label__required" aria-hidden="true">
|
|
98
|
+
{' '}*
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</span>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
<div className="form-radio-group" role="radiogroup" aria-describedby={describedBy} aria-invalid={error ? true : undefined}>
|
|
105
|
+
{options.map((option, index) => {
|
|
106
|
+
const optionId = `radio-${groupId}-${index}`
|
|
107
|
+
const isChecked = value === option.value
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div key={option.value} className="form-radio-option">
|
|
111
|
+
<input
|
|
112
|
+
ref={index === 0 ? ref : undefined}
|
|
113
|
+
id={optionId}
|
|
114
|
+
type="radio"
|
|
115
|
+
name={name}
|
|
116
|
+
value={option.value}
|
|
117
|
+
checked={isChecked}
|
|
118
|
+
onChange={onChange}
|
|
119
|
+
disabled={option.disabled || disabled}
|
|
120
|
+
required={required}
|
|
121
|
+
className="form-radio"
|
|
122
|
+
{...props}
|
|
123
|
+
/>
|
|
124
|
+
<label htmlFor={optionId} className="form-radio-label">
|
|
125
|
+
{option.label}
|
|
126
|
+
</label>
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
})}
|
|
130
|
+
</div>
|
|
131
|
+
{helperText && !error && (
|
|
132
|
+
<span id={helperId} className="form-helper-text">
|
|
133
|
+
{helperText}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
{error && (
|
|
137
|
+
<span id={errorId} className="form-error-text" role="alert">
|
|
138
|
+
{error}
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
Radio.displayName = 'Radio'
|
|
147
|
+
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { Select } from './Select'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* # Select Component
|
|
7
|
+
*
|
|
8
|
+
* An accessible select dropdown component with proper label association, error handling,
|
|
9
|
+
* and ARIA attributes. Supports keyboard navigation and screen reader announcements.
|
|
10
|
+
*
|
|
11
|
+
* ## Usage
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* import { Select } from '@a11ypros/a11y-ui-components'
|
|
15
|
+
*
|
|
16
|
+
* function MyComponent() {
|
|
17
|
+
* const [value, setValue] = useState('')
|
|
18
|
+
*
|
|
19
|
+
* return (
|
|
20
|
+
* <Select
|
|
21
|
+
* label="Country"
|
|
22
|
+
* value={value}
|
|
23
|
+
* onChange={(e) => setValue(e.target.value)}
|
|
24
|
+
* options={[
|
|
25
|
+
* { value: 'us', label: 'United States' },
|
|
26
|
+
* { value: 'ca', label: 'Canada' },
|
|
27
|
+
* { value: 'uk', label: 'United Kingdom' },
|
|
28
|
+
* ]}
|
|
29
|
+
* />
|
|
30
|
+
* )
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* ## Features
|
|
35
|
+
*
|
|
36
|
+
* - **Label association**: Labels are properly associated with selects using \`htmlFor\` and \`id\`
|
|
37
|
+
* - **Error handling**: Error messages are announced to screen readers via ARIA
|
|
38
|
+
* - **Helper text**: Optional helper text for additional context
|
|
39
|
+
* - **Placeholder option**: Optional placeholder text for the first option
|
|
40
|
+
* - **Keyboard navigation**: Arrow keys navigate options, Enter selects
|
|
41
|
+
*
|
|
42
|
+
* ## Accessibility
|
|
43
|
+
*
|
|
44
|
+
* ### WCAG 2.1/2.2 Compliance
|
|
45
|
+
*
|
|
46
|
+
* - **1.3.1 Info and Relationships**: Proper label-select association via \`htmlFor\` and \`id\`
|
|
47
|
+
* - **2.1.1 Keyboard**: Full keyboard navigation with arrow keys and Enter
|
|
48
|
+
* - **2.5.3 Label in Name**: Label text matches accessible name
|
|
49
|
+
* - **4.1.2 Name, Role, Value**: Proper ARIA attributes including \`aria-required\`, \`aria-invalid\`, \`aria-describedby\`
|
|
50
|
+
*
|
|
51
|
+
* ### Keyboard Interactions
|
|
52
|
+
*
|
|
53
|
+
* | Key | Action |
|
|
54
|
+
* |-----|--------|
|
|
55
|
+
* | **Tab** | Moves focus to the select |
|
|
56
|
+
* | **Shift+Tab** | Moves focus away from the select |
|
|
57
|
+
* | **Arrow Up** | Move to previous option |
|
|
58
|
+
* | **Arrow Down** | Move to next option |
|
|
59
|
+
* | **Enter** | Open/close dropdown and select option |
|
|
60
|
+
* | **Space** | Open/close dropdown |
|
|
61
|
+
* | **Escape** | Close dropdown |
|
|
62
|
+
* | **Home** | Move to first option |
|
|
63
|
+
* | **End** | Move to last option |
|
|
64
|
+
*
|
|
65
|
+
* ### Screen Reader Support
|
|
66
|
+
*
|
|
67
|
+
* - Select role and label are announced when focused
|
|
68
|
+
* - Selected option value is announced
|
|
69
|
+
* - Required state is announced ("required")
|
|
70
|
+
* - Error messages are announced when present
|
|
71
|
+
* - Helper text is announced via \`aria-describedby\`
|
|
72
|
+
* - Placeholder option is announced appropriately
|
|
73
|
+
*
|
|
74
|
+
* ### Focus Management
|
|
75
|
+
*
|
|
76
|
+
* - Focus indicators use 2px solid outline with 2px offset
|
|
77
|
+
* - Focus styles respect \`prefers-reduced-motion\` media query
|
|
78
|
+
* - Error state has distinct focus styling
|
|
79
|
+
* - Focus visible only on keyboard navigation using \`:focus-visible\`
|
|
80
|
+
*
|
|
81
|
+
* ## Best Practices
|
|
82
|
+
*
|
|
83
|
+
* - **Always provide a label**: Required for accessibility and usability
|
|
84
|
+
* - **Use for 5+ options**: Select is ideal for longer lists (use Radio for 2-4 options)
|
|
85
|
+
* - **Provide helpful error messages**: Be specific about what needs to be selected
|
|
86
|
+
* - **Use placeholder for guidance**: Help users understand what to select
|
|
87
|
+
* - **Group related options**: Use optgroups for logical grouping when appropriate
|
|
88
|
+
*
|
|
89
|
+
* ## Common Pitfalls
|
|
90
|
+
*
|
|
91
|
+
* - Missing label (screen readers can't identify the select)
|
|
92
|
+
* - Using select for 2-3 options (use Radio buttons instead)
|
|
93
|
+
* - Vague error messages (be specific about validation failures)
|
|
94
|
+
* - Not providing a placeholder (users don't know what to select)
|
|
95
|
+
* - Too many options without grouping (becomes hard to navigate)
|
|
96
|
+
*
|
|
97
|
+
* @component
|
|
98
|
+
* @example
|
|
99
|
+
* ```tsx
|
|
100
|
+
* <Select
|
|
101
|
+
* label="Country"
|
|
102
|
+
* value={country}
|
|
103
|
+
* onChange={(e) => setCountry(e.target.value)}
|
|
104
|
+
* options={countries}
|
|
105
|
+
* required
|
|
106
|
+
* error={errors.country}
|
|
107
|
+
* />
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
const meta: Meta<typeof Select> = {
|
|
111
|
+
title: 'Components/Form/Select',
|
|
112
|
+
component: Select,
|
|
113
|
+
tags: ['autodocs'],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export default meta
|
|
117
|
+
type Story = StoryObj<typeof Select>
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Standard select dropdown with multiple options. The label is properly associated
|
|
121
|
+
* with the select for screen readers and clicking the label focuses the select.
|
|
122
|
+
*/
|
|
123
|
+
export const Default: Story = {
|
|
124
|
+
render: () => {
|
|
125
|
+
const [value, setValue] = useState('')
|
|
126
|
+
return (
|
|
127
|
+
<Select
|
|
128
|
+
id="select-default"
|
|
129
|
+
label="Choose a country"
|
|
130
|
+
value={value}
|
|
131
|
+
onChange={(e) => setValue(e.target.value)}
|
|
132
|
+
options={[
|
|
133
|
+
{ value: 'us', label: 'United States' },
|
|
134
|
+
{ value: 'ca', label: 'Canada' },
|
|
135
|
+
{ value: 'uk', label: 'United Kingdom' },
|
|
136
|
+
{ value: 'au', label: 'Australia' },
|
|
137
|
+
]}
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Select with placeholder option. The placeholder helps users understand
|
|
145
|
+
* what to select and is typically not a valid selection option.
|
|
146
|
+
*/
|
|
147
|
+
export const WithPlaceholder: Story = {
|
|
148
|
+
render: () => {
|
|
149
|
+
const [value, setValue] = useState('')
|
|
150
|
+
return (
|
|
151
|
+
<Select
|
|
152
|
+
id="select-placeholder"
|
|
153
|
+
label="Payment method"
|
|
154
|
+
value={value}
|
|
155
|
+
onChange={(e) => setValue(e.target.value)}
|
|
156
|
+
placeholder="Select a payment method"
|
|
157
|
+
options={[
|
|
158
|
+
{ value: 'credit', label: 'Credit Card' },
|
|
159
|
+
{ value: 'paypal', label: 'PayPal' },
|
|
160
|
+
{ value: 'bank', label: 'Bank Transfer' },
|
|
161
|
+
]}
|
|
162
|
+
/>
|
|
163
|
+
)
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Select with error message. Error messages are announced to screen readers
|
|
169
|
+
* and displayed visually below the select. The select is marked as invalid
|
|
170
|
+
* with \`aria-invalid="true"\`.
|
|
171
|
+
*/
|
|
172
|
+
export const WithError: Story = {
|
|
173
|
+
render: () => {
|
|
174
|
+
const [value, setValue] = useState('')
|
|
175
|
+
return (
|
|
176
|
+
<Select
|
|
177
|
+
id="select-error"
|
|
178
|
+
label="Country"
|
|
179
|
+
value={value}
|
|
180
|
+
onChange={(e) => setValue(e.target.value)}
|
|
181
|
+
error="Please select a country"
|
|
182
|
+
options={[
|
|
183
|
+
{ value: 'us', label: 'United States' },
|
|
184
|
+
{ value: 'ca', label: 'Canada' },
|
|
185
|
+
{ value: 'uk', label: 'United Kingdom' },
|
|
186
|
+
]}
|
|
187
|
+
/>
|
|
188
|
+
)
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Select with helper text. Helper text provides additional context or guidance
|
|
194
|
+
* and is associated with the select via \`aria-describedby\`.
|
|
195
|
+
*/
|
|
196
|
+
export const WithHelperText: Story = {
|
|
197
|
+
render: () => {
|
|
198
|
+
const [value, setValue] = useState('')
|
|
199
|
+
return (
|
|
200
|
+
<Select
|
|
201
|
+
id="select-helper"
|
|
202
|
+
label="Shipping method"
|
|
203
|
+
value={value}
|
|
204
|
+
onChange={(e) => setValue(e.target.value)}
|
|
205
|
+
helperText="Standard shipping takes 3-5 business days"
|
|
206
|
+
options={[
|
|
207
|
+
{ value: 'standard', label: 'Standard (Free)' },
|
|
208
|
+
{ value: 'express', label: 'Express ($10)' },
|
|
209
|
+
{ value: 'overnight', label: 'Overnight ($25)' },
|
|
210
|
+
]}
|
|
211
|
+
/>
|
|
212
|
+
)
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Required select field. Required selects are marked with \`aria-required="true"\`
|
|
218
|
+
* and display a visual indicator (asterisk) next to the label.
|
|
219
|
+
*/
|
|
220
|
+
export const Required: Story = {
|
|
221
|
+
render: () => {
|
|
222
|
+
const [value, setValue] = useState('')
|
|
223
|
+
return (
|
|
224
|
+
<Select
|
|
225
|
+
id="select-required"
|
|
226
|
+
label="State"
|
|
227
|
+
value={value}
|
|
228
|
+
onChange={(e) => setValue(e.target.value)}
|
|
229
|
+
required
|
|
230
|
+
placeholder="Select a state"
|
|
231
|
+
options={[
|
|
232
|
+
{ value: 'ca', label: 'California' },
|
|
233
|
+
{ value: 'ny', label: 'New York' },
|
|
234
|
+
{ value: 'tx', label: 'Texas' },
|
|
235
|
+
]}
|
|
236
|
+
/>
|
|
237
|
+
)
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Disabled select that cannot be changed. Disabled selects are announced as
|
|
243
|
+
* "disabled" by screen readers and appear visually dimmed.
|
|
244
|
+
*/
|
|
245
|
+
export const Disabled: Story = {
|
|
246
|
+
args: {
|
|
247
|
+
id: 'select-disabled',
|
|
248
|
+
label: 'Disabled select',
|
|
249
|
+
disabled: true,
|
|
250
|
+
value: 'option1',
|
|
251
|
+
options: [
|
|
252
|
+
{ value: 'option1', label: 'Option 1' },
|
|
253
|
+
{ value: 'option2', label: 'Option 2' },
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Select with many options. For long lists, consider grouping related options
|
|
260
|
+
* or using a searchable select component for better usability.
|
|
261
|
+
*/
|
|
262
|
+
export const ManyOptions: Story = {
|
|
263
|
+
render: () => {
|
|
264
|
+
const [value, setValue] = useState('')
|
|
265
|
+
return (
|
|
266
|
+
<Select
|
|
267
|
+
id="select-many"
|
|
268
|
+
label="Choose a city"
|
|
269
|
+
value={value}
|
|
270
|
+
onChange={(e) => setValue(e.target.value)}
|
|
271
|
+
placeholder="Select a city"
|
|
272
|
+
options={[
|
|
273
|
+
{ value: 'ny', label: 'New York' },
|
|
274
|
+
{ value: 'la', label: 'Los Angeles' },
|
|
275
|
+
{ value: 'ch', label: 'Chicago' },
|
|
276
|
+
{ value: 'ho', label: 'Houston' },
|
|
277
|
+
{ value: 'ph', label: 'Phoenix' },
|
|
278
|
+
{ value: 'ph2', label: 'Philadelphia' },
|
|
279
|
+
{ value: 'sa', label: 'San Antonio' },
|
|
280
|
+
{ value: 'sd', label: 'San Diego' },
|
|
281
|
+
{ value: 'da', label: 'Dallas' },
|
|
282
|
+
{ value: 'sj', label: 'San Jose' },
|
|
283
|
+
]}
|
|
284
|
+
/>
|
|
285
|
+
)
|
|
286
|
+
},
|
|
287
|
+
parameters: {
|
|
288
|
+
docs: {
|
|
289
|
+
description: {
|
|
290
|
+
story: 'Select with many options. For very long lists (20+ options), consider using a searchable select or grouping options.',
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|