@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.
Files changed (188) hide show
  1. package/dist/{Sidebar-yb115a2w.stx → Sidebar-4hn7vw4n.stx} +18 -0
  2. package/dist/index.js +5 -5
  3. package/package.json +5 -6
  4. package/src/components/CodeBlock.stx +0 -54
  5. package/src/components/Footer.stx +0 -66
  6. package/src/components/Hero.stx +0 -25
  7. package/src/components/Installation.stx +0 -37
  8. package/src/components/index.ts +0 -4
  9. package/src/composables/index.ts +0 -4
  10. package/src/composables/useCopyCode.ts +0 -73
  11. package/src/composables/useDarkMode.ts +0 -100
  12. package/src/composables/useSEO.ts +0 -230
  13. package/src/composables/utilities/index.ts +0 -7
  14. package/src/composables/utilities/useClickOutside.ts +0 -72
  15. package/src/composables/utilities/useFocusTrap.ts +0 -159
  16. package/src/composables/utilities/useKeyboard.ts +0 -124
  17. package/src/composables/utilities/useLocalStorage.ts +0 -132
  18. package/src/composables/utilities/useMediaQuery.ts +0 -118
  19. package/src/index.ts +0 -99
  20. package/src/stx.d.ts +0 -4
  21. package/src/ui/accordion/Accordion.stx +0 -146
  22. package/src/ui/accordion/AccordionItem.stx +0 -37
  23. package/src/ui/accordion/index.ts +0 -47
  24. package/src/ui/audio/Audio.stx +0 -50
  25. package/src/ui/audio/README.md +0 -41
  26. package/src/ui/audio/index.ts +0 -13
  27. package/src/ui/auth/Login.stx +0 -161
  28. package/src/ui/auth/README.md +0 -142
  29. package/src/ui/auth/Signup.stx +0 -159
  30. package/src/ui/auth/TwoFactorChallenge.stx +0 -111
  31. package/src/ui/auth/index.ts +0 -32
  32. package/src/ui/avatar/Avatar.stx +0 -77
  33. package/src/ui/avatar/index.ts +0 -12
  34. package/src/ui/badge/Badge.stx +0 -62
  35. package/src/ui/badge/index.ts +0 -10
  36. package/src/ui/breadcrumb/Breadcrumb.stx +0 -79
  37. package/src/ui/breadcrumb/index.ts +0 -15
  38. package/src/ui/button/Button.stx +0 -74
  39. package/src/ui/button/README.md +0 -80
  40. package/src/ui/button/index.ts +0 -85
  41. package/src/ui/calendar/Calendar.stx +0 -189
  42. package/src/ui/calendar/README.md +0 -254
  43. package/src/ui/calendar/index.ts +0 -14
  44. package/src/ui/card/Card.stx +0 -68
  45. package/src/ui/card/index.ts +0 -13
  46. package/src/ui/checkbox/Checkbox.stx +0 -86
  47. package/src/ui/checkbox/index.ts +0 -16
  48. package/src/ui/combobox/Combobox.stx +0 -96
  49. package/src/ui/combobox/ComboboxButton.stx +0 -25
  50. package/src/ui/combobox/ComboboxInput.stx +0 -32
  51. package/src/ui/combobox/ComboboxOption.stx +0 -49
  52. package/src/ui/combobox/ComboboxOptions.stx +0 -16
  53. package/src/ui/combobox/README.md +0 -150
  54. package/src/ui/combobox/index.ts +0 -40
  55. package/src/ui/command-palette/CommandPalette.stx +0 -81
  56. package/src/ui/command-palette/CommandPaletteItem.stx +0 -27
  57. package/src/ui/command-palette/README.md +0 -42
  58. package/src/ui/command-palette/index.ts +0 -15
  59. package/src/ui/dialog/Dialog.stx +0 -41
  60. package/src/ui/dialog/DialogBackdrop.stx +0 -9
  61. package/src/ui/dialog/DialogDescription.stx +0 -11
  62. package/src/ui/dialog/DialogPanel.stx +0 -16
  63. package/src/ui/dialog/DialogTitle.stx +0 -11
  64. package/src/ui/dialog/README.md +0 -121
  65. package/src/ui/dialog/index.ts +0 -32
  66. package/src/ui/drawer/Drawer.stx +0 -120
  67. package/src/ui/drawer/README.md +0 -90
  68. package/src/ui/drawer/index.ts +0 -9
  69. package/src/ui/dropdown/Dropdown.stx +0 -82
  70. package/src/ui/dropdown/DropdownButton.stx +0 -29
  71. package/src/ui/dropdown/DropdownItem.stx +0 -35
  72. package/src/ui/dropdown/DropdownItems.stx +0 -18
  73. package/src/ui/dropdown/README.md +0 -114
  74. package/src/ui/dropdown/index.ts +0 -29
  75. package/src/ui/form/Form.stx +0 -95
  76. package/src/ui/form/index.ts +0 -67
  77. package/src/ui/heatmap/Heatmap.stx +0 -294
  78. package/src/ui/image/Image.stx +0 -165
  79. package/src/ui/image/README.md +0 -164
  80. package/src/ui/image/index.ts +0 -22
  81. package/src/ui/input/EmailInput.stx +0 -30
  82. package/src/ui/input/NumberInput.stx +0 -126
  83. package/src/ui/input/PasswordInput.stx +0 -143
  84. package/src/ui/input/SearchInput.stx +0 -44
  85. package/src/ui/input/TextInput.stx +0 -132
  86. package/src/ui/input/index.ts +0 -180
  87. package/src/ui/listbox/Listbox.stx +0 -101
  88. package/src/ui/listbox/ListboxButton.stx +0 -28
  89. package/src/ui/listbox/ListboxLabel.stx +0 -11
  90. package/src/ui/listbox/ListboxOption.stx +0 -49
  91. package/src/ui/listbox/ListboxOptions.stx +0 -16
  92. package/src/ui/listbox/README.md +0 -135
  93. package/src/ui/listbox/index.ts +0 -39
  94. package/src/ui/navigator/Navigator.stx +0 -113
  95. package/src/ui/navigator/README.md +0 -352
  96. package/src/ui/navigator/index.ts +0 -20
  97. package/src/ui/notification/Notification.stx +0 -117
  98. package/src/ui/notification/README.md +0 -114
  99. package/src/ui/notification/index.ts +0 -12
  100. package/src/ui/pagination/Pagination.stx +0 -150
  101. package/src/ui/pagination/index.ts +0 -11
  102. package/src/ui/payment/Checkout.stx +0 -31
  103. package/src/ui/payment/DefaultPaymentMethod.stx +0 -62
  104. package/src/ui/payment/PaymentMethods.stx +0 -104
  105. package/src/ui/payment/README.md +0 -345
  106. package/src/ui/payment/SubscriptionCheckout.stx +0 -323
  107. package/src/ui/payment/index.ts +0 -59
  108. package/src/ui/popover/Popover.stx +0 -60
  109. package/src/ui/popover/PopoverButton.stx +0 -29
  110. package/src/ui/popover/PopoverPanel.stx +0 -23
  111. package/src/ui/popover/README.md +0 -91
  112. package/src/ui/popover/index.ts +0 -22
  113. package/src/ui/portal/Portal.stx +0 -57
  114. package/src/ui/portal/index.ts +0 -6
  115. package/src/ui/progress/Progress.stx +0 -131
  116. package/src/ui/progress/index.ts +0 -12
  117. package/src/ui/radio/Radio.stx +0 -77
  118. package/src/ui/radio/index.ts +0 -15
  119. package/src/ui/radio-group/README.md +0 -139
  120. package/src/ui/radio-group/RadioGroup.stx +0 -60
  121. package/src/ui/radio-group/RadioGroupDescription.stx +0 -11
  122. package/src/ui/radio-group/RadioGroupLabel.stx +0 -11
  123. package/src/ui/radio-group/RadioGroupOption.stx +0 -58
  124. package/src/ui/radio-group/index.ts +0 -30
  125. package/src/ui/select/README.md +0 -180
  126. package/src/ui/select/Select.stx +0 -82
  127. package/src/ui/select/index.ts +0 -21
  128. package/src/ui/sidebar/Sidebar.stx +0 -458
  129. package/src/ui/sidebar/SidebarFooter.stx +0 -71
  130. package/src/ui/sidebar/SidebarHeader.stx +0 -103
  131. package/src/ui/sidebar/SidebarItem.stx +0 -86
  132. package/src/ui/sidebar/SidebarSection.stx +0 -83
  133. package/src/ui/sidebar/index.ts +0 -153
  134. package/src/ui/sidebar/themes.ts +0 -240
  135. package/src/ui/skeleton/Skeleton.stx +0 -48
  136. package/src/ui/skeleton/index.ts +0 -11
  137. package/src/ui/spinner/Spinner.stx +0 -109
  138. package/src/ui/spinner/index.ts +0 -9
  139. package/src/ui/stepper/README.md +0 -36
  140. package/src/ui/stepper/Stepper.stx +0 -17
  141. package/src/ui/stepper/StepperStep.stx +0 -57
  142. package/src/ui/stepper/index.ts +0 -19
  143. package/src/ui/storage/README.md +0 -193
  144. package/src/ui/storage/index.ts +0 -347
  145. package/src/ui/switch/README.md +0 -114
  146. package/src/ui/switch/Switch.stx +0 -72
  147. package/src/ui/switch/index.ts +0 -10
  148. package/src/ui/table/README.md +0 -39
  149. package/src/ui/table/Table.stx +0 -14
  150. package/src/ui/table/TableBody.stx +0 -10
  151. package/src/ui/table/TableCell.stx +0 -10
  152. package/src/ui/table/TableHead.stx +0 -10
  153. package/src/ui/table/TableHeader.stx +0 -20
  154. package/src/ui/table/TableRow.stx +0 -11
  155. package/src/ui/table/index.ts +0 -35
  156. package/src/ui/tabs/TabPanel.stx +0 -25
  157. package/src/ui/tabs/Tabs.stx +0 -202
  158. package/src/ui/tabs/index.ts +0 -50
  159. package/src/ui/teleport/Teleport.stx +0 -65
  160. package/src/ui/teleport/index.ts +0 -7
  161. package/src/ui/textarea/Textarea.stx +0 -108
  162. package/src/ui/textarea/index.ts +0 -20
  163. package/src/ui/tooltip/Tooltip.stx +0 -71
  164. package/src/ui/tooltip/index.ts +0 -10
  165. package/src/ui/transition/README.md +0 -30
  166. package/src/ui/transition/Transition.stx +0 -102
  167. package/src/ui/transition/index.ts +0 -25
  168. package/src/ui/video/README.md +0 -47
  169. package/src/ui/video/Video.stx +0 -45
  170. package/src/ui/video/index.ts +0 -16
  171. package/src/ui/virtual-list/VirtualList.stx +0 -67
  172. package/src/ui/virtual-list/index.ts +0 -10
  173. package/src/ui/virtual-table/VirtualTable.stx +0 -103
  174. package/src/ui/virtual-table/index.ts +0 -21
  175. package/src/utils/PROP_VALIDATION.md +0 -458
  176. package/src/utils/THEME_SYSTEM.md +0 -702
  177. package/src/utils/accessibility.ts +0 -841
  178. package/src/utils/animation/easing.ts +0 -368
  179. package/src/utils/animation/helpers.ts +0 -223
  180. package/src/utils/animation/index.ts +0 -5
  181. package/src/utils/animation/keyframes.ts +0 -230
  182. package/src/utils/error-handling.ts +0 -475
  183. package/src/utils/highlighter.ts +0 -93
  184. package/src/utils/i18n.ts +0 -634
  185. package/src/utils/keep-alive.ts +0 -819
  186. package/src/utils/lazy-loading.ts +0 -530
  187. package/src/utils/prop-validation.ts +0 -478
  188. 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
- }