@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,248 @@
1
+ 'use client'
2
+
3
+ import React, { useState, useCallback, useRef } from 'react'
4
+ import { createArrowKeyHandler, isNavigationKey, isArrowKey } from '../../utils/keyboard'
5
+ import { getExpandedAttributes, getSelectedAttributes, getCurrentAttributes } from '../../utils/aria'
6
+ import './Tabs.css'
7
+
8
+ export interface TabItem {
9
+ id: string
10
+ label: string
11
+ content: React.ReactNode
12
+ disabled?: boolean
13
+ }
14
+
15
+ export interface TabsProps {
16
+ /**
17
+ * Tab items
18
+ */
19
+ items: TabItem[]
20
+
21
+ /**
22
+ * Default selected tab ID
23
+ */
24
+ defaultSelectedId?: string
25
+
26
+ /**
27
+ * Controlled selected tab ID
28
+ */
29
+ selectedId?: string
30
+
31
+ /**
32
+ * Callback when tab selection changes
33
+ */
34
+ onSelectionChange?: (id: string) => void
35
+
36
+ /**
37
+ * Orientation of tabs
38
+ */
39
+ orientation?: 'horizontal' | 'vertical'
40
+
41
+ /**
42
+ * Activation mode for tabs
43
+ * - 'automatic': Arrow keys both move focus and activate tabs immediately
44
+ * - 'manual': Arrow keys move focus only, Enter/Space activates the focused tab
45
+ * @default 'automatic'
46
+ */
47
+ activationMode?: 'automatic' | 'manual'
48
+
49
+ /**
50
+ * Label for the tab list (required for accessibility)
51
+ */
52
+ 'aria-label'?: string
53
+ 'aria-labelledby'?: string
54
+ }
55
+
56
+ /**
57
+ * Accessible Tabs component
58
+ *
59
+ * WCAG Compliance:
60
+ * - 2.1.1 Keyboard: Arrow key navigation, Home/End support
61
+ * - 4.1.2 Name, Role, Value: ARIA tabs pattern
62
+ * - 2.4.3 Focus Order: Proper focus management
63
+ *
64
+ * @example
65
+ * ```tsx
66
+ * <Tabs
67
+ * items={[
68
+ * { id: 'tab1', label: 'Tab 1', content: <div>Content 1</div> },
69
+ * { id: 'tab2', label: 'Tab 2', content: <div>Content 2</div> },
70
+ * ]}
71
+ * aria-label="Settings tabs"
72
+ * />
73
+ * ```
74
+ */
75
+ export const Tabs: React.FC<TabsProps> = ({
76
+ items,
77
+ defaultSelectedId,
78
+ selectedId: controlledSelectedId,
79
+ onSelectionChange,
80
+ orientation = 'horizontal',
81
+ activationMode = 'automatic',
82
+ 'aria-label': ariaLabel,
83
+ 'aria-labelledby': ariaLabelledBy,
84
+ }) => {
85
+ const initialSelectedId = defaultSelectedId || items[0]?.id
86
+ const [internalSelectedId, setInternalSelectedId] = useState(initialSelectedId)
87
+ const [focusedId, setFocusedId] = useState<string | null>(initialSelectedId)
88
+ const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map())
89
+
90
+ const selectedId = controlledSelectedId ?? internalSelectedId
91
+ const selectedIndex = items.findIndex((item) => item.id === selectedId)
92
+
93
+ // In automatic mode, focused tab is always the selected tab
94
+ // In manual mode, focused tab can be different from selected tab
95
+ const effectiveFocusedId = activationMode === 'automatic' ? selectedId : (focusedId || selectedId)
96
+
97
+ const handleSelect = useCallback(
98
+ (id: string) => {
99
+ if (onSelectionChange) {
100
+ onSelectionChange(id)
101
+ } else {
102
+ setInternalSelectedId(id)
103
+ }
104
+ // In manual mode, update focused tab when selecting
105
+ if (activationMode === 'manual') {
106
+ setFocusedId(id)
107
+ }
108
+ },
109
+ [onSelectionChange, activationMode]
110
+ )
111
+
112
+ const handleKeyDown = useCallback(
113
+ (event: React.KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
114
+ const isHorizontal = orientation === 'horizontal'
115
+ let newIndex = currentIndex
116
+
117
+ // Handle Enter/Space for manual activation
118
+ if (activationMode === 'manual' && (event.key === 'Enter' || event.key === ' ')) {
119
+ event.preventDefault()
120
+ const currentTab = items[currentIndex]
121
+ if (currentTab && !currentTab.disabled) {
122
+ handleSelect(currentTab.id)
123
+ }
124
+ return
125
+ }
126
+
127
+ // Handle arrow keys and Home/End
128
+ if (isNavigationKey(event.key) || isArrowKey(event.key)) {
129
+ event.preventDefault()
130
+
131
+ switch (event.key) {
132
+ case 'Home':
133
+ newIndex = 0
134
+ break
135
+ case 'End':
136
+ newIndex = items.length - 1
137
+ break
138
+ case 'ArrowRight':
139
+ if (isHorizontal) {
140
+ newIndex = (currentIndex + 1) % items.length
141
+ }
142
+ break
143
+ case 'ArrowLeft':
144
+ if (isHorizontal) {
145
+ newIndex = (currentIndex - 1 + items.length) % items.length
146
+ }
147
+ break
148
+ case 'ArrowDown':
149
+ if (!isHorizontal) {
150
+ newIndex = (currentIndex + 1) % items.length
151
+ }
152
+ break
153
+ case 'ArrowUp':
154
+ if (!isHorizontal) {
155
+ newIndex = (currentIndex - 1 + items.length) % items.length
156
+ }
157
+ break
158
+ }
159
+
160
+ // Skip disabled tabs
161
+ while (items[newIndex]?.disabled && newIndex !== currentIndex) {
162
+ if (event.key === 'Home' || event.key === 'ArrowRight' || event.key === 'ArrowDown') {
163
+ newIndex = (newIndex + 1) % items.length
164
+ } else {
165
+ newIndex = (newIndex - 1 + items.length) % items.length
166
+ }
167
+ }
168
+
169
+ const newTab = items[newIndex]
170
+ if (newTab && !newTab.disabled) {
171
+ if (activationMode === 'automatic') {
172
+ // Automatic: move focus and activate
173
+ handleSelect(newTab.id)
174
+ tabRefs.current.get(newTab.id)?.focus()
175
+ } else {
176
+ // Manual: move focus only
177
+ setFocusedId(newTab.id)
178
+ tabRefs.current.get(newTab.id)?.focus()
179
+ }
180
+ }
181
+ }
182
+ },
183
+ [items, orientation, activationMode, handleSelect]
184
+ )
185
+
186
+ const selectedTab = items.find((item) => item.id === selectedId)
187
+
188
+ return (
189
+ <div className={`tabs tabs--${orientation}`}>
190
+ <div
191
+ className="tabs-list"
192
+ role="tablist"
193
+ aria-orientation={orientation}
194
+ aria-label={ariaLabel}
195
+ aria-labelledby={ariaLabelledBy}
196
+ >
197
+ {items.map((item, index) => {
198
+ const isSelected = item.id === selectedId
199
+ const isFocused = item.id === effectiveFocusedId
200
+ // In manual mode, focused tab should be focusable even if not selected
201
+ // In automatic mode, only selected tab is focusable
202
+ const tabIndex = activationMode === 'manual'
203
+ ? (isFocused ? 0 : -1)
204
+ : (isSelected ? 0 : -1)
205
+
206
+ return (
207
+ <button
208
+ key={item.id}
209
+ ref={(el) => {
210
+ if (el) {
211
+ tabRefs.current.set(item.id, el)
212
+ } else {
213
+ tabRefs.current.delete(item.id)
214
+ }
215
+ }}
216
+ id={`tab-${item.id}`}
217
+ role="tab"
218
+ aria-controls={`tabpanel-${item.id}`}
219
+ aria-selected={isSelected}
220
+ tabIndex={tabIndex}
221
+ disabled={item.disabled}
222
+ className={`tabs-tab ${isSelected ? 'tabs-tab--selected' : ''} ${item.disabled ? 'tabs-tab--disabled' : ''}`}
223
+ onClick={() => !item.disabled && handleSelect(item.id)}
224
+ onKeyDown={(e) => handleKeyDown(e, index)}
225
+ onFocus={() => setFocusedId(item.id)}
226
+ {...getCurrentAttributes(isSelected ? 'page' : undefined)}
227
+ >
228
+ {item.label}
229
+ </button>
230
+ )
231
+ })}
232
+ </div>
233
+ {selectedTab && (
234
+ <div
235
+ id={`tabpanel-${selectedTab.id}`}
236
+ role="tabpanel"
237
+ aria-labelledby={`tab-${selectedTab.id}`}
238
+ className="tabs-panel"
239
+ >
240
+ {selectedTab.content}
241
+ </div>
242
+ )}
243
+ </div>
244
+ )
245
+ }
246
+
247
+ Tabs.displayName = 'Tabs'
248
+
@@ -0,0 +1,3 @@
1
+ export { Tabs } from './Tabs'
2
+ export type { TabsProps, TabItem } from './Tabs'
3
+
@@ -0,0 +1,153 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { ToastProvider, useToast } from './ToastProvider'
3
+ import { Button } from '../Button/Button'
4
+
5
+ /**
6
+ * # Toast Component
7
+ *
8
+ * An accessible toast notification system with ARIA live regions for screen reader
9
+ * announcements. Provides non-intrusive feedback for user actions.
10
+ *
11
+ * ## Usage
12
+ *
13
+ * ```tsx
14
+ * import { ToastProvider, useToast } from '@a11ypros/a11y-ui-components'
15
+ *
16
+ * function MyComponent() {
17
+ * const { addToast } = useToast()
18
+ *
19
+ * const handleSave = async () => {
20
+ * await saveData()
21
+ * addToast({
22
+ * message: 'Data saved successfully',
23
+ * type: 'success'
24
+ * })
25
+ * }
26
+ *
27
+ * return <Button onClick={handleSave}>Save</Button>
28
+ * }
29
+ *
30
+ * // Wrap your app with ToastProvider
31
+ * function App() {
32
+ * return (
33
+ * <ToastProvider>
34
+ * <MyComponent />
35
+ * </ToastProvider>
36
+ * )
37
+ * }
38
+ * ```
39
+ *
40
+ * ## Toast Types
41
+ *
42
+ * - **info**: Informational messages (blue)
43
+ * - **success**: Success confirmations (green)
44
+ * - **warning**: Warning messages (yellow/orange)
45
+ * - **error**: Error messages (red)
46
+ *
47
+ * ## Features
48
+ *
49
+ * - **ARIA live regions**: Automatically announces to screen readers
50
+ * - **Auto-dismiss**: Toasts automatically disappear after a timeout
51
+ * - **Keyboard dismissible**: ESC key closes all toasts
52
+ * - **Multiple toasts**: Can display multiple toasts simultaneously
53
+ * - **Accessible close button**: Each toast has a close button with proper ARIA labels
54
+ *
55
+ * ## Accessibility
56
+ *
57
+ * ### WCAG 2.1/2.2 Compliance
58
+ *
59
+ * - **4.1.3 Status Messages**: ARIA live region announcements for screen readers
60
+ * - **2.1.1 Keyboard**: ESC key support to dismiss toasts
61
+ * - **4.1.2 Name, Role, Value**: Proper ARIA attributes and semantic HTML
62
+ * - **2.4.7 Focus Visible**: Clear focus indicators on close buttons
63
+ *
64
+ * ### Keyboard Interactions
65
+ *
66
+ * | Key | Action |
67
+ * |-----|--------|
68
+ * | **ESC** | Closes all visible toasts |
69
+ * | **Tab** | Moves focus to toast close button |
70
+ * | **Enter/Space** | Activates close button |
71
+ *
72
+ * ### Screen Reader Support
73
+ *
74
+ * - Toast messages are announced via ARIA live regions
75
+ * - Live region politeness: "polite" for info/success, "assertive" for warnings/errors
76
+ * - Close button has accessible label ("Close notification")
77
+ * - Toast type is included in announcement when relevant
78
+ *
79
+ * ### Focus Management
80
+ *
81
+ * - Focus moves to close button when toast appears (optional, can be configured)
82
+ * - Focus returns to previous element when toast closes
83
+ * - Toasts don't trap focus (unlike modals)
84
+ *
85
+ * ## Best Practices
86
+ *
87
+ * 1. **Use appropriate types**: Match toast type to message severity
88
+ * 2. **Keep messages concise**: Short, clear messages are more effective
89
+ * 3. **Don't overuse**: Too many toasts can be overwhelming
90
+ * 4. **Provide context**: Include enough information for users to understand the message
91
+ * 5. **Handle errors gracefully**: Use error toasts for user-facing errors, not technical details
92
+ *
93
+ * ## Common Pitfalls
94
+ *
95
+ * - Using toasts for critical information (use modals for important confirmations)
96
+ * - Too many toasts at once (can overwhelm users)
97
+ * - Vague messages (be specific about what happened)
98
+ * - Missing ToastProvider wrapper (toasts won't work without it)
99
+ * - Using wrong toast type (confuses users about message severity)
100
+ *
101
+ * @component
102
+ * @example
103
+ * ```tsx
104
+ * const { addToast } = useToast()
105
+ *
106
+ * addToast({
107
+ * message: 'Settings saved',
108
+ * type: 'success'
109
+ * })
110
+ * ```
111
+ */
112
+ const meta: Meta<typeof ToastProvider> = {
113
+ title: 'Components/Toast',
114
+ component: ToastProvider,
115
+ tags: ['autodocs'],
116
+ }
117
+
118
+ export default meta
119
+ type Story = StoryObj<typeof ToastProvider>
120
+
121
+ const ToastDemo = () => {
122
+ const { addToast } = useToast()
123
+
124
+ return (
125
+ <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
126
+ <Button onClick={() => addToast({ message: 'Info message', type: 'info' })}>
127
+ Show Info
128
+ </Button>
129
+ <Button onClick={() => addToast({ message: 'Success!', type: 'success' })}>
130
+ Show Success
131
+ </Button>
132
+ <Button onClick={() => addToast({ message: 'Warning message', type: 'warning' })}>
133
+ Show Warning
134
+ </Button>
135
+ <Button onClick={() => addToast({ message: 'Error occurred', type: 'error' })}>
136
+ Show Error
137
+ </Button>
138
+ </div>
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Toast notification system with all four types: info, success, warning, and error.
144
+ * Each toast type has distinct styling and appropriate ARIA live region politeness.
145
+ */
146
+ export const Default: Story = {
147
+ render: () => (
148
+ <ToastProvider>
149
+ <ToastDemo />
150
+ </ToastProvider>
151
+ ),
152
+ }
153
+
@@ -0,0 +1,175 @@
1
+ 'use client'
2
+
3
+ import React, { useEffect, useState } from 'react'
4
+ import { useAriaLive } from '../../hooks/useAriaLive'
5
+ import { isEscapeKey } from '../../utils/keyboard'
6
+ import { Button } from '../Button/Button'
7
+ import './Toast.css'
8
+
9
+ export interface ToastProps {
10
+ /**
11
+ * Unique ID for the toast
12
+ */
13
+ id: string
14
+
15
+ /**
16
+ * Toast message
17
+ */
18
+ message: string
19
+
20
+ /**
21
+ * Toast type
22
+ */
23
+ type?: 'info' | 'success' | 'warning' | 'error'
24
+
25
+ /**
26
+ * Whether toast can be dismissed
27
+ */
28
+ dismissible?: boolean
29
+
30
+ /**
31
+ * Auto-dismiss duration in milliseconds (0 = no auto-dismiss). For WCAG compliance, this should be 6 seconds.
32
+ */
33
+ duration?: number
34
+
35
+ /**
36
+ * Callback when toast is dismissed
37
+ */
38
+ onDismiss: (id: string) => void
39
+
40
+ /**
41
+ * Pause auto-dismiss on hover
42
+ */
43
+ pauseOnHover?: boolean
44
+ }
45
+
46
+ /**
47
+ * Accessible Toast component
48
+ *
49
+ * WCAG Compliance:
50
+ * - 4.1.3 Status Messages: ARIA live region announcements
51
+ * - 2.1.1 Keyboard: ESC key support, Tab navigation support
52
+ * - 2.4.3 Focus Order: Consistent focus order - toasts always appear in same position
53
+ * - 4.1.2 Name, Role, Value: Proper ARIA attributes
54
+ *
55
+ * Focus Order:
56
+ * - Toasts are focusable with tabIndex={0} (not positive tabindex)
57
+ * - Toast container is always rendered in the same DOM position (via portal to body)
58
+ * - Toasts appear in consistent order (order added) for predictable tab navigation
59
+ * - Container itself is not focusable, only individual toasts are focusable
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * <Toast
64
+ * id="toast-1"
65
+ * message="Successfully saved!"
66
+ * type="success"
67
+ * onDismiss={handleDismiss}
68
+ * />
69
+ * ```
70
+ */
71
+ export const Toast: React.FC<ToastProps> = ({
72
+ id,
73
+ message,
74
+ type = 'info',
75
+ dismissible = true,
76
+ duration = 6000,
77
+ onDismiss,
78
+ pauseOnHover = true,
79
+ }) => {
80
+ const [isPaused, setIsPaused] = useState(false)
81
+ const timeoutRef = React.useRef<NodeJS.Timeout | null>(null)
82
+
83
+ // Announce toast via ARIA live region
84
+ useAriaLive(message, type === 'error' ? 'assertive' : 'polite')
85
+
86
+ // Auto-dismiss
87
+ useEffect(() => {
88
+ if (duration === 0 || isPaused) {
89
+ if (timeoutRef.current) {
90
+ clearTimeout(timeoutRef.current)
91
+ timeoutRef.current = null
92
+ }
93
+ return
94
+ }
95
+
96
+ timeoutRef.current = setTimeout(() => {
97
+ onDismiss(id)
98
+ }, duration)
99
+
100
+ return () => {
101
+ if (timeoutRef.current) {
102
+ clearTimeout(timeoutRef.current)
103
+ }
104
+ }
105
+ }, [duration, id, onDismiss, isPaused])
106
+
107
+ // Handle ESC key
108
+ useEffect(() => {
109
+ if (!dismissible) return
110
+
111
+ const handleKeyDown = (event: KeyboardEvent) => {
112
+ if (isEscapeKey(event.key)) {
113
+ onDismiss(id)
114
+ }
115
+ }
116
+
117
+ document.addEventListener('keydown', handleKeyDown)
118
+ return () => {
119
+ document.removeEventListener('keydown', handleKeyDown)
120
+ }
121
+ }, [dismissible, id, onDismiss])
122
+
123
+ const handleMouseEnter = () => {
124
+ if (pauseOnHover) {
125
+ setIsPaused(true)
126
+ }
127
+ }
128
+
129
+ const handleMouseLeave = () => {
130
+ if (pauseOnHover) {
131
+ setIsPaused(false)
132
+ }
133
+ }
134
+
135
+ const handleDismiss = () => {
136
+ onDismiss(id)
137
+ }
138
+
139
+ const classes = [
140
+ 'toast',
141
+ `toast--${type}`,
142
+ ]
143
+ .filter(Boolean)
144
+ .join(' ')
145
+
146
+ return (
147
+ <div
148
+ className={classes}
149
+ role="alert"
150
+ aria-live={type === 'error' ? 'assertive' : 'polite'}
151
+ aria-atomic="true"
152
+ tabIndex={0}
153
+ onMouseEnter={handleMouseEnter}
154
+ onMouseLeave={handleMouseLeave}
155
+ >
156
+ <div className="toast-content">
157
+ <span className="toast-message">{message}</span>
158
+ </div>
159
+ {dismissible && (
160
+ <Button
161
+ variant="ghost"
162
+ size="sm"
163
+ onClick={handleDismiss}
164
+ aria-label="Dismiss notification"
165
+ className="toast-dismiss"
166
+ >
167
+ ×
168
+ </Button>
169
+ )}
170
+ </div>
171
+ )
172
+ }
173
+
174
+ Toast.displayName = 'Toast'
175
+
@@ -0,0 +1,73 @@
1
+ 'use client'
2
+
3
+ import React, { createContext, useContext, useState, useCallback } from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import { Toast, ToastProps } from './Toast'
6
+ import './ToastProvider.css'
7
+
8
+ export interface ToastItem extends Omit<ToastProps, 'onDismiss'> {
9
+ id: string
10
+ }
11
+
12
+ interface ToastContextValue {
13
+ addToast: (toast: Omit<ToastItem, 'id'>) => void
14
+ removeToast: (id: string) => void
15
+ }
16
+
17
+ const ToastContext = createContext<ToastContextValue | undefined>(undefined)
18
+
19
+ export const useToast = () => {
20
+ const context = useContext(ToastContext)
21
+ if (!context) {
22
+ throw new Error('useToast must be used within ToastProvider')
23
+ }
24
+ return context
25
+ }
26
+
27
+ export interface ToastProviderProps {
28
+ children: React.ReactNode
29
+ position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center'
30
+ }
31
+
32
+ /**
33
+ * Toast Provider component
34
+ * Manages toast stack and positioning
35
+ */
36
+ export const ToastProvider: React.FC<ToastProviderProps> = ({
37
+ children,
38
+ position = 'top-right',
39
+ }) => {
40
+ const [toasts, setToasts] = useState<ToastItem[]>([])
41
+
42
+ const addToast = useCallback((toast: Omit<ToastItem, 'id'>) => {
43
+ const id = `toast-${Date.now()}-${Math.random()}`
44
+ setToasts((prev) => [...prev, { ...toast, id }])
45
+ }, [])
46
+
47
+ const removeToast = useCallback((id: string) => {
48
+ setToasts((prev) => prev.filter((toast) => toast.id !== id))
49
+ }, [])
50
+
51
+ // Always render container to maintain consistent DOM position for focus order
52
+ // Toasts appear in order they were added (newest last), maintaining consistent tab order
53
+ const toastContainer = (
54
+ <div
55
+ className={`toast-container toast-container--${position}`}
56
+ role="region"
57
+ aria-label="Notifications"
58
+ >
59
+ {toasts.map((toast) => (
60
+ <Toast key={toast.id} {...toast} onDismiss={removeToast} />
61
+ ))}
62
+ </div>
63
+ )
64
+
65
+ return (
66
+ <ToastContext.Provider value={{ addToast, removeToast }}>
67
+ {children}
68
+ {typeof document !== 'undefined' &&
69
+ createPortal(toastContainer, document.body)}
70
+ </ToastContext.Provider>
71
+ )
72
+ }
73
+
@@ -0,0 +1,5 @@
1
+ export { Toast } from './Toast'
2
+ export { ToastProvider, useToast } from './ToastProvider'
3
+ export type { ToastProps } from './Toast'
4
+ export type { ToastItem, ToastProviderProps } from './ToastProvider'
5
+
@@ -0,0 +1,51 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ /**
6
+ * Hook to manage ARIA live regions for screen reader announcements
7
+ *
8
+ * @param message - Message to announce
9
+ * @param priority - 'polite' (default) or 'assertive'
10
+ * @param clearOnUnmount - Whether to clear the message on unmount
11
+ */
12
+ export function useAriaLive(
13
+ message: string | undefined,
14
+ priority: 'polite' | 'assertive' = 'polite',
15
+ clearOnUnmount: boolean = true
16
+ ): void {
17
+ const liveRegionRef = useRef<HTMLDivElement | null>(null)
18
+
19
+ useEffect(() => {
20
+ // Create or get the live region element
21
+ let liveRegion = document.getElementById(`aria-live-${priority}`) as HTMLDivElement
22
+
23
+ if (!liveRegion) {
24
+ liveRegion = document.createElement('div')
25
+ liveRegion.id = `aria-live-${priority}`
26
+ liveRegion.setAttribute('role', 'status')
27
+ liveRegion.setAttribute('aria-live', priority)
28
+ liveRegion.setAttribute('aria-atomic', 'true')
29
+ liveRegion.style.position = 'absolute'
30
+ liveRegion.style.left = '-10000px'
31
+ liveRegion.style.width = '1px'
32
+ liveRegion.style.height = '1px'
33
+ liveRegion.style.overflow = 'hidden'
34
+ document.body.appendChild(liveRegion)
35
+ }
36
+
37
+ liveRegionRef.current = liveRegion
38
+
39
+ // Update the message
40
+ if (message) {
41
+ liveRegion.textContent = message
42
+ }
43
+
44
+ return () => {
45
+ if (clearOnUnmount && liveRegionRef.current) {
46
+ liveRegionRef.current.textContent = ''
47
+ }
48
+ }
49
+ }, [message, priority, clearOnUnmount])
50
+ }
51
+