@a11ypros/a11y-ui-components 1.0.1 → 1.0.2
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/README.md +182 -157
- package/dist/components/Button/Button.d.ts +37 -0
- package/dist/components/Button/Button.d.ts.map +1 -0
- package/dist/components/Button/Button.js +52 -0
- package/dist/components/Button/index.d.ts +3 -0
- package/dist/components/Button/index.d.ts.map +1 -0
- package/dist/components/Button/index.js +1 -0
- package/dist/components/DataTable/DataTable.d.ts +71 -0
- package/dist/components/DataTable/DataTable.d.ts.map +1 -0
- package/dist/components/DataTable/DataTable.js +122 -0
- package/dist/components/DataTable/index.d.ts +3 -0
- package/dist/components/DataTable/index.d.ts.map +1 -0
- package/dist/components/DataTable/index.js +1 -0
- package/dist/components/Form/Checkbox.d.ts +36 -0
- package/dist/components/Form/Checkbox.d.ts.map +1 -0
- package/dist/components/Form/Checkbox.js +39 -0
- package/dist/components/Form/Fieldset.d.ts +33 -0
- package/dist/components/Form/Fieldset.d.ts.map +1 -0
- package/dist/components/Form/Fieldset.js +34 -0
- package/dist/components/Form/Input.d.ts +37 -0
- package/dist/components/Form/Input.d.ts.map +1 -0
- package/dist/components/Form/Input.js +41 -0
- package/dist/components/Form/Label.d.ts +30 -0
- package/dist/components/Form/Label.d.ts.map +1 -0
- package/dist/components/Form/Label.js +30 -0
- package/dist/components/Form/Radio.d.ts +53 -0
- package/dist/components/Form/Radio.d.ts.map +1 -0
- package/dist/components/Form/Radio.js +39 -0
- package/dist/components/Form/Select.d.ts +51 -0
- package/dist/components/Form/Select.d.ts.map +1 -0
- package/dist/components/Form/Select.js +49 -0
- package/dist/components/Form/Textarea.d.ts +44 -0
- package/dist/components/Form/Textarea.d.ts.map +1 -0
- package/dist/components/Form/Textarea.js +43 -0
- package/dist/components/Form/index.d.ts +8 -0
- package/dist/components/Form/index.d.ts.map +1 -0
- package/dist/components/Form/index.js +7 -0
- package/dist/components/Link/Link.d.ts +34 -0
- package/dist/components/Link/Link.d.ts.map +1 -0
- package/dist/components/Link/Link.js +48 -0
- package/dist/components/Link/index.d.ts +3 -0
- package/dist/components/Link/index.d.ts.map +1 -0
- package/dist/components/Link/index.js +1 -0
- package/dist/components/Modal/Modal.d.ts +64 -0
- package/dist/components/Modal/Modal.d.ts.map +1 -0
- package/dist/components/Modal/Modal.js +108 -0
- package/dist/components/Modal/index.d.ts +3 -0
- package/dist/components/Modal/index.d.ts.map +1 -0
- package/dist/components/Modal/index.js +1 -0
- package/dist/components/Tabs/Tabs.d.ts +63 -0
- package/dist/components/Tabs/Tabs.d.ts.map +1 -0
- package/dist/components/Tabs/Tabs.js +134 -0
- package/dist/components/Tabs/index.d.ts +3 -0
- package/dist/components/Tabs/index.d.ts.map +1 -0
- package/dist/components/Tabs/index.js +1 -0
- package/dist/components/Toast/Toast.d.ts +59 -0
- package/dist/components/Toast/Toast.d.ts.map +1 -0
- package/dist/components/Toast/Toast.js +91 -0
- package/dist/components/Toast/ToastProvider.d.ts +22 -0
- package/dist/components/Toast/ToastProvider.d.ts.map +1 -0
- package/dist/components/Toast/ToastProvider.js +33 -0
- package/dist/components/Toast/index.d.ts +5 -0
- package/dist/components/Toast/index.d.ts.map +1 -0
- package/dist/components/Toast/index.js +2 -0
- package/dist/hooks/useAriaLive.d.ts +9 -0
- package/dist/hooks/useAriaLive.d.ts.map +1 -0
- package/dist/hooks/useAriaLive.js +39 -0
- package/dist/hooks/useFocusReturn.d.ts +9 -0
- package/dist/hooks/useFocusReturn.d.ts.map +1 -0
- package/dist/hooks/useFocusReturn.js +33 -0
- package/dist/hooks/useFocusTrap.d.ts +9 -0
- package/dist/hooks/useFocusTrap.d.ts.map +1 -0
- package/dist/hooks/useFocusTrap.js +68 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/{packages/design-system/src/index.ts → dist/index.js} +0 -4
- package/dist/styles/index.d.ts +3 -0
- package/dist/styles/index.d.ts.map +1 -0
- package/dist/styles/index.js +1 -0
- package/dist/tokens/breakpoints.d.ts +25 -0
- package/dist/tokens/breakpoints.d.ts.map +1 -0
- package/dist/tokens/breakpoints.js +23 -0
- package/dist/tokens/colors.d.ts +81 -0
- package/dist/tokens/colors.d.ts.map +1 -0
- package/dist/tokens/colors.js +86 -0
- package/dist/tokens/index.d.ts +6 -0
- package/dist/tokens/index.d.ts.map +1 -0
- package/dist/tokens/index.js +5 -0
- package/dist/tokens/motion.d.ts +30 -0
- package/dist/tokens/motion.d.ts.map +1 -0
- package/dist/tokens/motion.js +34 -0
- package/dist/tokens/spacing.d.ts +22 -0
- package/dist/tokens/spacing.d.ts.map +1 -0
- package/dist/tokens/spacing.js +20 -0
- package/dist/tokens/theme.d.ts +159 -0
- package/dist/tokens/theme.d.ts.map +1 -0
- package/dist/tokens/theme.js +15 -0
- package/dist/tokens/typography.d.ts +45 -0
- package/dist/tokens/typography.d.ts.map +1 -0
- package/dist/tokens/typography.js +56 -0
- package/dist/utils/aria.d.ts +60 -0
- package/dist/utils/aria.d.ts.map +1 -0
- package/dist/utils/aria.js +86 -0
- package/dist/utils/focus.d.ts +30 -0
- package/dist/utils/focus.d.ts.map +1 -0
- package/dist/utils/focus.js +80 -0
- package/dist/utils/index.d.ts +4 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/keyboard.d.ts +38 -0
- package/dist/utils/keyboard.d.ts.map +1 -0
- package/dist/utils/keyboard.js +59 -0
- package/package.json +58 -31
- package/.storybook/custom.css +0 -69
- package/.storybook/main.ts +0 -46
- package/.storybook/manager.ts +0 -26
- package/.storybook/package.json +0 -6
- package/.storybook/preview.tsx +0 -31
- package/.storybook/public/logo.png +0 -0
- package/.storybook/vite.config.ts +0 -24
- package/.storybook/welcome.mdx +0 -97
- package/DEPLOYMENT.md +0 -154
- package/apps/web/app/(docs)/audit/audit.css +0 -269
- package/apps/web/app/(docs)/audit/page.tsx +0 -271
- package/apps/web/app/(docs)/components/button/page.tsx +0 -49
- package/apps/web/app/(docs)/components/form/page.tsx +0 -92
- package/apps/web/app/(docs)/components/link/page.tsx +0 -31
- package/apps/web/app/(docs)/components/modal/page.tsx +0 -41
- package/apps/web/app/(docs)/components/page.tsx +0 -37
- package/apps/web/app/(docs)/components/table/page.tsx +0 -54
- package/apps/web/app/(docs)/components/tabs/page.tsx +0 -61
- package/apps/web/app/(docs)/components/toast/page.tsx +0 -51
- package/apps/web/app/api/audit/route.ts +0 -128
- package/apps/web/app/favicon.ico +0 -0
- package/apps/web/app/layout.tsx +0 -20
- package/apps/web/app/page.tsx +0 -17
- package/apps/web/app/styles/globals.css +0 -5
- package/apps/web/next-env.d.ts +0 -5
- package/apps/web/next.config.js +0 -21
- package/apps/web/package.json +0 -28
- package/apps/web/public/_headers +0 -17
- package/apps/web/public/_redirects +0 -31
- package/apps/web/public/logo.png +0 -0
- package/apps/web/tsconfig.json +0 -29
- package/netlify/functions/audit.ts +0 -163
- package/netlify.toml +0 -37
- package/packages/design-system/README.md +0 -252
- package/packages/design-system/package.json +0 -68
- package/packages/design-system/scripts/copy-css.js +0 -63
- package/packages/design-system/src/components/Button/Button.stories.tsx +0 -228
- package/packages/design-system/src/components/Button/Button.tsx +0 -137
- package/packages/design-system/src/components/Button/index.ts +0 -3
- package/packages/design-system/src/components/DataTable/DataTable.stories.tsx +0 -211
- package/packages/design-system/src/components/DataTable/DataTable.tsx +0 -293
- package/packages/design-system/src/components/DataTable/index.ts +0 -3
- package/packages/design-system/src/components/Form/Checkbox.stories.tsx +0 -252
- package/packages/design-system/src/components/Form/Checkbox.tsx +0 -114
- package/packages/design-system/src/components/Form/Fieldset.stories.tsx +0 -210
- package/packages/design-system/src/components/Form/Fieldset.tsx +0 -71
- package/packages/design-system/src/components/Form/Input.stories.tsx +0 -164
- package/packages/design-system/src/components/Form/Input.tsx +0 -113
- package/packages/design-system/src/components/Form/Label.tsx +0 -56
- package/packages/design-system/src/components/Form/Radio.stories.tsx +0 -265
- package/packages/design-system/src/components/Form/Radio.tsx +0 -147
- package/packages/design-system/src/components/Form/Select.stories.tsx +0 -295
- package/packages/design-system/src/components/Form/Select.tsx +0 -160
- package/packages/design-system/src/components/Form/Textarea.stories.tsx +0 -253
- package/packages/design-system/src/components/Form/Textarea.tsx +0 -145
- package/packages/design-system/src/components/Form/index.ts +0 -8
- package/packages/design-system/src/components/Link/Link.stories.tsx +0 -128
- package/packages/design-system/src/components/Link/Link.tsx +0 -117
- package/packages/design-system/src/components/Link/index.ts +0 -3
- package/packages/design-system/src/components/Modal/Modal.stories.tsx +0 -165
- package/packages/design-system/src/components/Modal/Modal.tsx +0 -202
- package/packages/design-system/src/components/Modal/index.ts +0 -3
- package/packages/design-system/src/components/Tabs/Tabs.stories.tsx +0 -213
- package/packages/design-system/src/components/Tabs/Tabs.tsx +0 -248
- package/packages/design-system/src/components/Tabs/index.ts +0 -3
- package/packages/design-system/src/components/Toast/Toast.stories.tsx +0 -153
- package/packages/design-system/src/components/Toast/Toast.tsx +0 -175
- package/packages/design-system/src/components/Toast/ToastProvider.tsx +0 -73
- package/packages/design-system/src/components/Toast/index.ts +0 -5
- package/packages/design-system/src/hooks/useAriaLive.ts +0 -51
- package/packages/design-system/src/hooks/useFocusReturn.ts +0 -40
- package/packages/design-system/src/hooks/useFocusTrap.ts +0 -82
- package/packages/design-system/src/styles/index.ts +0 -3
- package/packages/design-system/src/tokens/breakpoints.ts +0 -28
- package/packages/design-system/src/tokens/colors.ts +0 -98
- package/packages/design-system/src/tokens/index.ts +0 -6
- package/packages/design-system/src/tokens/motion.ts +0 -41
- package/packages/design-system/src/tokens/spacing.ts +0 -24
- package/packages/design-system/src/tokens/theme.ts +0 -19
- package/packages/design-system/src/tokens/typography.ts +0 -64
- package/packages/design-system/src/utils/aria.ts +0 -108
- package/packages/design-system/src/utils/focus.ts +0 -87
- package/packages/design-system/src/utils/index.ts +0 -4
- package/packages/design-system/src/utils/keyboard.ts +0 -77
- package/packages/design-system/tsconfig.json +0 -17
- package/public/logo.png +0 -0
- package/scripts/fix-storybook-paths.js +0 -53
- package/tsconfig.json +0 -20
- /package/{packages/design-system/src → dist}/components/Button/Button.css +0 -0
- /package/{packages/design-system/src → dist}/components/DataTable/DataTable.css +0 -0
- /package/{packages/design-system/src → dist}/components/Form/Checkbox.css +0 -0
- /package/{packages/design-system/src → dist}/components/Form/Fieldset.css +0 -0
- /package/{packages/design-system/src → dist}/components/Form/Input.css +0 -0
- /package/{packages/design-system/src → dist}/components/Form/Label.css +0 -0
- /package/{packages/design-system/src → dist}/components/Form/Radio.css +0 -0
- /package/{packages/design-system/src → dist}/components/Form/Select.css +0 -0
- /package/{packages/design-system/src → dist}/components/Form/Textarea.css +0 -0
- /package/{packages/design-system/src → dist}/components/Link/Link.css +0 -0
- /package/{packages/design-system/src → dist}/components/Modal/Modal.css +0 -0
- /package/{packages/design-system/src → dist}/components/Tabs/Tabs.css +0 -0
- /package/{packages/design-system/src → dist}/components/Toast/Toast.css +0 -0
- /package/{packages/design-system/src → dist}/components/Toast/ToastProvider.css +0 -0
- /package/{packages/design-system/src → dist}/styles/components.css +0 -0
- /package/{packages/design-system/src → dist}/styles/global.css +0 -0
|
@@ -1,248 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,153 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,175 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,73 +0,0 @@
|
|
|
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
|
-
|
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
|