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