@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.
Files changed (217) hide show
  1. package/.storybook/custom.css +69 -0
  2. package/.storybook/main.ts +46 -0
  3. package/.storybook/manager.ts +26 -0
  4. package/.storybook/package.json +6 -0
  5. package/.storybook/preview.tsx +31 -0
  6. package/.storybook/public/logo.png +0 -0
  7. package/.storybook/vite.config.ts +24 -0
  8. package/.storybook/welcome.mdx +97 -0
  9. package/DEPLOYMENT.md +154 -0
  10. package/README.md +227 -0
  11. package/apps/web/app/(docs)/audit/audit.css +269 -0
  12. package/apps/web/app/(docs)/audit/page.tsx +271 -0
  13. package/apps/web/app/(docs)/components/button/page.tsx +49 -0
  14. package/apps/web/app/(docs)/components/form/page.tsx +92 -0
  15. package/apps/web/app/(docs)/components/link/page.tsx +31 -0
  16. package/apps/web/app/(docs)/components/modal/page.tsx +41 -0
  17. package/apps/web/app/(docs)/components/page.tsx +37 -0
  18. package/apps/web/app/(docs)/components/table/page.tsx +54 -0
  19. package/apps/web/app/(docs)/components/tabs/page.tsx +61 -0
  20. package/apps/web/app/(docs)/components/toast/page.tsx +51 -0
  21. package/apps/web/app/api/audit/route.ts +128 -0
  22. package/apps/web/app/favicon.ico +0 -0
  23. package/apps/web/app/layout.tsx +20 -0
  24. package/apps/web/app/page.tsx +17 -0
  25. package/apps/web/app/styles/globals.css +5 -0
  26. package/apps/web/next-env.d.ts +5 -0
  27. package/apps/web/next.config.js +21 -0
  28. package/apps/web/package.json +28 -0
  29. package/apps/web/public/_headers +17 -0
  30. package/apps/web/public/_redirects +31 -0
  31. package/apps/web/public/logo.png +0 -0
  32. package/apps/web/tsconfig.json +29 -0
  33. package/netlify/functions/audit.ts +163 -0
  34. package/netlify.toml +37 -0
  35. package/package.json +30 -58
  36. package/packages/design-system/README.md +252 -0
  37. package/packages/design-system/package.json +68 -0
  38. package/packages/design-system/scripts/copy-css.js +63 -0
  39. package/packages/design-system/src/components/Button/Button.stories.tsx +228 -0
  40. package/packages/design-system/src/components/Button/Button.tsx +137 -0
  41. package/packages/design-system/src/components/Button/index.ts +3 -0
  42. package/packages/design-system/src/components/DataTable/DataTable.stories.tsx +211 -0
  43. package/packages/design-system/src/components/DataTable/DataTable.tsx +293 -0
  44. package/packages/design-system/src/components/DataTable/index.ts +3 -0
  45. package/packages/design-system/src/components/Form/Checkbox.stories.tsx +252 -0
  46. package/packages/design-system/src/components/Form/Checkbox.tsx +114 -0
  47. package/packages/design-system/src/components/Form/Fieldset.stories.tsx +210 -0
  48. package/packages/design-system/src/components/Form/Fieldset.tsx +71 -0
  49. package/packages/design-system/src/components/Form/Input.stories.tsx +164 -0
  50. package/packages/design-system/src/components/Form/Input.tsx +113 -0
  51. package/packages/design-system/src/components/Form/Label.tsx +56 -0
  52. package/packages/design-system/src/components/Form/Radio.stories.tsx +265 -0
  53. package/packages/design-system/src/components/Form/Radio.tsx +147 -0
  54. package/packages/design-system/src/components/Form/Select.stories.tsx +295 -0
  55. package/packages/design-system/src/components/Form/Select.tsx +160 -0
  56. package/packages/design-system/src/components/Form/Textarea.stories.tsx +253 -0
  57. package/packages/design-system/src/components/Form/Textarea.tsx +145 -0
  58. package/packages/design-system/src/components/Form/index.ts +8 -0
  59. package/packages/design-system/src/components/Link/Link.stories.tsx +128 -0
  60. package/packages/design-system/src/components/Link/Link.tsx +117 -0
  61. package/packages/design-system/src/components/Link/index.ts +3 -0
  62. package/packages/design-system/src/components/Modal/Modal.stories.tsx +165 -0
  63. package/packages/design-system/src/components/Modal/Modal.tsx +202 -0
  64. package/packages/design-system/src/components/Modal/index.ts +3 -0
  65. package/packages/design-system/src/components/Tabs/Tabs.stories.tsx +213 -0
  66. package/packages/design-system/src/components/Tabs/Tabs.tsx +248 -0
  67. package/packages/design-system/src/components/Tabs/index.ts +3 -0
  68. package/packages/design-system/src/components/Toast/Toast.stories.tsx +153 -0
  69. package/packages/design-system/src/components/Toast/Toast.tsx +175 -0
  70. package/packages/design-system/src/components/Toast/ToastProvider.tsx +73 -0
  71. package/packages/design-system/src/components/Toast/index.ts +5 -0
  72. package/packages/design-system/src/hooks/useAriaLive.ts +51 -0
  73. package/packages/design-system/src/hooks/useFocusReturn.ts +40 -0
  74. package/packages/design-system/src/hooks/useFocusTrap.ts +82 -0
  75. package/{dist/index.js → packages/design-system/src/index.ts} +4 -0
  76. package/packages/design-system/src/styles/index.ts +3 -0
  77. package/packages/design-system/src/tokens/breakpoints.ts +28 -0
  78. package/packages/design-system/src/tokens/colors.ts +98 -0
  79. package/packages/design-system/src/tokens/index.ts +6 -0
  80. package/packages/design-system/src/tokens/motion.ts +41 -0
  81. package/packages/design-system/src/tokens/spacing.ts +24 -0
  82. package/packages/design-system/src/tokens/theme.ts +19 -0
  83. package/packages/design-system/src/tokens/typography.ts +64 -0
  84. package/packages/design-system/src/utils/aria.ts +108 -0
  85. package/packages/design-system/src/utils/focus.ts +87 -0
  86. package/packages/design-system/src/utils/index.ts +4 -0
  87. package/packages/design-system/src/utils/keyboard.ts +77 -0
  88. package/packages/design-system/tsconfig.json +17 -0
  89. package/public/logo.png +0 -0
  90. package/scripts/fix-storybook-paths.js +53 -0
  91. package/tsconfig.json +20 -0
  92. package/dist/components/Button/Button.d.ts +0 -37
  93. package/dist/components/Button/Button.d.ts.map +0 -1
  94. package/dist/components/Button/Button.js +0 -52
  95. package/dist/components/Button/index.d.ts +0 -3
  96. package/dist/components/Button/index.d.ts.map +0 -1
  97. package/dist/components/Button/index.js +0 -1
  98. package/dist/components/DataTable/DataTable.d.ts +0 -71
  99. package/dist/components/DataTable/DataTable.d.ts.map +0 -1
  100. package/dist/components/DataTable/DataTable.js +0 -122
  101. package/dist/components/DataTable/index.d.ts +0 -3
  102. package/dist/components/DataTable/index.d.ts.map +0 -1
  103. package/dist/components/DataTable/index.js +0 -1
  104. package/dist/components/Form/Checkbox.d.ts +0 -36
  105. package/dist/components/Form/Checkbox.d.ts.map +0 -1
  106. package/dist/components/Form/Checkbox.js +0 -39
  107. package/dist/components/Form/Fieldset.d.ts +0 -33
  108. package/dist/components/Form/Fieldset.d.ts.map +0 -1
  109. package/dist/components/Form/Fieldset.js +0 -34
  110. package/dist/components/Form/Input.d.ts +0 -37
  111. package/dist/components/Form/Input.d.ts.map +0 -1
  112. package/dist/components/Form/Input.js +0 -41
  113. package/dist/components/Form/Label.d.ts +0 -30
  114. package/dist/components/Form/Label.d.ts.map +0 -1
  115. package/dist/components/Form/Label.js +0 -30
  116. package/dist/components/Form/Radio.d.ts +0 -53
  117. package/dist/components/Form/Radio.d.ts.map +0 -1
  118. package/dist/components/Form/Radio.js +0 -39
  119. package/dist/components/Form/Select.d.ts +0 -51
  120. package/dist/components/Form/Select.d.ts.map +0 -1
  121. package/dist/components/Form/Select.js +0 -49
  122. package/dist/components/Form/Textarea.d.ts +0 -44
  123. package/dist/components/Form/Textarea.d.ts.map +0 -1
  124. package/dist/components/Form/Textarea.js +0 -43
  125. package/dist/components/Form/index.d.ts +0 -8
  126. package/dist/components/Form/index.d.ts.map +0 -1
  127. package/dist/components/Form/index.js +0 -7
  128. package/dist/components/Link/Link.d.ts +0 -34
  129. package/dist/components/Link/Link.d.ts.map +0 -1
  130. package/dist/components/Link/Link.js +0 -48
  131. package/dist/components/Link/index.d.ts +0 -3
  132. package/dist/components/Link/index.d.ts.map +0 -1
  133. package/dist/components/Link/index.js +0 -1
  134. package/dist/components/Modal/Modal.d.ts +0 -64
  135. package/dist/components/Modal/Modal.d.ts.map +0 -1
  136. package/dist/components/Modal/Modal.js +0 -108
  137. package/dist/components/Modal/index.d.ts +0 -3
  138. package/dist/components/Modal/index.d.ts.map +0 -1
  139. package/dist/components/Modal/index.js +0 -1
  140. package/dist/components/Tabs/Tabs.d.ts +0 -63
  141. package/dist/components/Tabs/Tabs.d.ts.map +0 -1
  142. package/dist/components/Tabs/Tabs.js +0 -134
  143. package/dist/components/Tabs/index.d.ts +0 -3
  144. package/dist/components/Tabs/index.d.ts.map +0 -1
  145. package/dist/components/Tabs/index.js +0 -1
  146. package/dist/components/Toast/Toast.d.ts +0 -59
  147. package/dist/components/Toast/Toast.d.ts.map +0 -1
  148. package/dist/components/Toast/Toast.js +0 -91
  149. package/dist/components/Toast/ToastProvider.d.ts +0 -22
  150. package/dist/components/Toast/ToastProvider.d.ts.map +0 -1
  151. package/dist/components/Toast/ToastProvider.js +0 -33
  152. package/dist/components/Toast/index.d.ts +0 -5
  153. package/dist/components/Toast/index.d.ts.map +0 -1
  154. package/dist/components/Toast/index.js +0 -2
  155. package/dist/hooks/useAriaLive.d.ts +0 -9
  156. package/dist/hooks/useAriaLive.d.ts.map +0 -1
  157. package/dist/hooks/useAriaLive.js +0 -39
  158. package/dist/hooks/useFocusReturn.d.ts +0 -9
  159. package/dist/hooks/useFocusReturn.d.ts.map +0 -1
  160. package/dist/hooks/useFocusReturn.js +0 -33
  161. package/dist/hooks/useFocusTrap.d.ts +0 -9
  162. package/dist/hooks/useFocusTrap.d.ts.map +0 -1
  163. package/dist/hooks/useFocusTrap.js +0 -68
  164. package/dist/index.d.ts +0 -22
  165. package/dist/index.d.ts.map +0 -1
  166. package/dist/styles/index.d.ts +0 -3
  167. package/dist/styles/index.d.ts.map +0 -1
  168. package/dist/styles/index.js +0 -1
  169. package/dist/tokens/breakpoints.d.ts +0 -25
  170. package/dist/tokens/breakpoints.d.ts.map +0 -1
  171. package/dist/tokens/breakpoints.js +0 -23
  172. package/dist/tokens/colors.d.ts +0 -81
  173. package/dist/tokens/colors.d.ts.map +0 -1
  174. package/dist/tokens/colors.js +0 -86
  175. package/dist/tokens/index.d.ts +0 -6
  176. package/dist/tokens/index.d.ts.map +0 -1
  177. package/dist/tokens/index.js +0 -5
  178. package/dist/tokens/motion.d.ts +0 -30
  179. package/dist/tokens/motion.d.ts.map +0 -1
  180. package/dist/tokens/motion.js +0 -34
  181. package/dist/tokens/spacing.d.ts +0 -22
  182. package/dist/tokens/spacing.d.ts.map +0 -1
  183. package/dist/tokens/spacing.js +0 -20
  184. package/dist/tokens/theme.d.ts +0 -159
  185. package/dist/tokens/theme.d.ts.map +0 -1
  186. package/dist/tokens/theme.js +0 -15
  187. package/dist/tokens/typography.d.ts +0 -45
  188. package/dist/tokens/typography.d.ts.map +0 -1
  189. package/dist/tokens/typography.js +0 -56
  190. package/dist/utils/aria.d.ts +0 -60
  191. package/dist/utils/aria.d.ts.map +0 -1
  192. package/dist/utils/aria.js +0 -86
  193. package/dist/utils/focus.d.ts +0 -30
  194. package/dist/utils/focus.d.ts.map +0 -1
  195. package/dist/utils/focus.js +0 -80
  196. package/dist/utils/index.d.ts +0 -4
  197. package/dist/utils/index.d.ts.map +0 -1
  198. package/dist/utils/index.js +0 -3
  199. package/dist/utils/keyboard.d.ts +0 -38
  200. package/dist/utils/keyboard.d.ts.map +0 -1
  201. package/dist/utils/keyboard.js +0 -59
  202. /package/{dist → packages/design-system/src}/components/Button/Button.css +0 -0
  203. /package/{dist → packages/design-system/src}/components/DataTable/DataTable.css +0 -0
  204. /package/{dist → packages/design-system/src}/components/Form/Checkbox.css +0 -0
  205. /package/{dist → packages/design-system/src}/components/Form/Fieldset.css +0 -0
  206. /package/{dist → packages/design-system/src}/components/Form/Input.css +0 -0
  207. /package/{dist → packages/design-system/src}/components/Form/Label.css +0 -0
  208. /package/{dist → packages/design-system/src}/components/Form/Radio.css +0 -0
  209. /package/{dist → packages/design-system/src}/components/Form/Select.css +0 -0
  210. /package/{dist → packages/design-system/src}/components/Form/Textarea.css +0 -0
  211. /package/{dist → packages/design-system/src}/components/Link/Link.css +0 -0
  212. /package/{dist → packages/design-system/src}/components/Modal/Modal.css +0 -0
  213. /package/{dist → packages/design-system/src}/components/Tabs/Tabs.css +0 -0
  214. /package/{dist → packages/design-system/src}/components/Toast/Toast.css +0 -0
  215. /package/{dist → packages/design-system/src}/components/Toast/ToastProvider.css +0 -0
  216. /package/{dist → packages/design-system/src}/styles/components.css +0 -0
  217. /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
+