@a11ypros/a11y-ui-components 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/custom.css +69 -0
- package/.storybook/main.ts +46 -0
- package/.storybook/manager.ts +26 -0
- package/.storybook/package.json +6 -0
- package/.storybook/preview.tsx +31 -0
- package/.storybook/public/logo.png +0 -0
- package/.storybook/vite.config.ts +24 -0
- package/.storybook/welcome.mdx +97 -0
- package/DEPLOYMENT.md +154 -0
- package/README.md +227 -0
- package/apps/web/app/(docs)/audit/audit.css +269 -0
- package/apps/web/app/(docs)/audit/page.tsx +271 -0
- package/apps/web/app/(docs)/components/button/page.tsx +49 -0
- package/apps/web/app/(docs)/components/form/page.tsx +92 -0
- package/apps/web/app/(docs)/components/link/page.tsx +31 -0
- package/apps/web/app/(docs)/components/modal/page.tsx +41 -0
- package/apps/web/app/(docs)/components/page.tsx +37 -0
- package/apps/web/app/(docs)/components/table/page.tsx +54 -0
- package/apps/web/app/(docs)/components/tabs/page.tsx +61 -0
- package/apps/web/app/(docs)/components/toast/page.tsx +51 -0
- package/apps/web/app/api/audit/route.ts +128 -0
- package/apps/web/app/favicon.ico +0 -0
- package/apps/web/app/layout.tsx +20 -0
- package/apps/web/app/page.tsx +17 -0
- package/apps/web/app/styles/globals.css +5 -0
- package/apps/web/next-env.d.ts +5 -0
- package/apps/web/next.config.js +21 -0
- package/apps/web/package.json +28 -0
- package/apps/web/public/_headers +17 -0
- package/apps/web/public/_redirects +31 -0
- package/apps/web/public/logo.png +0 -0
- package/apps/web/tsconfig.json +29 -0
- package/netlify/functions/audit.ts +163 -0
- package/netlify.toml +37 -0
- package/package.json +30 -58
- package/packages/design-system/README.md +252 -0
- package/packages/design-system/package.json +68 -0
- package/packages/design-system/scripts/copy-css.js +63 -0
- package/packages/design-system/src/components/Button/Button.stories.tsx +228 -0
- package/packages/design-system/src/components/Button/Button.tsx +137 -0
- package/packages/design-system/src/components/Button/index.ts +3 -0
- package/packages/design-system/src/components/DataTable/DataTable.stories.tsx +211 -0
- package/packages/design-system/src/components/DataTable/DataTable.tsx +293 -0
- package/packages/design-system/src/components/DataTable/index.ts +3 -0
- package/packages/design-system/src/components/Form/Checkbox.stories.tsx +252 -0
- package/packages/design-system/src/components/Form/Checkbox.tsx +114 -0
- package/packages/design-system/src/components/Form/Fieldset.stories.tsx +210 -0
- package/packages/design-system/src/components/Form/Fieldset.tsx +71 -0
- package/packages/design-system/src/components/Form/Input.stories.tsx +164 -0
- package/packages/design-system/src/components/Form/Input.tsx +113 -0
- package/packages/design-system/src/components/Form/Label.tsx +56 -0
- package/packages/design-system/src/components/Form/Radio.stories.tsx +265 -0
- package/packages/design-system/src/components/Form/Radio.tsx +147 -0
- package/packages/design-system/src/components/Form/Select.stories.tsx +295 -0
- package/packages/design-system/src/components/Form/Select.tsx +160 -0
- package/packages/design-system/src/components/Form/Textarea.stories.tsx +253 -0
- package/packages/design-system/src/components/Form/Textarea.tsx +145 -0
- package/packages/design-system/src/components/Form/index.ts +8 -0
- package/packages/design-system/src/components/Link/Link.stories.tsx +128 -0
- package/packages/design-system/src/components/Link/Link.tsx +117 -0
- package/packages/design-system/src/components/Link/index.ts +3 -0
- package/packages/design-system/src/components/Modal/Modal.stories.tsx +165 -0
- package/packages/design-system/src/components/Modal/Modal.tsx +202 -0
- package/packages/design-system/src/components/Modal/index.ts +3 -0
- package/packages/design-system/src/components/Tabs/Tabs.stories.tsx +213 -0
- package/packages/design-system/src/components/Tabs/Tabs.tsx +248 -0
- package/packages/design-system/src/components/Tabs/index.ts +3 -0
- package/packages/design-system/src/components/Toast/Toast.stories.tsx +153 -0
- package/packages/design-system/src/components/Toast/Toast.tsx +175 -0
- package/packages/design-system/src/components/Toast/ToastProvider.tsx +73 -0
- package/packages/design-system/src/components/Toast/index.ts +5 -0
- package/packages/design-system/src/hooks/useAriaLive.ts +51 -0
- package/packages/design-system/src/hooks/useFocusReturn.ts +40 -0
- package/packages/design-system/src/hooks/useFocusTrap.ts +82 -0
- package/{dist/index.js → packages/design-system/src/index.ts} +4 -0
- package/packages/design-system/src/styles/index.ts +3 -0
- package/packages/design-system/src/tokens/breakpoints.ts +28 -0
- package/packages/design-system/src/tokens/colors.ts +98 -0
- package/packages/design-system/src/tokens/index.ts +6 -0
- package/packages/design-system/src/tokens/motion.ts +41 -0
- package/packages/design-system/src/tokens/spacing.ts +24 -0
- package/packages/design-system/src/tokens/theme.ts +19 -0
- package/packages/design-system/src/tokens/typography.ts +64 -0
- package/packages/design-system/src/utils/aria.ts +108 -0
- package/packages/design-system/src/utils/focus.ts +87 -0
- package/packages/design-system/src/utils/index.ts +4 -0
- package/packages/design-system/src/utils/keyboard.ts +77 -0
- package/packages/design-system/tsconfig.json +17 -0
- package/public/logo.png +0 -0
- package/scripts/fix-storybook-paths.js +53 -0
- package/tsconfig.json +20 -0
- package/dist/components/Button/Button.d.ts +0 -37
- package/dist/components/Button/Button.d.ts.map +0 -1
- package/dist/components/Button/Button.js +0 -52
- package/dist/components/Button/index.d.ts +0 -3
- package/dist/components/Button/index.d.ts.map +0 -1
- package/dist/components/Button/index.js +0 -1
- package/dist/components/DataTable/DataTable.d.ts +0 -71
- package/dist/components/DataTable/DataTable.d.ts.map +0 -1
- package/dist/components/DataTable/DataTable.js +0 -122
- package/dist/components/DataTable/index.d.ts +0 -3
- package/dist/components/DataTable/index.d.ts.map +0 -1
- package/dist/components/DataTable/index.js +0 -1
- package/dist/components/Form/Checkbox.d.ts +0 -36
- package/dist/components/Form/Checkbox.d.ts.map +0 -1
- package/dist/components/Form/Checkbox.js +0 -39
- package/dist/components/Form/Fieldset.d.ts +0 -33
- package/dist/components/Form/Fieldset.d.ts.map +0 -1
- package/dist/components/Form/Fieldset.js +0 -34
- package/dist/components/Form/Input.d.ts +0 -37
- package/dist/components/Form/Input.d.ts.map +0 -1
- package/dist/components/Form/Input.js +0 -41
- package/dist/components/Form/Label.d.ts +0 -30
- package/dist/components/Form/Label.d.ts.map +0 -1
- package/dist/components/Form/Label.js +0 -30
- package/dist/components/Form/Radio.d.ts +0 -53
- package/dist/components/Form/Radio.d.ts.map +0 -1
- package/dist/components/Form/Radio.js +0 -39
- package/dist/components/Form/Select.d.ts +0 -51
- package/dist/components/Form/Select.d.ts.map +0 -1
- package/dist/components/Form/Select.js +0 -49
- package/dist/components/Form/Textarea.d.ts +0 -44
- package/dist/components/Form/Textarea.d.ts.map +0 -1
- package/dist/components/Form/Textarea.js +0 -43
- package/dist/components/Form/index.d.ts +0 -8
- package/dist/components/Form/index.d.ts.map +0 -1
- package/dist/components/Form/index.js +0 -7
- package/dist/components/Link/Link.d.ts +0 -34
- package/dist/components/Link/Link.d.ts.map +0 -1
- package/dist/components/Link/Link.js +0 -48
- package/dist/components/Link/index.d.ts +0 -3
- package/dist/components/Link/index.d.ts.map +0 -1
- package/dist/components/Link/index.js +0 -1
- package/dist/components/Modal/Modal.d.ts +0 -64
- package/dist/components/Modal/Modal.d.ts.map +0 -1
- package/dist/components/Modal/Modal.js +0 -108
- package/dist/components/Modal/index.d.ts +0 -3
- package/dist/components/Modal/index.d.ts.map +0 -1
- package/dist/components/Modal/index.js +0 -1
- package/dist/components/Tabs/Tabs.d.ts +0 -63
- package/dist/components/Tabs/Tabs.d.ts.map +0 -1
- package/dist/components/Tabs/Tabs.js +0 -134
- package/dist/components/Tabs/index.d.ts +0 -3
- package/dist/components/Tabs/index.d.ts.map +0 -1
- package/dist/components/Tabs/index.js +0 -1
- package/dist/components/Toast/Toast.d.ts +0 -59
- package/dist/components/Toast/Toast.d.ts.map +0 -1
- package/dist/components/Toast/Toast.js +0 -91
- package/dist/components/Toast/ToastProvider.d.ts +0 -22
- package/dist/components/Toast/ToastProvider.d.ts.map +0 -1
- package/dist/components/Toast/ToastProvider.js +0 -33
- package/dist/components/Toast/index.d.ts +0 -5
- package/dist/components/Toast/index.d.ts.map +0 -1
- package/dist/components/Toast/index.js +0 -2
- package/dist/hooks/useAriaLive.d.ts +0 -9
- package/dist/hooks/useAriaLive.d.ts.map +0 -1
- package/dist/hooks/useAriaLive.js +0 -39
- package/dist/hooks/useFocusReturn.d.ts +0 -9
- package/dist/hooks/useFocusReturn.d.ts.map +0 -1
- package/dist/hooks/useFocusReturn.js +0 -33
- package/dist/hooks/useFocusTrap.d.ts +0 -9
- package/dist/hooks/useFocusTrap.d.ts.map +0 -1
- package/dist/hooks/useFocusTrap.js +0 -68
- package/dist/index.d.ts +0 -22
- package/dist/index.d.ts.map +0 -1
- package/dist/styles/index.d.ts +0 -3
- package/dist/styles/index.d.ts.map +0 -1
- package/dist/styles/index.js +0 -1
- package/dist/tokens/breakpoints.d.ts +0 -25
- package/dist/tokens/breakpoints.d.ts.map +0 -1
- package/dist/tokens/breakpoints.js +0 -23
- package/dist/tokens/colors.d.ts +0 -81
- package/dist/tokens/colors.d.ts.map +0 -1
- package/dist/tokens/colors.js +0 -86
- package/dist/tokens/index.d.ts +0 -6
- package/dist/tokens/index.d.ts.map +0 -1
- package/dist/tokens/index.js +0 -5
- package/dist/tokens/motion.d.ts +0 -30
- package/dist/tokens/motion.d.ts.map +0 -1
- package/dist/tokens/motion.js +0 -34
- package/dist/tokens/spacing.d.ts +0 -22
- package/dist/tokens/spacing.d.ts.map +0 -1
- package/dist/tokens/spacing.js +0 -20
- package/dist/tokens/theme.d.ts +0 -159
- package/dist/tokens/theme.d.ts.map +0 -1
- package/dist/tokens/theme.js +0 -15
- package/dist/tokens/typography.d.ts +0 -45
- package/dist/tokens/typography.d.ts.map +0 -1
- package/dist/tokens/typography.js +0 -56
- package/dist/utils/aria.d.ts +0 -60
- package/dist/utils/aria.d.ts.map +0 -1
- package/dist/utils/aria.js +0 -86
- package/dist/utils/focus.d.ts +0 -30
- package/dist/utils/focus.d.ts.map +0 -1
- package/dist/utils/focus.js +0 -80
- package/dist/utils/index.d.ts +0 -4
- package/dist/utils/index.d.ts.map +0 -1
- package/dist/utils/index.js +0 -3
- package/dist/utils/keyboard.d.ts +0 -38
- package/dist/utils/keyboard.d.ts.map +0 -1
- package/dist/utils/keyboard.js +0 -59
- /package/{dist → packages/design-system/src}/components/Button/Button.css +0 -0
- /package/{dist → packages/design-system/src}/components/DataTable/DataTable.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Checkbox.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Fieldset.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Input.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Label.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Radio.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Select.css +0 -0
- /package/{dist → packages/design-system/src}/components/Form/Textarea.css +0 -0
- /package/{dist → packages/design-system/src}/components/Link/Link.css +0 -0
- /package/{dist → packages/design-system/src}/components/Modal/Modal.css +0 -0
- /package/{dist → packages/design-system/src}/components/Tabs/Tabs.css +0 -0
- /package/{dist → packages/design-system/src}/components/Toast/Toast.css +0 -0
- /package/{dist → packages/design-system/src}/components/Toast/ToastProvider.css +0 -0
- /package/{dist → packages/design-system/src}/styles/components.css +0 -0
- /package/{dist → packages/design-system/src}/styles/global.css +0 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useState, useRef, useCallback } from 'react'
|
|
4
|
+
import { useAriaLive } from '../../hooks/useAriaLive'
|
|
5
|
+
import { createArrowKeyHandler, isNavigationKey } from '../../utils/keyboard'
|
|
6
|
+
import { Checkbox } from '../Form/Checkbox'
|
|
7
|
+
import './DataTable.css'
|
|
8
|
+
|
|
9
|
+
export interface DataTableColumn<T> {
|
|
10
|
+
key: string
|
|
11
|
+
header: string
|
|
12
|
+
render?: (row: T, index: number) => React.ReactNode
|
|
13
|
+
sortable?: boolean
|
|
14
|
+
width?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DataTableProps<T> {
|
|
18
|
+
/**
|
|
19
|
+
* Data rows
|
|
20
|
+
*/
|
|
21
|
+
data: T[]
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Column definitions
|
|
25
|
+
*/
|
|
26
|
+
columns: DataTableColumn<T>[]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Key function to get unique ID for each row
|
|
30
|
+
*/
|
|
31
|
+
getRowId: (row: T) => string
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether rows are selectable
|
|
35
|
+
*/
|
|
36
|
+
selectable?: boolean
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Selected row IDs
|
|
40
|
+
*/
|
|
41
|
+
selectedRows?: string[]
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Callback when selection changes
|
|
45
|
+
*/
|
|
46
|
+
onSelectionChange?: (selectedIds: string[]) => void
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sort configuration
|
|
50
|
+
*/
|
|
51
|
+
sortConfig?: {
|
|
52
|
+
column: string
|
|
53
|
+
direction: 'asc' | 'desc'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Callback when sort changes
|
|
58
|
+
*/
|
|
59
|
+
onSortChange?: (column: string, direction: 'asc' | 'desc') => void
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Caption for the table (required for accessibility)
|
|
63
|
+
*/
|
|
64
|
+
caption?: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Accessible DataTable component
|
|
69
|
+
*
|
|
70
|
+
* WCAG Compliance:
|
|
71
|
+
* - 1.3.1 Info and Relationships: Semantic table structure
|
|
72
|
+
* - 2.1.1 Keyboard: Arrow keys, Home/End navigation
|
|
73
|
+
* - 4.1.2 Name, Role, Value: Proper ARIA attributes
|
|
74
|
+
* - 4.1.3 Status Messages: Sort announcements
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```tsx
|
|
78
|
+
* <DataTable
|
|
79
|
+
* data={users}
|
|
80
|
+
* columns={columns}
|
|
81
|
+
* getRowId={(user) => user.id}
|
|
82
|
+
* caption="User list"
|
|
83
|
+
* />
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
export function DataTable<T extends Record<string, any>>({
|
|
87
|
+
data,
|
|
88
|
+
columns,
|
|
89
|
+
getRowId,
|
|
90
|
+
selectable = false,
|
|
91
|
+
selectedRows = [],
|
|
92
|
+
onSelectionChange,
|
|
93
|
+
sortConfig,
|
|
94
|
+
onSortChange,
|
|
95
|
+
caption,
|
|
96
|
+
}: DataTableProps<T>) {
|
|
97
|
+
const [focusedRow, setFocusedRow] = useState<string | null>(null)
|
|
98
|
+
const tableRef = useRef<HTMLTableElement>(null)
|
|
99
|
+
const rowRefs = useRef<Map<string, HTMLTableRowElement>>(new Map())
|
|
100
|
+
|
|
101
|
+
// Announce sort changes
|
|
102
|
+
const sortAnnouncement = sortConfig
|
|
103
|
+
? `Sorted by ${columns.find((c) => c.key === sortConfig.column)?.header || sortConfig.column}, ${sortConfig.direction === 'asc' ? 'ascending' : 'descending'}`
|
|
104
|
+
: undefined
|
|
105
|
+
useAriaLive(sortAnnouncement, 'polite')
|
|
106
|
+
|
|
107
|
+
const handleSelectAll = useCallback(() => {
|
|
108
|
+
if (!onSelectionChange) return
|
|
109
|
+
|
|
110
|
+
const allSelected = selectedRows.length === data.length
|
|
111
|
+
if (allSelected) {
|
|
112
|
+
onSelectionChange([])
|
|
113
|
+
} else {
|
|
114
|
+
onSelectionChange(data.map(getRowId))
|
|
115
|
+
}
|
|
116
|
+
}, [data, selectedRows, onSelectionChange, getRowId])
|
|
117
|
+
|
|
118
|
+
const handleSelectRow = useCallback(
|
|
119
|
+
(rowId: string) => {
|
|
120
|
+
if (!onSelectionChange) return
|
|
121
|
+
|
|
122
|
+
const isSelected = selectedRows.includes(rowId)
|
|
123
|
+
if (isSelected) {
|
|
124
|
+
onSelectionChange(selectedRows.filter((id) => id !== rowId))
|
|
125
|
+
} else {
|
|
126
|
+
onSelectionChange([...selectedRows, rowId])
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
[selectedRows, onSelectionChange]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
const handleSort = useCallback(
|
|
133
|
+
(columnKey: string) => {
|
|
134
|
+
if (!onSortChange || !columns.find((c) => c.key === columnKey)?.sortable) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const newDirection =
|
|
139
|
+
sortConfig?.column === columnKey && sortConfig.direction === 'asc'
|
|
140
|
+
? 'desc'
|
|
141
|
+
: 'asc'
|
|
142
|
+
onSortChange(columnKey, newDirection)
|
|
143
|
+
},
|
|
144
|
+
[onSortChange, sortConfig, columns]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const handleKeyDown = useCallback(
|
|
148
|
+
(event: React.KeyboardEvent<HTMLTableRowElement>, rowId: string, index: number) => {
|
|
149
|
+
const row = rowRefs.current.get(rowId)
|
|
150
|
+
if (!row) return
|
|
151
|
+
|
|
152
|
+
if (isNavigationKey(event.key)) {
|
|
153
|
+
event.preventDefault()
|
|
154
|
+
|
|
155
|
+
let newIndex = index
|
|
156
|
+
switch (event.key) {
|
|
157
|
+
case 'ArrowDown':
|
|
158
|
+
newIndex = Math.min(index + 1, data.length - 1)
|
|
159
|
+
break
|
|
160
|
+
case 'ArrowUp':
|
|
161
|
+
newIndex = Math.max(index - 1, 0)
|
|
162
|
+
break
|
|
163
|
+
case 'Home':
|
|
164
|
+
newIndex = 0
|
|
165
|
+
break
|
|
166
|
+
case 'End':
|
|
167
|
+
newIndex = data.length - 1
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const newRowId = getRowId(data[newIndex])
|
|
172
|
+
setFocusedRow(newRowId)
|
|
173
|
+
rowRefs.current.get(newRowId)?.focus()
|
|
174
|
+
} else if (event.key === ' ' && selectable) {
|
|
175
|
+
event.preventDefault()
|
|
176
|
+
handleSelectRow(rowId)
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
[data, selectable, handleSelectRow, getRowId]
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const allSelected = data.length > 0 && selectedRows.length === data.length
|
|
183
|
+
const someSelected = selectedRows.length > 0 && selectedRows.length < data.length
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div className="data-table-wrapper">
|
|
187
|
+
<table
|
|
188
|
+
ref={tableRef}
|
|
189
|
+
className="data-table"
|
|
190
|
+
aria-label={caption}
|
|
191
|
+
>
|
|
192
|
+
{caption && <caption className="data-table-caption">{caption}</caption>}
|
|
193
|
+
<thead>
|
|
194
|
+
<tr>
|
|
195
|
+
{selectable && (
|
|
196
|
+
<th scope="col" className="data-table-header data-table-header--checkbox">
|
|
197
|
+
<Checkbox
|
|
198
|
+
checked={allSelected}
|
|
199
|
+
aria-label="Select all rows"
|
|
200
|
+
onChange={handleSelectAll}
|
|
201
|
+
aria-checked={allSelected ? 'true' : someSelected ? 'mixed' : 'false'}
|
|
202
|
+
/>
|
|
203
|
+
</th>
|
|
204
|
+
)}
|
|
205
|
+
{columns.map((column) => (
|
|
206
|
+
<th
|
|
207
|
+
key={column.key}
|
|
208
|
+
scope="col"
|
|
209
|
+
className={`data-table-header ${column.sortable ? 'data-table-header--sortable' : ''}`}
|
|
210
|
+
style={column.width ? { width: column.width } : undefined}
|
|
211
|
+
aria-sort={
|
|
212
|
+
sortConfig?.column === column.key
|
|
213
|
+
? sortConfig.direction === 'asc'
|
|
214
|
+
? 'ascending'
|
|
215
|
+
: 'descending'
|
|
216
|
+
: 'none'
|
|
217
|
+
}
|
|
218
|
+
>
|
|
219
|
+
{column.sortable ? (
|
|
220
|
+
<button
|
|
221
|
+
type="button"
|
|
222
|
+
className="data-table-sort-button"
|
|
223
|
+
aria-label={
|
|
224
|
+
sortConfig?.column === column.key
|
|
225
|
+
? `${column.header}, sorted ${sortConfig.direction === 'asc' ? 'ascending' : 'descending'}, activate to sort ${sortConfig.direction === 'asc' ? 'descending' : 'ascending'}`
|
|
226
|
+
: `Sort by ${column.header}`
|
|
227
|
+
}
|
|
228
|
+
onClick={() => handleSort(column.key)}
|
|
229
|
+
>
|
|
230
|
+
{column.header}
|
|
231
|
+
{sortConfig?.column === column.key && (
|
|
232
|
+
<span className="data-table-sort-indicator" aria-hidden="true">
|
|
233
|
+
{sortConfig.direction === 'asc' ? ' ↑' : ' ↓'}
|
|
234
|
+
</span>
|
|
235
|
+
)}
|
|
236
|
+
</button>
|
|
237
|
+
) : (
|
|
238
|
+
column.header
|
|
239
|
+
)}
|
|
240
|
+
</th>
|
|
241
|
+
))}
|
|
242
|
+
</tr>
|
|
243
|
+
</thead>
|
|
244
|
+
<tbody>
|
|
245
|
+
{data.map((row, index) => {
|
|
246
|
+
const rowId = getRowId(row)
|
|
247
|
+
const isSelected = selectedRows.includes(rowId)
|
|
248
|
+
const isFocused = focusedRow === rowId
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<tr
|
|
252
|
+
key={rowId}
|
|
253
|
+
ref={(el) => {
|
|
254
|
+
if (el) {
|
|
255
|
+
rowRefs.current.set(rowId, el)
|
|
256
|
+
} else {
|
|
257
|
+
rowRefs.current.delete(rowId)
|
|
258
|
+
}
|
|
259
|
+
}}
|
|
260
|
+
className={`data-table-row ${isSelected ? 'data-table-row--selected' : ''} ${isFocused ? 'data-table-row--focused' : ''}`}
|
|
261
|
+
tabIndex={0}
|
|
262
|
+
aria-selected={selectable ? isSelected : undefined}
|
|
263
|
+
onKeyDown={(e) => handleKeyDown(e, rowId, index)}
|
|
264
|
+
onClick={() => {
|
|
265
|
+
if (selectable) {
|
|
266
|
+
handleSelectRow(rowId)
|
|
267
|
+
}
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
{selectable && (
|
|
271
|
+
<td className="data-table-cell data-table-cell--checkbox">
|
|
272
|
+
<Checkbox
|
|
273
|
+
checked={isSelected}
|
|
274
|
+
aria-label={`Select row ${index + 1}`}
|
|
275
|
+
onChange={() => handleSelectRow(rowId)}
|
|
276
|
+
onClick={(e) => e.stopPropagation()}
|
|
277
|
+
/>
|
|
278
|
+
</td>
|
|
279
|
+
)}
|
|
280
|
+
{columns.map((column) => (
|
|
281
|
+
<td key={column.key} className="data-table-cell">
|
|
282
|
+
{column.render ? column.render(row, index) : row[column.key]}
|
|
283
|
+
</td>
|
|
284
|
+
))}
|
|
285
|
+
</tr>
|
|
286
|
+
)
|
|
287
|
+
})}
|
|
288
|
+
</tbody>
|
|
289
|
+
</table>
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { Checkbox } from './Checkbox'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* # Checkbox Component
|
|
7
|
+
*
|
|
8
|
+
* An accessible checkbox component with proper label association, error handling,
|
|
9
|
+
* and ARIA attributes. Supports both controlled and uncontrolled usage.
|
|
10
|
+
*
|
|
11
|
+
* ## Usage
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* import { Checkbox } from '@a11ypros/a11y-ui-components'
|
|
15
|
+
*
|
|
16
|
+
* function MyComponent() {
|
|
17
|
+
* const [checked, setChecked] = useState(false)
|
|
18
|
+
*
|
|
19
|
+
* return (
|
|
20
|
+
* <Checkbox
|
|
21
|
+
* id="agree"
|
|
22
|
+
* label="I agree to the terms and conditions"
|
|
23
|
+
* checked={checked}
|
|
24
|
+
* onChange={(e) => setChecked(e.target.checked)}
|
|
25
|
+
* />
|
|
26
|
+
* )
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* ## Features
|
|
31
|
+
*
|
|
32
|
+
* - **Label association**: Labels are properly associated with checkboxes using \`htmlFor\` and \`id\`
|
|
33
|
+
* - **Error handling**: Error messages are announced to screen readers via ARIA
|
|
34
|
+
* - **Helper text**: Optional helper text for additional context
|
|
35
|
+
* - **Keyboard accessible**: Full keyboard support with Space key for toggling
|
|
36
|
+
*
|
|
37
|
+
* ## Accessibility
|
|
38
|
+
*
|
|
39
|
+
* ### WCAG 2.1/2.2 Compliance
|
|
40
|
+
*
|
|
41
|
+
* - **1.3.1 Info and Relationships**: Proper label-checkbox association via \`htmlFor\` and \`id\`
|
|
42
|
+
* - **2.5.3 Label in Name**: Label text matches accessible name
|
|
43
|
+
* - **4.1.2 Name, Role, Value**: Proper ARIA attributes including \`aria-required\`, \`aria-invalid\`, \`aria-describedby\`
|
|
44
|
+
*
|
|
45
|
+
* ### Keyboard Interactions
|
|
46
|
+
*
|
|
47
|
+
* | Key | Action |
|
|
48
|
+
* |-----|--------|
|
|
49
|
+
* | **Tab** | Moves focus to the checkbox |
|
|
50
|
+
* | **Shift+Tab** | Moves focus away from the checkbox |
|
|
51
|
+
* | **Space** | Toggles checkbox state |
|
|
52
|
+
* | **Enter** | Toggles checkbox state (in some contexts) |
|
|
53
|
+
*
|
|
54
|
+
* ### Screen Reader Support
|
|
55
|
+
*
|
|
56
|
+
* - Checkbox role and label are announced when focused
|
|
57
|
+
* - Checked state is announced ("checked" or "not checked")
|
|
58
|
+
* - Required state is announced ("required")
|
|
59
|
+
* - Error messages are announced when present
|
|
60
|
+
* - Helper text is announced via \`aria-describedby\`
|
|
61
|
+
*
|
|
62
|
+
* ### Focus Management
|
|
63
|
+
*
|
|
64
|
+
* - Focus indicators use 2px solid outline with 2px offset
|
|
65
|
+
* - Focus styles respect \`prefers-reduced-motion\` media query
|
|
66
|
+
* - Error state has distinct focus styling
|
|
67
|
+
* - Focus visible only on keyboard navigation using \`:focus-visible\`
|
|
68
|
+
*
|
|
69
|
+
* ## Best Practices
|
|
70
|
+
*
|
|
71
|
+
* 1. **Always provide a label**: Required for accessibility and usability
|
|
72
|
+
* 2. **Use for binary choices**: Checkboxes are for independent yes/no choices
|
|
73
|
+
* 3. **Group related checkboxes**: Use Fieldset component to group related options
|
|
74
|
+
* 4. **Provide helpful error messages**: Be specific about what needs to be checked
|
|
75
|
+
* 5. **Use helper text for guidance**: Help users understand the implications of checking
|
|
76
|
+
*
|
|
77
|
+
* ## Common Pitfalls
|
|
78
|
+
*
|
|
79
|
+
* - Missing label (screen readers can't identify the checkbox)
|
|
80
|
+
* - Using checkbox for single choice (use Radio for mutually exclusive options)
|
|
81
|
+
* - Vague error messages (be specific about validation failures)
|
|
82
|
+
* - Not grouping related checkboxes (use Fieldset for logical grouping)
|
|
83
|
+
* - Using wrong element (use Radio for single choice from multiple options)
|
|
84
|
+
*
|
|
85
|
+
* @component
|
|
86
|
+
* @example
|
|
87
|
+
* ```tsx
|
|
88
|
+
* <Checkbox
|
|
89
|
+
* id="terms"
|
|
90
|
+
* label="I agree to the terms"
|
|
91
|
+
* required
|
|
92
|
+
* error={errors.terms}
|
|
93
|
+
* />
|
|
94
|
+
* ```
|
|
95
|
+
*/
|
|
96
|
+
const meta: Meta<typeof Checkbox> = {
|
|
97
|
+
title: 'Components/Form/Checkbox',
|
|
98
|
+
component: Checkbox,
|
|
99
|
+
tags: ['autodocs'],
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default meta
|
|
103
|
+
type Story = StoryObj<typeof Checkbox>
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Standard checkbox with label. The label is properly associated
|
|
107
|
+
* with the checkbox for screen readers and clicking the label toggles the checkbox.
|
|
108
|
+
*/
|
|
109
|
+
export const Default: Story = {
|
|
110
|
+
render: () => {
|
|
111
|
+
const [checked, setChecked] = useState(false)
|
|
112
|
+
return (
|
|
113
|
+
<Checkbox
|
|
114
|
+
id="checkbox-default"
|
|
115
|
+
label="I agree to the terms and conditions"
|
|
116
|
+
checked={checked}
|
|
117
|
+
onChange={(e) => setChecked(e.target.checked)}
|
|
118
|
+
/>
|
|
119
|
+
)
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Checkbox with error message. Error messages are announced to screen readers
|
|
125
|
+
* and displayed visually below the checkbox. The checkbox is marked as invalid
|
|
126
|
+
* with \`aria-invalid="true"\`.
|
|
127
|
+
*/
|
|
128
|
+
export const WithError: Story = {
|
|
129
|
+
render: () => {
|
|
130
|
+
const [checked, setChecked] = useState(false)
|
|
131
|
+
return (
|
|
132
|
+
<Checkbox
|
|
133
|
+
id="checkbox-error"
|
|
134
|
+
label="I agree to the terms"
|
|
135
|
+
checked={checked}
|
|
136
|
+
onChange={(e) => setChecked(e.target.checked)}
|
|
137
|
+
error="You must agree to the terms to continue"
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Checkbox with helper text. Helper text provides additional context or guidance
|
|
145
|
+
* and is associated with the checkbox via \`aria-describedby\`.
|
|
146
|
+
*/
|
|
147
|
+
export const WithHelperText: Story = {
|
|
148
|
+
render: () => {
|
|
149
|
+
const [checked, setChecked] = useState(false)
|
|
150
|
+
return (
|
|
151
|
+
<Checkbox
|
|
152
|
+
id="checkbox-helper"
|
|
153
|
+
label="Subscribe to newsletter"
|
|
154
|
+
checked={checked}
|
|
155
|
+
onChange={(e) => setChecked(e.target.checked)}
|
|
156
|
+
helperText="Receive weekly updates about new features"
|
|
157
|
+
/>
|
|
158
|
+
)
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Required checkbox field. Required checkboxes are marked with \`aria-required="true"\`
|
|
164
|
+
* and display a visual indicator (asterisk) next to the label.
|
|
165
|
+
*/
|
|
166
|
+
export const Required: Story = {
|
|
167
|
+
render: () => {
|
|
168
|
+
const [checked, setChecked] = useState(false)
|
|
169
|
+
return (
|
|
170
|
+
<Checkbox
|
|
171
|
+
id="checkbox-required"
|
|
172
|
+
label="I accept the privacy policy"
|
|
173
|
+
checked={checked}
|
|
174
|
+
onChange={(e) => setChecked(e.target.checked)}
|
|
175
|
+
required
|
|
176
|
+
/>
|
|
177
|
+
)
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Disabled checkbox that cannot be toggled. Disabled checkboxes are announced as
|
|
183
|
+
* "disabled" by screen readers and appear visually dimmed.
|
|
184
|
+
*/
|
|
185
|
+
export const Disabled: Story = {
|
|
186
|
+
args: {
|
|
187
|
+
id: 'checkbox-disabled',
|
|
188
|
+
label: 'This option is disabled',
|
|
189
|
+
disabled: true,
|
|
190
|
+
checked: false,
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Checked checkbox state. Shows the checkbox in its checked state.
|
|
196
|
+
*/
|
|
197
|
+
export const Checked: Story = {
|
|
198
|
+
args: {
|
|
199
|
+
id: 'checkbox-checked',
|
|
200
|
+
label: 'This option is selected',
|
|
201
|
+
checked: true,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Multiple checkboxes grouped together. Use Fieldset component to group
|
|
207
|
+
* related checkboxes for better semantic structure and accessibility.
|
|
208
|
+
*/
|
|
209
|
+
export const Grouped: Story = {
|
|
210
|
+
render: () => {
|
|
211
|
+
const [preferences, setPreferences] = useState({
|
|
212
|
+
email: false,
|
|
213
|
+
sms: false,
|
|
214
|
+
push: false,
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const handleChange = (key: keyof typeof preferences) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
218
|
+
setPreferences((prev) => ({ ...prev, [key]: e.target.checked }))
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
223
|
+
<Checkbox
|
|
224
|
+
id="pref-email"
|
|
225
|
+
label="Email notifications"
|
|
226
|
+
checked={preferences.email}
|
|
227
|
+
onChange={handleChange('email')}
|
|
228
|
+
/>
|
|
229
|
+
<Checkbox
|
|
230
|
+
id="pref-sms"
|
|
231
|
+
label="SMS notifications"
|
|
232
|
+
checked={preferences.sms}
|
|
233
|
+
onChange={handleChange('sms')}
|
|
234
|
+
/>
|
|
235
|
+
<Checkbox
|
|
236
|
+
id="pref-push"
|
|
237
|
+
label="Push notifications"
|
|
238
|
+
checked={preferences.push}
|
|
239
|
+
onChange={handleChange('push')}
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
)
|
|
243
|
+
},
|
|
244
|
+
parameters: {
|
|
245
|
+
docs: {
|
|
246
|
+
description: {
|
|
247
|
+
story: 'Multiple checkboxes that can be selected independently. For better accessibility, wrap related checkboxes in a Fieldset component.',
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { combineAriaDescribedBy } from '../../utils/aria'
|
|
5
|
+
import './Checkbox.css'
|
|
6
|
+
|
|
7
|
+
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
8
|
+
/**
|
|
9
|
+
* Label for the checkbox
|
|
10
|
+
*/
|
|
11
|
+
label?: string
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Error message to display
|
|
15
|
+
*/
|
|
16
|
+
error?: string
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Helper text to display
|
|
20
|
+
*/
|
|
21
|
+
helperText?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Accessible Checkbox component
|
|
26
|
+
*
|
|
27
|
+
* WCAG Compliance:
|
|
28
|
+
* - 1.3.1 Info and Relationships: Proper label-input association
|
|
29
|
+
* - 2.5.3 Label in Name: Label text matches accessible name
|
|
30
|
+
* - 4.1.2 Name, Role, Value: Proper ARIA attributes
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* <Checkbox
|
|
35
|
+
* id="agree"
|
|
36
|
+
* label="I agree to the terms"
|
|
37
|
+
* checked={checked}
|
|
38
|
+
* onChange={handleChange}
|
|
39
|
+
* />
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
|
43
|
+
(
|
|
44
|
+
{
|
|
45
|
+
id,
|
|
46
|
+
label,
|
|
47
|
+
error,
|
|
48
|
+
helperText,
|
|
49
|
+
className = '',
|
|
50
|
+
'aria-describedby': ariaDescribedBy,
|
|
51
|
+
...props
|
|
52
|
+
},
|
|
53
|
+
ref
|
|
54
|
+
) => {
|
|
55
|
+
const checkboxId = React.useId()
|
|
56
|
+
const finalId = id || `checkbox-${checkboxId}`
|
|
57
|
+
const errorId = error ? `${finalId}-error` : undefined
|
|
58
|
+
const helperId = helperText ? `${finalId}-helper` : undefined
|
|
59
|
+
|
|
60
|
+
const describedBy = combineAriaDescribedBy(
|
|
61
|
+
ariaDescribedBy,
|
|
62
|
+
errorId,
|
|
63
|
+
helperId
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const classes = [
|
|
67
|
+
'form-checkbox',
|
|
68
|
+
error && 'form-checkbox--error',
|
|
69
|
+
className,
|
|
70
|
+
]
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join(' ')
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="form-checkbox-wrapper">
|
|
76
|
+
<div className="form-checkbox-input-wrapper">
|
|
77
|
+
<input
|
|
78
|
+
ref={ref}
|
|
79
|
+
id={finalId}
|
|
80
|
+
type="checkbox"
|
|
81
|
+
className={classes}
|
|
82
|
+
aria-invalid={error ? true : undefined}
|
|
83
|
+
aria-describedby={describedBy}
|
|
84
|
+
required={props.required ? true : undefined}
|
|
85
|
+
{...props}
|
|
86
|
+
/>
|
|
87
|
+
{label && (
|
|
88
|
+
<label htmlFor={finalId} className="form-checkbox-label">
|
|
89
|
+
{label}
|
|
90
|
+
{props.required && (
|
|
91
|
+
<span className="form-label__required" aria-hidden="true">
|
|
92
|
+
{' '}*
|
|
93
|
+
</span>
|
|
94
|
+
)}
|
|
95
|
+
</label>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
{helperText && !error && (
|
|
99
|
+
<span id={helperId} className="form-helper-text">
|
|
100
|
+
{helperText}
|
|
101
|
+
</span>
|
|
102
|
+
)}
|
|
103
|
+
{error && (
|
|
104
|
+
<span id={errorId} className="form-error-text" role="alert">
|
|
105
|
+
{error}
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
Checkbox.displayName = 'Checkbox'
|
|
114
|
+
|