@stacksjs/components 0.2.92 → 0.2.94
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/dist/{Sidebar-yb115a2w.stx → Sidebar-4hn7vw4n.stx} +18 -0
- package/dist/index.js +5 -5
- package/package.json +5 -6
- package/src/components/CodeBlock.stx +0 -54
- package/src/components/Footer.stx +0 -66
- package/src/components/Hero.stx +0 -25
- package/src/components/Installation.stx +0 -37
- package/src/components/index.ts +0 -4
- package/src/composables/index.ts +0 -4
- package/src/composables/useCopyCode.ts +0 -73
- package/src/composables/useDarkMode.ts +0 -100
- package/src/composables/useSEO.ts +0 -230
- package/src/composables/utilities/index.ts +0 -7
- package/src/composables/utilities/useClickOutside.ts +0 -72
- package/src/composables/utilities/useFocusTrap.ts +0 -159
- package/src/composables/utilities/useKeyboard.ts +0 -124
- package/src/composables/utilities/useLocalStorage.ts +0 -132
- package/src/composables/utilities/useMediaQuery.ts +0 -118
- package/src/index.ts +0 -99
- package/src/stx.d.ts +0 -4
- package/src/ui/accordion/Accordion.stx +0 -146
- package/src/ui/accordion/AccordionItem.stx +0 -37
- package/src/ui/accordion/index.ts +0 -47
- package/src/ui/audio/Audio.stx +0 -50
- package/src/ui/audio/README.md +0 -41
- package/src/ui/audio/index.ts +0 -13
- package/src/ui/auth/Login.stx +0 -161
- package/src/ui/auth/README.md +0 -142
- package/src/ui/auth/Signup.stx +0 -159
- package/src/ui/auth/TwoFactorChallenge.stx +0 -111
- package/src/ui/auth/index.ts +0 -32
- package/src/ui/avatar/Avatar.stx +0 -77
- package/src/ui/avatar/index.ts +0 -12
- package/src/ui/badge/Badge.stx +0 -62
- package/src/ui/badge/index.ts +0 -10
- package/src/ui/breadcrumb/Breadcrumb.stx +0 -79
- package/src/ui/breadcrumb/index.ts +0 -15
- package/src/ui/button/Button.stx +0 -74
- package/src/ui/button/README.md +0 -80
- package/src/ui/button/index.ts +0 -85
- package/src/ui/calendar/Calendar.stx +0 -189
- package/src/ui/calendar/README.md +0 -254
- package/src/ui/calendar/index.ts +0 -14
- package/src/ui/card/Card.stx +0 -68
- package/src/ui/card/index.ts +0 -13
- package/src/ui/checkbox/Checkbox.stx +0 -86
- package/src/ui/checkbox/index.ts +0 -16
- package/src/ui/combobox/Combobox.stx +0 -96
- package/src/ui/combobox/ComboboxButton.stx +0 -25
- package/src/ui/combobox/ComboboxInput.stx +0 -32
- package/src/ui/combobox/ComboboxOption.stx +0 -49
- package/src/ui/combobox/ComboboxOptions.stx +0 -16
- package/src/ui/combobox/README.md +0 -150
- package/src/ui/combobox/index.ts +0 -40
- package/src/ui/command-palette/CommandPalette.stx +0 -81
- package/src/ui/command-palette/CommandPaletteItem.stx +0 -27
- package/src/ui/command-palette/README.md +0 -42
- package/src/ui/command-palette/index.ts +0 -15
- package/src/ui/dialog/Dialog.stx +0 -41
- package/src/ui/dialog/DialogBackdrop.stx +0 -9
- package/src/ui/dialog/DialogDescription.stx +0 -11
- package/src/ui/dialog/DialogPanel.stx +0 -16
- package/src/ui/dialog/DialogTitle.stx +0 -11
- package/src/ui/dialog/README.md +0 -121
- package/src/ui/dialog/index.ts +0 -32
- package/src/ui/drawer/Drawer.stx +0 -120
- package/src/ui/drawer/README.md +0 -90
- package/src/ui/drawer/index.ts +0 -9
- package/src/ui/dropdown/Dropdown.stx +0 -82
- package/src/ui/dropdown/DropdownButton.stx +0 -29
- package/src/ui/dropdown/DropdownItem.stx +0 -35
- package/src/ui/dropdown/DropdownItems.stx +0 -18
- package/src/ui/dropdown/README.md +0 -114
- package/src/ui/dropdown/index.ts +0 -29
- package/src/ui/form/Form.stx +0 -95
- package/src/ui/form/index.ts +0 -67
- package/src/ui/heatmap/Heatmap.stx +0 -294
- package/src/ui/image/Image.stx +0 -165
- package/src/ui/image/README.md +0 -164
- package/src/ui/image/index.ts +0 -22
- package/src/ui/input/EmailInput.stx +0 -30
- package/src/ui/input/NumberInput.stx +0 -126
- package/src/ui/input/PasswordInput.stx +0 -143
- package/src/ui/input/SearchInput.stx +0 -44
- package/src/ui/input/TextInput.stx +0 -132
- package/src/ui/input/index.ts +0 -180
- package/src/ui/listbox/Listbox.stx +0 -101
- package/src/ui/listbox/ListboxButton.stx +0 -28
- package/src/ui/listbox/ListboxLabel.stx +0 -11
- package/src/ui/listbox/ListboxOption.stx +0 -49
- package/src/ui/listbox/ListboxOptions.stx +0 -16
- package/src/ui/listbox/README.md +0 -135
- package/src/ui/listbox/index.ts +0 -39
- package/src/ui/navigator/Navigator.stx +0 -113
- package/src/ui/navigator/README.md +0 -352
- package/src/ui/navigator/index.ts +0 -20
- package/src/ui/notification/Notification.stx +0 -117
- package/src/ui/notification/README.md +0 -114
- package/src/ui/notification/index.ts +0 -12
- package/src/ui/pagination/Pagination.stx +0 -150
- package/src/ui/pagination/index.ts +0 -11
- package/src/ui/payment/Checkout.stx +0 -31
- package/src/ui/payment/DefaultPaymentMethod.stx +0 -62
- package/src/ui/payment/PaymentMethods.stx +0 -104
- package/src/ui/payment/README.md +0 -345
- package/src/ui/payment/SubscriptionCheckout.stx +0 -323
- package/src/ui/payment/index.ts +0 -59
- package/src/ui/popover/Popover.stx +0 -60
- package/src/ui/popover/PopoverButton.stx +0 -29
- package/src/ui/popover/PopoverPanel.stx +0 -23
- package/src/ui/popover/README.md +0 -91
- package/src/ui/popover/index.ts +0 -22
- package/src/ui/portal/Portal.stx +0 -57
- package/src/ui/portal/index.ts +0 -6
- package/src/ui/progress/Progress.stx +0 -131
- package/src/ui/progress/index.ts +0 -12
- package/src/ui/radio/Radio.stx +0 -77
- package/src/ui/radio/index.ts +0 -15
- package/src/ui/radio-group/README.md +0 -139
- package/src/ui/radio-group/RadioGroup.stx +0 -60
- package/src/ui/radio-group/RadioGroupDescription.stx +0 -11
- package/src/ui/radio-group/RadioGroupLabel.stx +0 -11
- package/src/ui/radio-group/RadioGroupOption.stx +0 -58
- package/src/ui/radio-group/index.ts +0 -30
- package/src/ui/select/README.md +0 -180
- package/src/ui/select/Select.stx +0 -82
- package/src/ui/select/index.ts +0 -21
- package/src/ui/sidebar/Sidebar.stx +0 -458
- package/src/ui/sidebar/SidebarFooter.stx +0 -71
- package/src/ui/sidebar/SidebarHeader.stx +0 -103
- package/src/ui/sidebar/SidebarItem.stx +0 -86
- package/src/ui/sidebar/SidebarSection.stx +0 -83
- package/src/ui/sidebar/index.ts +0 -153
- package/src/ui/sidebar/themes.ts +0 -240
- package/src/ui/skeleton/Skeleton.stx +0 -48
- package/src/ui/skeleton/index.ts +0 -11
- package/src/ui/spinner/Spinner.stx +0 -109
- package/src/ui/spinner/index.ts +0 -9
- package/src/ui/stepper/README.md +0 -36
- package/src/ui/stepper/Stepper.stx +0 -17
- package/src/ui/stepper/StepperStep.stx +0 -57
- package/src/ui/stepper/index.ts +0 -19
- package/src/ui/storage/README.md +0 -193
- package/src/ui/storage/index.ts +0 -347
- package/src/ui/switch/README.md +0 -114
- package/src/ui/switch/Switch.stx +0 -72
- package/src/ui/switch/index.ts +0 -10
- package/src/ui/table/README.md +0 -39
- package/src/ui/table/Table.stx +0 -14
- package/src/ui/table/TableBody.stx +0 -10
- package/src/ui/table/TableCell.stx +0 -10
- package/src/ui/table/TableHead.stx +0 -10
- package/src/ui/table/TableHeader.stx +0 -20
- package/src/ui/table/TableRow.stx +0 -11
- package/src/ui/table/index.ts +0 -35
- package/src/ui/tabs/TabPanel.stx +0 -25
- package/src/ui/tabs/Tabs.stx +0 -202
- package/src/ui/tabs/index.ts +0 -50
- package/src/ui/teleport/Teleport.stx +0 -65
- package/src/ui/teleport/index.ts +0 -7
- package/src/ui/textarea/Textarea.stx +0 -108
- package/src/ui/textarea/index.ts +0 -20
- package/src/ui/tooltip/Tooltip.stx +0 -71
- package/src/ui/tooltip/index.ts +0 -10
- package/src/ui/transition/README.md +0 -30
- package/src/ui/transition/Transition.stx +0 -102
- package/src/ui/transition/index.ts +0 -25
- package/src/ui/video/README.md +0 -47
- package/src/ui/video/Video.stx +0 -45
- package/src/ui/video/index.ts +0 -16
- package/src/ui/virtual-list/VirtualList.stx +0 -67
- package/src/ui/virtual-list/index.ts +0 -10
- package/src/ui/virtual-table/VirtualTable.stx +0 -103
- package/src/ui/virtual-table/index.ts +0 -21
- package/src/utils/PROP_VALIDATION.md +0 -458
- package/src/utils/THEME_SYSTEM.md +0 -702
- package/src/utils/accessibility.ts +0 -841
- package/src/utils/animation/easing.ts +0 -368
- package/src/utils/animation/helpers.ts +0 -223
- package/src/utils/animation/index.ts +0 -5
- package/src/utils/animation/keyframes.ts +0 -230
- package/src/utils/error-handling.ts +0 -475
- package/src/utils/highlighter.ts +0 -93
- package/src/utils/i18n.ts +0 -634
- package/src/utils/keep-alive.ts +0 -819
- package/src/utils/lazy-loading.ts +0 -530
- package/src/utils/prop-validation.ts +0 -478
- package/src/utils/theme.ts +0 -626
|
@@ -1,841 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Accessibility utilities for @stacksjs/components
|
|
3
|
-
*
|
|
4
|
-
* Provides comprehensive ARIA helpers, focus management, keyboard navigation,
|
|
5
|
-
* and screen reader utilities for building accessible components
|
|
6
|
-
*
|
|
7
|
-
* @example
|
|
8
|
-
* ```ts
|
|
9
|
-
* import { createAriaLabel, manageFocus, announceToScreenReader } from '@stacksjs/components'
|
|
10
|
-
*
|
|
11
|
-
* // Create accessible label
|
|
12
|
-
* const labelId = createAriaLabel('Search', element)
|
|
13
|
-
*
|
|
14
|
-
* // Manage focus
|
|
15
|
-
* const trap = createFocusTrap(dialogElement)
|
|
16
|
-
* trap.activate()
|
|
17
|
-
*
|
|
18
|
-
* // Announce to screen readers
|
|
19
|
-
* announceToScreenReader('Form submitted successfully')
|
|
20
|
-
* ```
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* ARIA live region types
|
|
25
|
-
*/
|
|
26
|
-
export type AriaLive = 'off' | 'polite' | 'assertive'
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* ARIA role types
|
|
30
|
-
*/
|
|
31
|
-
export type AriaRole =
|
|
32
|
-
| 'alert'
|
|
33
|
-
| 'alertdialog'
|
|
34
|
-
| 'application'
|
|
35
|
-
| 'article'
|
|
36
|
-
| 'banner'
|
|
37
|
-
| 'button'
|
|
38
|
-
| 'cell'
|
|
39
|
-
| 'checkbox'
|
|
40
|
-
| 'columnheader'
|
|
41
|
-
| 'combobox'
|
|
42
|
-
| 'complementary'
|
|
43
|
-
| 'contentinfo'
|
|
44
|
-
| 'definition'
|
|
45
|
-
| 'dialog'
|
|
46
|
-
| 'directory'
|
|
47
|
-
| 'document'
|
|
48
|
-
| 'feed'
|
|
49
|
-
| 'figure'
|
|
50
|
-
| 'form'
|
|
51
|
-
| 'grid'
|
|
52
|
-
| 'gridcell'
|
|
53
|
-
| 'group'
|
|
54
|
-
| 'heading'
|
|
55
|
-
| 'img'
|
|
56
|
-
| 'link'
|
|
57
|
-
| 'list'
|
|
58
|
-
| 'listbox'
|
|
59
|
-
| 'listitem'
|
|
60
|
-
| 'log'
|
|
61
|
-
| 'main'
|
|
62
|
-
| 'marquee'
|
|
63
|
-
| 'math'
|
|
64
|
-
| 'menu'
|
|
65
|
-
| 'menubar'
|
|
66
|
-
| 'menuitem'
|
|
67
|
-
| 'menuitemcheckbox'
|
|
68
|
-
| 'menuitemradio'
|
|
69
|
-
| 'navigation'
|
|
70
|
-
| 'none'
|
|
71
|
-
| 'note'
|
|
72
|
-
| 'option'
|
|
73
|
-
| 'presentation'
|
|
74
|
-
| 'progressbar'
|
|
75
|
-
| 'radio'
|
|
76
|
-
| 'radiogroup'
|
|
77
|
-
| 'region'
|
|
78
|
-
| 'row'
|
|
79
|
-
| 'rowgroup'
|
|
80
|
-
| 'rowheader'
|
|
81
|
-
| 'scrollbar'
|
|
82
|
-
| 'search'
|
|
83
|
-
| 'searchbox'
|
|
84
|
-
| 'separator'
|
|
85
|
-
| 'slider'
|
|
86
|
-
| 'spinbutton'
|
|
87
|
-
| 'status'
|
|
88
|
-
| 'switch'
|
|
89
|
-
| 'tab'
|
|
90
|
-
| 'table'
|
|
91
|
-
| 'tablist'
|
|
92
|
-
| 'tabpanel'
|
|
93
|
-
| 'term'
|
|
94
|
-
| 'textbox'
|
|
95
|
-
| 'timer'
|
|
96
|
-
| 'toolbar'
|
|
97
|
-
| 'tooltip'
|
|
98
|
-
| 'tree'
|
|
99
|
-
| 'treegrid'
|
|
100
|
-
| 'treeitem'
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Focus trap options
|
|
104
|
-
*/
|
|
105
|
-
export interface FocusTrapOptions {
|
|
106
|
-
/** Initial element to focus */
|
|
107
|
-
initialFocus?: HTMLElement | null
|
|
108
|
-
/** Element to focus when trap is deactivated */
|
|
109
|
-
returnFocus?: HTMLElement | null
|
|
110
|
-
/** Escape key closes trap */
|
|
111
|
-
escapeDeactivates?: boolean
|
|
112
|
-
/** Click outside deactivates trap */
|
|
113
|
-
clickOutsideDeactivates?: boolean
|
|
114
|
-
/** Allow outside click without deactivating */
|
|
115
|
-
allowOutsideClick?: boolean | ((_event: MouseEvent) => boolean)
|
|
116
|
-
/** Callback when activated */
|
|
117
|
-
onActivate?: () => void
|
|
118
|
-
/** Callback when deactivated */
|
|
119
|
-
onDeactivate?: () => void
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Focus trap instance
|
|
124
|
-
*/
|
|
125
|
-
export interface FocusTrap {
|
|
126
|
-
activate: () => void
|
|
127
|
-
deactivate: () => void
|
|
128
|
-
pause: () => void
|
|
129
|
-
unpause: () => void
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Generate a unique ID for accessibility attributes
|
|
134
|
-
*
|
|
135
|
-
* @param prefix - Optional prefix for the ID
|
|
136
|
-
* @returns Unique ID string
|
|
137
|
-
*/
|
|
138
|
-
let idCounter = 0
|
|
139
|
-
export function generateId(prefix = 'a11y'): string {
|
|
140
|
-
return `${prefix}-${++idCounter}-${Date.now()}`
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Create accessible label and associate with element
|
|
145
|
-
*
|
|
146
|
-
* @param text - Label text
|
|
147
|
-
* @param element - Element to associate with
|
|
148
|
-
* @param options - Label options
|
|
149
|
-
* @param options.visible - Whether the label should be visible
|
|
150
|
-
* @param options.id - Custom ID for the label
|
|
151
|
-
* @returns Generated label ID
|
|
152
|
-
*/
|
|
153
|
-
export function createAriaLabel(
|
|
154
|
-
text: string,
|
|
155
|
-
element?: HTMLElement | null,
|
|
156
|
-
options: { visible?: boolean, id?: string } = {},
|
|
157
|
-
): string {
|
|
158
|
-
const { visible = false, id = generateId('label') } = options
|
|
159
|
-
|
|
160
|
-
const label = document.createElement(visible ? 'label' : 'span')
|
|
161
|
-
label.id = id
|
|
162
|
-
label.textContent = text
|
|
163
|
-
|
|
164
|
-
if (!visible) {
|
|
165
|
-
// Visually hidden but accessible to screen readers
|
|
166
|
-
Object.assign(label.style, {
|
|
167
|
-
position: 'absolute',
|
|
168
|
-
width: '1px',
|
|
169
|
-
height: '1px',
|
|
170
|
-
padding: '0',
|
|
171
|
-
margin: '-1px',
|
|
172
|
-
overflow: 'hidden',
|
|
173
|
-
clip: 'rect(0, 0, 0, 0)',
|
|
174
|
-
whiteSpace: 'nowrap',
|
|
175
|
-
border: '0',
|
|
176
|
-
})
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (element) {
|
|
180
|
-
element.setAttribute('aria-labelledby', id)
|
|
181
|
-
if (element.parentElement) {
|
|
182
|
-
element.parentElement.insertBefore(label, element)
|
|
183
|
-
}
|
|
184
|
-
else {
|
|
185
|
-
document.body.appendChild(label)
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return id
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Create accessible description and associate with element
|
|
194
|
-
*
|
|
195
|
-
* @param text - Description text
|
|
196
|
-
* @param element - Element to associate with
|
|
197
|
-
* @returns Generated description ID
|
|
198
|
-
*/
|
|
199
|
-
export function createAriaDescription(text: string, element?: HTMLElement | null): string {
|
|
200
|
-
const id = generateId('desc')
|
|
201
|
-
|
|
202
|
-
const description = document.createElement('span')
|
|
203
|
-
description.id = id
|
|
204
|
-
description.textContent = text
|
|
205
|
-
|
|
206
|
-
// Visually hidden but accessible to screen readers
|
|
207
|
-
Object.assign(description.style, {
|
|
208
|
-
position: 'absolute',
|
|
209
|
-
width: '1px',
|
|
210
|
-
height: '1px',
|
|
211
|
-
padding: '0',
|
|
212
|
-
margin: '-1px',
|
|
213
|
-
overflow: 'hidden',
|
|
214
|
-
clip: 'rect(0, 0, 0, 0)',
|
|
215
|
-
whiteSpace: 'nowrap',
|
|
216
|
-
border: '0',
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
if (element) {
|
|
220
|
-
element.setAttribute('aria-describedby', id)
|
|
221
|
-
if (element.parentElement) {
|
|
222
|
-
element.parentElement.insertBefore(description, element)
|
|
223
|
-
}
|
|
224
|
-
else {
|
|
225
|
-
document.body.appendChild(description)
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
return id
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Announce message to screen readers
|
|
234
|
-
*
|
|
235
|
-
* Creates a live region and announces the message
|
|
236
|
-
*
|
|
237
|
-
* @param message - Message to announce
|
|
238
|
-
* @param priority - Announcement priority
|
|
239
|
-
*/
|
|
240
|
-
export function announceToScreenReader(message: string, priority: AriaLive = 'polite'): void {
|
|
241
|
-
const announcement = document.createElement('div')
|
|
242
|
-
announcement.setAttribute('role', 'status')
|
|
243
|
-
announcement.setAttribute('aria-live', priority)
|
|
244
|
-
announcement.setAttribute('aria-atomic', 'true')
|
|
245
|
-
|
|
246
|
-
// Visually hidden
|
|
247
|
-
Object.assign(announcement.style, {
|
|
248
|
-
position: 'absolute',
|
|
249
|
-
width: '1px',
|
|
250
|
-
height: '1px',
|
|
251
|
-
padding: '0',
|
|
252
|
-
margin: '-1px',
|
|
253
|
-
overflow: 'hidden',
|
|
254
|
-
clip: 'rect(0, 0, 0, 0)',
|
|
255
|
-
whiteSpace: 'nowrap',
|
|
256
|
-
border: '0',
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
document.body.appendChild(announcement)
|
|
260
|
-
|
|
261
|
-
// Delay to ensure screen readers pick it up
|
|
262
|
-
setTimeout(() => {
|
|
263
|
-
announcement.textContent = message
|
|
264
|
-
}, 100)
|
|
265
|
-
|
|
266
|
-
// Clean up after announcement
|
|
267
|
-
setTimeout(() => {
|
|
268
|
-
announcement.parentNode?.removeChild(announcement)
|
|
269
|
-
}, 1000)
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Get all focusable elements within a container
|
|
274
|
-
*
|
|
275
|
-
* @param container - Container element
|
|
276
|
-
* @returns Array of focusable elements
|
|
277
|
-
*/
|
|
278
|
-
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
|
|
279
|
-
const selector = [
|
|
280
|
-
'a[href]',
|
|
281
|
-
'area[href]',
|
|
282
|
-
'input:not([disabled]):not([type="hidden"])',
|
|
283
|
-
'select:not([disabled])',
|
|
284
|
-
'textarea:not([disabled])',
|
|
285
|
-
'button:not([disabled])',
|
|
286
|
-
'iframe',
|
|
287
|
-
'object',
|
|
288
|
-
'embed',
|
|
289
|
-
'[contenteditable]',
|
|
290
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
291
|
-
].join(',')
|
|
292
|
-
|
|
293
|
-
return Array.from(container.querySelectorAll<HTMLElement>(selector)).filter(
|
|
294
|
-
element => element.offsetParent !== null, // Visible elements only
|
|
295
|
-
)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Create a focus trap for modal dialogs and overlays
|
|
300
|
-
*
|
|
301
|
-
* Traps keyboard focus within a container element
|
|
302
|
-
*
|
|
303
|
-
* @param container - Container element to trap focus within
|
|
304
|
-
* @param options - Focus trap options
|
|
305
|
-
* @returns Focus trap instance
|
|
306
|
-
*/
|
|
307
|
-
export function createFocusTrap(
|
|
308
|
-
container: HTMLElement,
|
|
309
|
-
options: FocusTrapOptions = {},
|
|
310
|
-
): FocusTrap {
|
|
311
|
-
const {
|
|
312
|
-
initialFocus,
|
|
313
|
-
returnFocus,
|
|
314
|
-
escapeDeactivates = true,
|
|
315
|
-
clickOutsideDeactivates = true,
|
|
316
|
-
allowOutsideClick = false,
|
|
317
|
-
onActivate,
|
|
318
|
-
onDeactivate,
|
|
319
|
-
} = options
|
|
320
|
-
|
|
321
|
-
let isActive = false
|
|
322
|
-
let isPaused = false
|
|
323
|
-
let previouslyFocused: HTMLElement | null = null
|
|
324
|
-
|
|
325
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
326
|
-
if (isPaused)
|
|
327
|
-
return
|
|
328
|
-
|
|
329
|
-
// Handle escape key
|
|
330
|
-
if (escapeDeactivates && event.key === 'Escape') {
|
|
331
|
-
event.preventDefault()
|
|
332
|
-
deactivate()
|
|
333
|
-
return
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Handle tab key
|
|
337
|
-
if (event.key === 'Tab') {
|
|
338
|
-
const focusableElements = getFocusableElements(container)
|
|
339
|
-
|
|
340
|
-
if (focusableElements.length === 0) {
|
|
341
|
-
event.preventDefault()
|
|
342
|
-
return
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
const firstElement = focusableElements[0]
|
|
346
|
-
const lastElement = focusableElements[focusableElements.length - 1]
|
|
347
|
-
|
|
348
|
-
if (event.shiftKey) {
|
|
349
|
-
// Shift + Tab
|
|
350
|
-
if (document.activeElement === firstElement) {
|
|
351
|
-
event.preventDefault()
|
|
352
|
-
lastElement.focus()
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
else {
|
|
356
|
-
// Tab
|
|
357
|
-
if (document.activeElement === lastElement) {
|
|
358
|
-
event.preventDefault()
|
|
359
|
-
firstElement.focus()
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const handleClickOutside = (event: MouseEvent) => {
|
|
366
|
-
if (isPaused)
|
|
367
|
-
return
|
|
368
|
-
|
|
369
|
-
if (!container.contains(event.target as Node)) {
|
|
370
|
-
if (typeof allowOutsideClick === 'function') {
|
|
371
|
-
if (!allowOutsideClick(event) && clickOutsideDeactivates) {
|
|
372
|
-
deactivate()
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
else if (!allowOutsideClick && clickOutsideDeactivates) {
|
|
376
|
-
deactivate()
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
function activate() {
|
|
382
|
-
if (isActive)
|
|
383
|
-
return
|
|
384
|
-
|
|
385
|
-
isActive = true
|
|
386
|
-
previouslyFocused = document.activeElement as HTMLElement
|
|
387
|
-
|
|
388
|
-
// Set initial focus
|
|
389
|
-
const elementToFocus = initialFocus || getFocusableElements(container)[0]
|
|
390
|
-
if (elementToFocus && typeof elementToFocus.focus === 'function') {
|
|
391
|
-
elementToFocus.focus()
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// Add event listeners
|
|
395
|
-
document.addEventListener('keydown', handleKeyDown)
|
|
396
|
-
document.addEventListener('click', handleClickOutside)
|
|
397
|
-
|
|
398
|
-
onActivate?.()
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function deactivate() {
|
|
402
|
-
if (!isActive)
|
|
403
|
-
return
|
|
404
|
-
|
|
405
|
-
isActive = false
|
|
406
|
-
|
|
407
|
-
// Return focus
|
|
408
|
-
const elementToFocus = returnFocus || previouslyFocused
|
|
409
|
-
if (elementToFocus && typeof elementToFocus.focus === 'function') {
|
|
410
|
-
elementToFocus.focus()
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
// Remove event listeners
|
|
414
|
-
document.removeEventListener('keydown', handleKeyDown)
|
|
415
|
-
document.removeEventListener('click', handleClickOutside)
|
|
416
|
-
|
|
417
|
-
onDeactivate?.()
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
function pause() {
|
|
421
|
-
isPaused = true
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function unpause() {
|
|
425
|
-
isPaused = false
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
return {
|
|
429
|
-
activate,
|
|
430
|
-
deactivate,
|
|
431
|
-
pause,
|
|
432
|
-
unpause,
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* Manage focus order with roving tabindex
|
|
438
|
-
*
|
|
439
|
-
* Useful for toolbars, menus, and other composite widgets
|
|
440
|
-
*
|
|
441
|
-
* @param container - Container element
|
|
442
|
-
* @param options - Options
|
|
443
|
-
* @returns Cleanup function
|
|
444
|
-
*/
|
|
445
|
-
export interface RovingTabindexOptions {
|
|
446
|
-
/** Selector for items */
|
|
447
|
-
selector?: string
|
|
448
|
-
/** Initial focused index */
|
|
449
|
-
initialIndex?: number
|
|
450
|
-
/** Orientation for arrow key navigation */
|
|
451
|
-
orientation?: 'horizontal' | 'vertical' | 'both'
|
|
452
|
-
/** Loop navigation */
|
|
453
|
-
loop?: boolean
|
|
454
|
-
/** Callback when focus changes */
|
|
455
|
-
onFocusChange?: (index: number, element: HTMLElement) => void
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
export function createRovingTabindex(
|
|
459
|
-
container: HTMLElement,
|
|
460
|
-
options: RovingTabindexOptions = {},
|
|
461
|
-
): () => void {
|
|
462
|
-
const {
|
|
463
|
-
selector = '[role="option"], [role="menuitem"], [role="tab"], button',
|
|
464
|
-
initialIndex = 0,
|
|
465
|
-
orientation = 'both',
|
|
466
|
-
loop = true,
|
|
467
|
-
onFocusChange,
|
|
468
|
-
} = options
|
|
469
|
-
|
|
470
|
-
let currentIndex = initialIndex
|
|
471
|
-
|
|
472
|
-
const getItems = (): HTMLElement[] => {
|
|
473
|
-
return Array.from(container.querySelectorAll<HTMLElement>(selector))
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
const setFocusedIndex = (index: number) => {
|
|
477
|
-
const items = getItems()
|
|
478
|
-
if (index < 0 || index >= items.length)
|
|
479
|
-
return
|
|
480
|
-
|
|
481
|
-
// Update tabindex
|
|
482
|
-
items.forEach((item, i) => {
|
|
483
|
-
if (i === index) {
|
|
484
|
-
item.setAttribute('tabindex', '0')
|
|
485
|
-
if (typeof item.focus === 'function') {
|
|
486
|
-
item.focus()
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
else {
|
|
490
|
-
item.setAttribute('tabindex', '-1')
|
|
491
|
-
}
|
|
492
|
-
})
|
|
493
|
-
|
|
494
|
-
currentIndex = index
|
|
495
|
-
onFocusChange?.(index, items[index])
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
499
|
-
const items = getItems()
|
|
500
|
-
const maxIndex = items.length - 1
|
|
501
|
-
|
|
502
|
-
let handled = false
|
|
503
|
-
let newIndex = currentIndex
|
|
504
|
-
|
|
505
|
-
switch (event.key) {
|
|
506
|
-
case 'ArrowRight':
|
|
507
|
-
if (orientation === 'horizontal' || orientation === 'both') {
|
|
508
|
-
newIndex = currentIndex + 1
|
|
509
|
-
if (newIndex > maxIndex) {
|
|
510
|
-
newIndex = loop ? 0 : maxIndex
|
|
511
|
-
}
|
|
512
|
-
handled = true
|
|
513
|
-
}
|
|
514
|
-
break
|
|
515
|
-
|
|
516
|
-
case 'ArrowLeft':
|
|
517
|
-
if (orientation === 'horizontal' || orientation === 'both') {
|
|
518
|
-
newIndex = currentIndex - 1
|
|
519
|
-
if (newIndex < 0) {
|
|
520
|
-
newIndex = loop ? maxIndex : 0
|
|
521
|
-
}
|
|
522
|
-
handled = true
|
|
523
|
-
}
|
|
524
|
-
break
|
|
525
|
-
|
|
526
|
-
case 'ArrowDown':
|
|
527
|
-
if (orientation === 'vertical' || orientation === 'both') {
|
|
528
|
-
newIndex = currentIndex + 1
|
|
529
|
-
if (newIndex > maxIndex) {
|
|
530
|
-
newIndex = loop ? 0 : maxIndex
|
|
531
|
-
}
|
|
532
|
-
handled = true
|
|
533
|
-
}
|
|
534
|
-
break
|
|
535
|
-
|
|
536
|
-
case 'ArrowUp':
|
|
537
|
-
if (orientation === 'vertical' || orientation === 'both') {
|
|
538
|
-
newIndex = currentIndex - 1
|
|
539
|
-
if (newIndex < 0) {
|
|
540
|
-
newIndex = loop ? maxIndex : 0
|
|
541
|
-
}
|
|
542
|
-
handled = true
|
|
543
|
-
}
|
|
544
|
-
break
|
|
545
|
-
|
|
546
|
-
case 'Home':
|
|
547
|
-
newIndex = 0
|
|
548
|
-
handled = true
|
|
549
|
-
break
|
|
550
|
-
|
|
551
|
-
case 'End':
|
|
552
|
-
newIndex = maxIndex
|
|
553
|
-
handled = true
|
|
554
|
-
break
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
if (handled) {
|
|
558
|
-
event.preventDefault()
|
|
559
|
-
setFocusedIndex(newIndex)
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// Initialize
|
|
564
|
-
setFocusedIndex(initialIndex)
|
|
565
|
-
|
|
566
|
-
// Add event listener
|
|
567
|
-
container.addEventListener('keydown', handleKeyDown)
|
|
568
|
-
|
|
569
|
-
// Return cleanup function
|
|
570
|
-
return () => {
|
|
571
|
-
container.removeEventListener('keydown', handleKeyDown)
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/**
|
|
576
|
-
* Keyboard navigation helpers
|
|
577
|
-
*/
|
|
578
|
-
export const KeyboardKeys = {
|
|
579
|
-
Enter: 'Enter',
|
|
580
|
-
Space: ' ',
|
|
581
|
-
Escape: 'Escape',
|
|
582
|
-
ArrowUp: 'ArrowUp',
|
|
583
|
-
ArrowDown: 'ArrowDown',
|
|
584
|
-
ArrowLeft: 'ArrowLeft',
|
|
585
|
-
ArrowRight: 'ArrowRight',
|
|
586
|
-
Tab: 'Tab',
|
|
587
|
-
Home: 'Home',
|
|
588
|
-
End: 'End',
|
|
589
|
-
PageUp: 'PageUp',
|
|
590
|
-
PageDown: 'PageDown',
|
|
591
|
-
} as const
|
|
592
|
-
|
|
593
|
-
/**
|
|
594
|
-
* Check if element has keyboard focus
|
|
595
|
-
*/
|
|
596
|
-
export function hasFocus(element: HTMLElement): boolean {
|
|
597
|
-
return document.activeElement === element
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* Move focus to next/previous element
|
|
602
|
-
*/
|
|
603
|
-
export function moveFocus(direction: 'next' | 'previous', from?: HTMLElement): void {
|
|
604
|
-
const activeElement = from || (document.activeElement as HTMLElement)
|
|
605
|
-
const focusableElements = getFocusableElements(document.body)
|
|
606
|
-
|
|
607
|
-
const currentIndex = focusableElements.indexOf(activeElement)
|
|
608
|
-
if (currentIndex === -1)
|
|
609
|
-
return
|
|
610
|
-
|
|
611
|
-
const nextIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1
|
|
612
|
-
const nextElement = focusableElements[nextIndex]
|
|
613
|
-
|
|
614
|
-
if (nextElement) {
|
|
615
|
-
nextElement.focus()
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* Check if element is visible to screen readers
|
|
621
|
-
*/
|
|
622
|
-
export function isVisibleToScreenReader(element: HTMLElement): boolean {
|
|
623
|
-
if (element.getAttribute('aria-hidden') === 'true') {
|
|
624
|
-
return false
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
const style = window.getComputedStyle(element)
|
|
628
|
-
if (style.display === 'none' || style.visibility === 'hidden') {
|
|
629
|
-
return false
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
return true
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
/**
|
|
636
|
-
* ARIA attribute helpers
|
|
637
|
-
*/
|
|
638
|
-
export const aria = {
|
|
639
|
-
/**
|
|
640
|
-
* Set aria-expanded attribute
|
|
641
|
-
*/
|
|
642
|
-
setExpanded(element: HTMLElement, expanded: boolean): void {
|
|
643
|
-
element.setAttribute('aria-expanded', String(expanded))
|
|
644
|
-
},
|
|
645
|
-
|
|
646
|
-
/**
|
|
647
|
-
* Set aria-selected attribute
|
|
648
|
-
*/
|
|
649
|
-
setSelected(element: HTMLElement, selected: boolean): void {
|
|
650
|
-
element.setAttribute('aria-selected', String(selected))
|
|
651
|
-
},
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Set aria-checked attribute
|
|
655
|
-
*/
|
|
656
|
-
setChecked(element: HTMLElement, checked: boolean | 'mixed'): void {
|
|
657
|
-
element.setAttribute('aria-checked', String(checked))
|
|
658
|
-
},
|
|
659
|
-
|
|
660
|
-
/**
|
|
661
|
-
* Set aria-disabled attribute
|
|
662
|
-
*/
|
|
663
|
-
setDisabled(element: HTMLElement, disabled: boolean): void {
|
|
664
|
-
element.setAttribute('aria-disabled', String(disabled))
|
|
665
|
-
if (disabled) {
|
|
666
|
-
element.setAttribute('tabindex', '-1')
|
|
667
|
-
}
|
|
668
|
-
else {
|
|
669
|
-
element.removeAttribute('tabindex')
|
|
670
|
-
}
|
|
671
|
-
},
|
|
672
|
-
|
|
673
|
-
/**
|
|
674
|
-
* Set aria-hidden attribute
|
|
675
|
-
*/
|
|
676
|
-
setHidden(element: HTMLElement, hidden: boolean): void {
|
|
677
|
-
if (hidden) {
|
|
678
|
-
element.setAttribute('aria-hidden', 'true')
|
|
679
|
-
}
|
|
680
|
-
else {
|
|
681
|
-
element.removeAttribute('aria-hidden')
|
|
682
|
-
}
|
|
683
|
-
},
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* Set aria-pressed attribute (for toggle buttons)
|
|
687
|
-
*/
|
|
688
|
-
setPressed(element: HTMLElement, pressed: boolean): void {
|
|
689
|
-
element.setAttribute('aria-pressed', String(pressed))
|
|
690
|
-
},
|
|
691
|
-
|
|
692
|
-
/**
|
|
693
|
-
* Set aria-invalid attribute
|
|
694
|
-
*/
|
|
695
|
-
setInvalid(element: HTMLElement, invalid: boolean): void {
|
|
696
|
-
element.setAttribute('aria-invalid', String(invalid))
|
|
697
|
-
},
|
|
698
|
-
|
|
699
|
-
/**
|
|
700
|
-
* Set aria-required attribute
|
|
701
|
-
*/
|
|
702
|
-
setRequired(element: HTMLElement, required: boolean): void {
|
|
703
|
-
element.setAttribute('aria-required', String(required))
|
|
704
|
-
},
|
|
705
|
-
|
|
706
|
-
/**
|
|
707
|
-
* Set aria-live region
|
|
708
|
-
*/
|
|
709
|
-
setLive(element: HTMLElement, live: AriaLive): void {
|
|
710
|
-
element.setAttribute('aria-live', live)
|
|
711
|
-
},
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* Set aria-busy attribute
|
|
715
|
-
*/
|
|
716
|
-
setBusy(element: HTMLElement, busy: boolean): void {
|
|
717
|
-
element.setAttribute('aria-busy', String(busy))
|
|
718
|
-
},
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Screen reader only text
|
|
723
|
-
*
|
|
724
|
-
* Creates visually hidden text that's accessible to screen readers
|
|
725
|
-
*/
|
|
726
|
-
export function createScreenReaderText(text: string): HTMLSpanElement {
|
|
727
|
-
const span = document.createElement('span')
|
|
728
|
-
span.textContent = text
|
|
729
|
-
span.className = 'sr-only'
|
|
730
|
-
|
|
731
|
-
Object.assign(span.style, {
|
|
732
|
-
position: 'absolute',
|
|
733
|
-
width: '1px',
|
|
734
|
-
height: '1px',
|
|
735
|
-
padding: '0',
|
|
736
|
-
margin: '-1px',
|
|
737
|
-
overflow: 'hidden',
|
|
738
|
-
clip: 'rect(0, 0, 0, 0)',
|
|
739
|
-
whiteSpace: 'nowrap',
|
|
740
|
-
border: '0',
|
|
741
|
-
})
|
|
742
|
-
|
|
743
|
-
return span
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/**
|
|
747
|
-
* Check if reduced motion is preferred
|
|
748
|
-
*/
|
|
749
|
-
export function prefersReducedMotion(): boolean {
|
|
750
|
-
if (typeof window.matchMedia === 'function') {
|
|
751
|
-
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
752
|
-
}
|
|
753
|
-
return false
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
/**
|
|
757
|
-
* Check if high contrast mode is enabled
|
|
758
|
-
*/
|
|
759
|
-
export function prefersHighContrast(): boolean {
|
|
760
|
-
if (typeof window.matchMedia === 'function') {
|
|
761
|
-
return window.matchMedia('(prefers-contrast: high)').matches
|
|
762
|
-
}
|
|
763
|
-
return false
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* Get color scheme preference
|
|
768
|
-
*/
|
|
769
|
-
export function getColorSchemePreference(): 'light' | 'dark' | null {
|
|
770
|
-
if (typeof window.matchMedia === 'function') {
|
|
771
|
-
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
772
|
-
return 'dark'
|
|
773
|
-
}
|
|
774
|
-
if (window.matchMedia('(prefers-color-scheme: light)').matches) {
|
|
775
|
-
return 'light'
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
return null
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
/**
|
|
782
|
-
* Skip link helper for keyboard navigation
|
|
783
|
-
*
|
|
784
|
-
* Creates a skip link that appears on focus
|
|
785
|
-
*/
|
|
786
|
-
export function createSkipLink(targetId: string, text = 'Skip to main content'): HTMLAnchorElement {
|
|
787
|
-
const link = document.createElement('a')
|
|
788
|
-
link.href = `#${targetId}`
|
|
789
|
-
link.textContent = text
|
|
790
|
-
link.className = 'skip-link'
|
|
791
|
-
|
|
792
|
-
Object.assign(link.style, {
|
|
793
|
-
position: 'absolute',
|
|
794
|
-
top: '-40px',
|
|
795
|
-
left: '0',
|
|
796
|
-
background: '#000',
|
|
797
|
-
color: '#fff',
|
|
798
|
-
padding: '8px',
|
|
799
|
-
textDecoration: 'none',
|
|
800
|
-
zIndex: '100',
|
|
801
|
-
})
|
|
802
|
-
|
|
803
|
-
link.addEventListener('focus', () => {
|
|
804
|
-
link.style.top = '0'
|
|
805
|
-
})
|
|
806
|
-
|
|
807
|
-
link.addEventListener('blur', () => {
|
|
808
|
-
link.style.top = '-40px'
|
|
809
|
-
})
|
|
810
|
-
|
|
811
|
-
return link
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/**
|
|
815
|
-
* Validate ARIA relationships
|
|
816
|
-
*
|
|
817
|
-
* Ensures aria-labelledby, aria-describedby, etc. reference valid IDs
|
|
818
|
-
*/
|
|
819
|
-
export function validateAriaRelationships(element: HTMLElement): string[] {
|
|
820
|
-
const errors: string[] = []
|
|
821
|
-
|
|
822
|
-
const checkAttribute = (attr: string) => {
|
|
823
|
-
const value = element.getAttribute(attr)
|
|
824
|
-
if (value) {
|
|
825
|
-
const ids = value.split(' ')
|
|
826
|
-
for (const id of ids) {
|
|
827
|
-
if (!document.getElementById(id)) {
|
|
828
|
-
errors.push(`${attr} references non-existent ID: ${id}`)
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
checkAttribute('aria-labelledby')
|
|
835
|
-
checkAttribute('aria-describedby')
|
|
836
|
-
checkAttribute('aria-controls')
|
|
837
|
-
checkAttribute('aria-owns')
|
|
838
|
-
checkAttribute('aria-activedescendant')
|
|
839
|
-
|
|
840
|
-
return errors
|
|
841
|
-
}
|