@proyecto-viviana/solidaria 0.2.4 → 0.2.8

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 (219) hide show
  1. package/LICENSE +21 -0
  2. package/dist/actiongroup/createActionGroup.d.ts +29 -0
  3. package/dist/actiongroup/createActionGroup.d.ts.map +1 -0
  4. package/dist/actiongroup/index.d.ts +2 -0
  5. package/dist/actiongroup/index.d.ts.map +1 -0
  6. package/dist/autocomplete/createAutocomplete.d.ts +6 -2
  7. package/dist/autocomplete/createAutocomplete.d.ts.map +1 -1
  8. package/dist/breadcrumbs/createBreadcrumbs.d.ts +2 -0
  9. package/dist/breadcrumbs/createBreadcrumbs.d.ts.map +1 -1
  10. package/dist/button/createToggleButtonGroup.d.ts +32 -0
  11. package/dist/button/createToggleButtonGroup.d.ts.map +1 -0
  12. package/dist/button/index.d.ts +2 -0
  13. package/dist/button/index.d.ts.map +1 -1
  14. package/dist/calendar/createCalendarCell.d.ts +2 -0
  15. package/dist/calendar/createCalendarCell.d.ts.map +1 -1
  16. package/dist/calendar/createCalendarGrid.d.ts.map +1 -1
  17. package/dist/calendar/createRangeCalendarCell.d.ts +3 -1
  18. package/dist/calendar/createRangeCalendarCell.d.ts.map +1 -1
  19. package/dist/checkbox/createCheckboxGroup.d.ts +5 -1
  20. package/dist/checkbox/createCheckboxGroup.d.ts.map +1 -1
  21. package/dist/collections/index.d.ts +56 -0
  22. package/dist/collections/index.d.ts.map +1 -0
  23. package/dist/color/createColorArea.d.ts.map +1 -1
  24. package/dist/color/createColorSlider.d.ts.map +1 -1
  25. package/dist/color/createColorWheel.d.ts.map +1 -1
  26. package/dist/combobox/createComboBox.d.ts +6 -0
  27. package/dist/combobox/createComboBox.d.ts.map +1 -1
  28. package/dist/datepicker/createDatePicker.d.ts +6 -0
  29. package/dist/datepicker/createDatePicker.d.ts.map +1 -1
  30. package/dist/datepicker/createDateRangePicker.d.ts +40 -0
  31. package/dist/datepicker/createDateRangePicker.d.ts.map +1 -0
  32. package/dist/datepicker/createDateSegment.d.ts +1 -1
  33. package/dist/datepicker/createDateSegment.d.ts.map +1 -1
  34. package/dist/datepicker/createTimeSegment.d.ts +29 -0
  35. package/dist/datepicker/createTimeSegment.d.ts.map +1 -0
  36. package/dist/datepicker/index.d.ts +2 -0
  37. package/dist/datepicker/index.d.ts.map +1 -1
  38. package/dist/disclosure/createDisclosureGroup.d.ts +2 -1
  39. package/dist/disclosure/createDisclosureGroup.d.ts.map +1 -1
  40. package/dist/dnd/createDrag.d.ts.map +1 -1
  41. package/dist/dnd/createDraggableCollection.d.ts +4 -0
  42. package/dist/dnd/createDraggableCollection.d.ts.map +1 -1
  43. package/dist/dnd/createDraggableItem.d.ts.map +1 -1
  44. package/dist/dnd/createDrop.d.ts.map +1 -1
  45. package/dist/dnd/createDroppableCollection.d.ts +32 -1
  46. package/dist/dnd/createDroppableCollection.d.ts.map +1 -1
  47. package/dist/dnd/createDroppableItem.d.ts.map +1 -1
  48. package/dist/dnd/index.d.ts +1 -1
  49. package/dist/dnd/index.d.ts.map +1 -1
  50. package/dist/grid/createGrid.d.ts.map +1 -1
  51. package/dist/gridlist/createGridList.d.ts.map +1 -1
  52. package/dist/index.d.ts +6 -4
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +4659 -3452
  55. package/dist/index.js.map +1 -7
  56. package/dist/index.ssr.js +4659 -3452
  57. package/dist/index.ssr.js.map +1 -7
  58. package/dist/interactions/createFocus.d.ts.map +1 -1
  59. package/dist/interactions/createFocusWithin.d.ts.map +1 -1
  60. package/dist/link/createLink.d.ts +10 -0
  61. package/dist/link/createLink.d.ts.map +1 -1
  62. package/dist/listbox/createListBox.d.ts +1 -0
  63. package/dist/listbox/createListBox.d.ts.map +1 -1
  64. package/dist/listbox/createOption.d.ts.map +1 -1
  65. package/dist/menu/createMenu.d.ts +1 -0
  66. package/dist/menu/createMenu.d.ts.map +1 -1
  67. package/dist/meter/createMeter.d.ts.map +1 -1
  68. package/dist/numberfield/createNumberField.d.ts +18 -0
  69. package/dist/numberfield/createNumberField.d.ts.map +1 -1
  70. package/dist/overlays/createModal.d.ts +16 -0
  71. package/dist/overlays/createModal.d.ts.map +1 -1
  72. package/dist/overlays/createOverlay.d.ts.map +1 -1
  73. package/dist/overlays/index.d.ts +1 -1
  74. package/dist/overlays/index.d.ts.map +1 -1
  75. package/dist/popover/createOverlayPosition.d.ts.map +1 -1
  76. package/dist/popover/createPopover.d.ts.map +1 -1
  77. package/dist/progress/createProgressBar.d.ts.map +1 -1
  78. package/dist/radio/createRadioGroup.d.ts +2 -2
  79. package/dist/radio/createRadioGroup.d.ts.map +1 -1
  80. package/dist/searchfield/createSearchField.d.ts.map +1 -1
  81. package/dist/select/createHiddenSelect.d.ts.map +1 -1
  82. package/dist/select/createSelect.d.ts.map +1 -1
  83. package/dist/slider/createSlider.d.ts.map +1 -1
  84. package/dist/table/createTable.d.ts.map +1 -1
  85. package/dist/tabs/createTabs.d.ts +1 -1
  86. package/dist/tabs/createTabs.d.ts.map +1 -1
  87. package/dist/tag/createTag.d.ts.map +1 -1
  88. package/dist/tag/createTagGroup.d.ts.map +1 -1
  89. package/dist/toast/createToast.d.ts +4 -0
  90. package/dist/toast/createToast.d.ts.map +1 -1
  91. package/dist/toast/createToastRegion.d.ts.map +1 -1
  92. package/dist/toolbar/createToolbar.d.ts.map +1 -1
  93. package/dist/tooltip/createTooltipTrigger.d.ts.map +1 -1
  94. package/dist/tree/createTree.d.ts.map +1 -1
  95. package/dist/tree/createTreeItem.d.ts.map +1 -1
  96. package/dist/tree/types.d.ts +4 -0
  97. package/dist/tree/types.d.ts.map +1 -1
  98. package/dist/utils/env.d.ts +1 -1
  99. package/dist/utils/env.d.ts.map +1 -1
  100. package/dist/utils/platform.d.ts.map +1 -1
  101. package/dist/visually-hidden/createVisuallyHidden.d.ts.map +1 -1
  102. package/package.json +8 -6
  103. package/src/actiongroup/createActionGroup.ts +324 -0
  104. package/src/actiongroup/index.ts +8 -0
  105. package/src/autocomplete/createAutocomplete.ts +32 -9
  106. package/src/breadcrumbs/createBreadcrumbs.ts +10 -15
  107. package/src/button/createButton.ts +1 -1
  108. package/src/button/createToggleButtonGroup.ts +128 -0
  109. package/src/button/index.ts +9 -0
  110. package/src/calendar/createCalendarCell.ts +6 -4
  111. package/src/calendar/createCalendarGrid.ts +27 -18
  112. package/src/calendar/createRangeCalendarCell.ts +26 -9
  113. package/src/checkbox/createCheckboxGroup.ts +21 -4
  114. package/src/collections/index.ts +242 -0
  115. package/src/color/createColorArea.ts +380 -314
  116. package/src/color/createColorField.ts +137 -137
  117. package/src/color/createColorSlider.ts +286 -197
  118. package/src/color/createColorSwatch.ts +40 -40
  119. package/src/color/createColorWheel.ts +218 -208
  120. package/src/color/index.ts +24 -24
  121. package/src/color/types.ts +116 -116
  122. package/src/combobox/createComboBox.ts +670 -647
  123. package/src/combobox/index.ts +6 -6
  124. package/src/datepicker/createDatePicker.ts +54 -16
  125. package/src/datepicker/createDateRangePicker.ts +246 -0
  126. package/src/datepicker/createDateSegment.ts +185 -31
  127. package/src/datepicker/createTimeSegment.ts +370 -0
  128. package/src/datepicker/index.ts +14 -0
  129. package/src/dialog/createDialog.ts +120 -120
  130. package/src/dialog/index.ts +2 -2
  131. package/src/dialog/types.ts +19 -19
  132. package/src/disclosure/createDisclosureGroup.ts +5 -2
  133. package/src/dnd/createDrag.ts +224 -209
  134. package/src/dnd/createDraggableCollection.ts +96 -63
  135. package/src/dnd/createDraggableItem.ts +259 -243
  136. package/src/dnd/createDrop.ts +322 -321
  137. package/src/dnd/createDroppableCollection.ts +682 -293
  138. package/src/dnd/createDroppableItem.ts +215 -213
  139. package/src/dnd/index.ts +55 -47
  140. package/src/dnd/types.ts +89 -89
  141. package/src/dnd/utils.ts +294 -294
  142. package/src/focus/createAutoFocus.ts +321 -321
  143. package/src/focus/createFocusRestore.ts +313 -313
  144. package/src/focus/createVirtualFocus.ts +396 -396
  145. package/src/form/createFormValidation.ts +224 -224
  146. package/src/form/index.ts +11 -11
  147. package/src/grid/createGrid.ts +3 -1
  148. package/src/gridlist/createGridList.ts +16 -0
  149. package/src/gridlist/createGridListItem.ts +1 -1
  150. package/src/i18n/NumberFormatter.ts +266 -266
  151. package/src/i18n/createCollator.ts +79 -79
  152. package/src/i18n/createDateFormatter.ts +83 -83
  153. package/src/i18n/createFilter.ts +131 -131
  154. package/src/i18n/createNumberFormatter.ts +52 -52
  155. package/src/i18n/index.ts +40 -40
  156. package/src/i18n/locale.tsx +188 -188
  157. package/src/i18n/utils.ts +99 -99
  158. package/src/index.ts +51 -0
  159. package/src/interactions/createFocus.ts +6 -5
  160. package/src/interactions/createFocusWithin.ts +6 -5
  161. package/src/interactions/createLongPress.ts +174 -174
  162. package/src/interactions/createMove.ts +289 -289
  163. package/src/interactions/createPress.ts +5 -5
  164. package/src/landmark/createLandmark.ts +377 -377
  165. package/src/landmark/index.ts +8 -8
  166. package/src/link/createLink.ts +23 -8
  167. package/src/listbox/createListBox.ts +308 -269
  168. package/src/listbox/createOption.ts +162 -151
  169. package/src/listbox/index.ts +12 -12
  170. package/src/live-announcer/announce.ts +322 -322
  171. package/src/live-announcer/index.ts +9 -9
  172. package/src/menu/createMenu.ts +405 -396
  173. package/src/menu/createMenuItem.ts +149 -149
  174. package/src/menu/createMenuTrigger.ts +88 -88
  175. package/src/menu/index.ts +18 -18
  176. package/src/meter/createMeter.ts +1 -6
  177. package/src/numberfield/createNumberField.ts +311 -268
  178. package/src/numberfield/index.ts +5 -5
  179. package/src/overlays/ariaHideOutside.ts +219 -219
  180. package/src/overlays/createInteractOutside.ts +149 -149
  181. package/src/overlays/createModal.tsx +238 -202
  182. package/src/overlays/createOverlay.ts +165 -155
  183. package/src/overlays/createOverlayTrigger.ts +85 -85
  184. package/src/overlays/createPreventScroll.ts +266 -266
  185. package/src/overlays/index.ts +48 -44
  186. package/src/popover/calculatePosition.ts +6 -6
  187. package/src/popover/createOverlayPosition.ts +7 -4
  188. package/src/popover/createPopover.ts +21 -7
  189. package/src/progress/createProgressBar.ts +6 -1
  190. package/src/radio/createRadioGroup.ts +88 -14
  191. package/src/searchfield/createSearchField.ts +241 -186
  192. package/src/searchfield/index.ts +2 -2
  193. package/src/select/createHiddenSelect.tsx +263 -236
  194. package/src/select/createSelect.ts +373 -395
  195. package/src/select/index.ts +14 -14
  196. package/src/slider/createSlider.ts +364 -349
  197. package/src/slider/index.ts +2 -2
  198. package/src/ssr/index.tsx +370 -370
  199. package/src/table/createTable.ts +3 -1
  200. package/src/table/createTableColumnHeader.ts +1 -1
  201. package/src/table/createTableRow.ts +1 -1
  202. package/src/tabs/createTabs.ts +80 -51
  203. package/src/tag/createTag.ts +135 -6
  204. package/src/tag/createTagGroup.ts +7 -2
  205. package/src/toast/createToast.ts +8 -2
  206. package/src/toast/createToastRegion.ts +0 -1
  207. package/src/toolbar/createToolbar.ts +75 -1
  208. package/src/tooltip/createTooltip.ts +79 -79
  209. package/src/tooltip/createTooltipTrigger.ts +226 -222
  210. package/src/tooltip/index.ts +6 -6
  211. package/src/tree/createTree.ts +261 -246
  212. package/src/tree/createTreeItem.ts +282 -233
  213. package/src/tree/createTreeSelectionCheckbox.ts +68 -68
  214. package/src/tree/index.ts +16 -16
  215. package/src/tree/types.ts +91 -87
  216. package/src/utils/env.ts +55 -54
  217. package/src/utils/platform.ts +16 -6
  218. package/src/visually-hidden/createVisuallyHidden.ts +139 -124
  219. package/src/visually-hidden/index.ts +6 -6
@@ -1,647 +1,670 @@
1
- /**
2
- * Provides the behavior and accessibility implementation for a combobox component.
3
- * A combobox combines a text input with a listbox, allowing users to filter a list of options.
4
- * Based on @react-aria/combobox useComboBox.
5
- */
6
-
7
- import { type JSX, type Accessor, createEffect, onCleanup } from 'solid-js';
8
- import { isServer } from 'solid-js/web';
9
- import { createPress } from '../interactions/createPress';
10
- import { createFocusRing } from '../interactions/createFocusRing';
11
- import { createLabel } from '../label/createLabel';
12
- import { filterDOMProps } from '../utils/filterDOMProps';
13
- import { mergeProps } from '../utils/mergeProps';
14
- import { createId } from '../ssr';
15
- import { access, type MaybeAccessor } from '../utils/reactivity';
16
- import { isAppleDevice } from '../utils/platform';
17
- import { openLink } from '../utils/dom';
18
- import { ariaHideOutside } from '../overlays/ariaHideOutside';
19
- import { announce } from '../live-announcer';
20
- import { createStringFormatter } from '../i18n';
21
- import { comboBoxIntlStrings } from './intl';
22
- import { isDevEnv } from '../utils/env';
23
- import type { ComboBoxState, CollectionNode, Key } from '@proyecto-viviana/solid-stately';
24
-
25
- /**
26
- * Helper to count items in a collection
27
- */
28
- function getItemCount<T>(collection: { getKeys(): Iterable<Key> }): number {
29
- let count = 0;
30
- for (const _ of collection.getKeys()) {
31
- count++;
32
- }
33
- return count;
34
- }
35
-
36
- export interface AriaComboBoxProps {
37
- /** An ID for the combobox. */
38
- id?: string;
39
- /** Whether the combobox is disabled. */
40
- isDisabled?: boolean;
41
- /** Whether the combobox is required. */
42
- isRequired?: boolean;
43
- /** Whether the combobox is read-only. */
44
- isReadOnly?: boolean;
45
- /** The label for the combobox. */
46
- label?: JSX.Element;
47
- /** An accessible label for the combobox when no visible label is provided. */
48
- 'aria-label'?: string;
49
- /** The ID of an element that labels the combobox. */
50
- 'aria-labelledby'?: string;
51
- /** The ID of an element that describes the combobox. */
52
- 'aria-describedby'?: string;
53
- /** Placeholder text for the input when no value is entered. */
54
- placeholder?: string;
55
- /** Whether the combobox should be auto-focused. */
56
- autoFocus?: boolean;
57
- /** Handler called when focus moves to the combobox input. */
58
- onFocus?: (e: FocusEvent) => void;
59
- /** Handler called when focus moves away from the combobox input. */
60
- onBlur?: (e: FocusEvent) => void;
61
- /** Handler called when the focus state changes. */
62
- onFocusChange?: (isFocused: boolean) => void;
63
- /** The name of the combobox, used when submitting an HTML form. */
64
- name?: string;
65
- /**
66
- * Describes the type of autocomplete functionality the input should provide.
67
- * @default 'list'
68
- */
69
- autoComplete?: 'list' | 'none' | 'inline' | 'both';
70
- /** Whether focus should wrap from the last item to the first. */
71
- shouldFocusWrap?: boolean;
72
- }
73
-
74
- export interface ComboBoxAria<T> {
75
- /** Props for the label element. */
76
- labelProps: JSX.HTMLAttributes<HTMLElement>;
77
- /** Props for the input element. */
78
- inputProps: JSX.InputHTMLAttributes<HTMLInputElement>;
79
- /** Props for the trigger button element. */
80
- buttonProps: JSX.HTMLAttributes<HTMLElement>;
81
- /** Props for the listbox popup. */
82
- listBoxProps: JSX.HTMLAttributes<HTMLElement>;
83
- /** Props for the description element, if any. */
84
- descriptionProps: JSX.HTMLAttributes<HTMLElement>;
85
- /** Props for the error message element, if any. */
86
- errorMessageProps: JSX.HTMLAttributes<HTMLElement>;
87
- /** Whether the input is currently focused. */
88
- isFocused: Accessor<boolean>;
89
- /** Whether the input has keyboard focus. */
90
- isFocusVisible: Accessor<boolean>;
91
- /** Whether the listbox is currently open. */
92
- isOpen: Accessor<boolean>;
93
- /** The currently selected item. */
94
- selectedItem: Accessor<CollectionNode<T> | null>;
95
- }
96
-
97
- // Shared data between combobox and options
98
- const comboBoxData = new WeakMap<object, ComboBoxData>();
99
-
100
- interface ComboBoxData {
101
- id: string;
102
- }
103
-
104
- export function getComboBoxData(state: ComboBoxState<unknown>): ComboBoxData | undefined {
105
- return comboBoxData.get(state);
106
- }
107
-
108
- /**
109
- * Provides the behavior and accessibility implementation for a combobox component.
110
- */
111
- export function createComboBox<T>(
112
- props: MaybeAccessor<AriaComboBoxProps>,
113
- state: ComboBoxState<T>,
114
- inputRef: () => HTMLInputElement | null,
115
- buttonRef?: () => HTMLElement | null,
116
- listBoxRef?: () => HTMLElement | null
117
- ): ComboBoxAria<T> {
118
- const getProps = () => access(props);
119
- const id = createId(getProps().id);
120
-
121
- // Development-time warning for missing accessibility labels
122
- if (isDevEnv()) {
123
- const p = getProps();
124
- if (!p.label && !p['aria-label'] && !p['aria-labelledby']) {
125
- console.warn(
126
- '[solidaria] A ComboBox requires a label, aria-label, or aria-labelledby attribute for accessibility.'
127
- );
128
- }
129
- }
130
-
131
- // Track if a pointerdown happened inside the listbox to prevent blur from closing
132
- let isPointerDownInsideListBox = false;
133
-
134
- // Generate IDs for associated elements
135
- const inputId = `${id}-input`;
136
- const buttonId = `${id}-button`;
137
- const listBoxId = `${id}-listbox`;
138
- const descriptionId = `${id}-description`;
139
- const errorMessageId = `${id}-error`;
140
-
141
- // Set up global pointerdown listener to track clicks inside listbox
142
- // This is needed because the option's createPress stops propagation
143
- createEffect(() => {
144
- if (typeof document === 'undefined') return;
145
-
146
- const handleGlobalPointerDown = (e: PointerEvent) => {
147
- const target = e.target as HTMLElement;
148
- // Check if the click is inside the listbox
149
- if (target.closest(`[id="${listBoxId}"]`)) {
150
- isPointerDownInsideListBox = true;
151
- }
152
- };
153
-
154
- document.addEventListener('pointerdown', handleGlobalPointerDown, true);
155
-
156
- onCleanup(() => {
157
- document.removeEventListener('pointerdown', handleGlobalPointerDown, true);
158
- });
159
- });
160
-
161
- // Filter DOM props
162
- const domProps = () =>
163
- filterDOMProps(getProps() as unknown as Record<string, unknown>, { labelable: true });
164
-
165
- // Share data with child options
166
- createEffect(() => {
167
- comboBoxData.set(state, { id });
168
-
169
- onCleanup(() => {
170
- comboBoxData.delete(state);
171
- });
172
- });
173
-
174
- // Label handling
175
- const { labelProps, fieldProps } = createLabel({
176
- get id() {
177
- return inputId;
178
- },
179
- get label() {
180
- return getProps().label;
181
- },
182
- get 'aria-label'() {
183
- return getProps()['aria-label'];
184
- },
185
- get 'aria-labelledby'() {
186
- return getProps()['aria-labelledby'];
187
- },
188
- labelElementType: 'label',
189
- });
190
-
191
- // Focus ring for keyboard focus styling
192
- const { isFocusVisible, focusProps } = createFocusRing({
193
- get autoFocus() {
194
- return getProps().autoFocus;
195
- },
196
- });
197
-
198
- // Track focus state from state
199
- const isFocused = state.isFocused;
200
-
201
- // String formatter for VoiceOver announcements
202
- // Only create on client side
203
- const stringFormatter = !isServer ? createStringFormatter(comboBoxIntlStrings) : null;
204
-
205
- // Track previous values for announcements
206
- let lastFocusedKey: Key | null = null;
207
- let lastSelectedKey: Key | null = null;
208
- let lastOptionCount = 0;
209
- let lastIsOpen = false;
210
-
211
- // VoiceOver has issues with announcing aria-activedescendant properly on change
212
- // (especially on iOS). We use a live region announcer to announce focus changes
213
- // manually. This matches React Aria's behavior.
214
- createEffect(() => {
215
- if (isServer || !stringFormatter) return;
216
-
217
- const focusedKey = state.focusedKey();
218
- const isOpen = state.isOpen();
219
- const collection = state.collection();
220
-
221
- // Get the focused item
222
- const focusedItem = focusedKey != null && isOpen
223
- ? collection.getItem(focusedKey)
224
- : null;
225
-
226
- // Announce focus changes on Apple devices
227
- if (isAppleDevice() && focusedItem != null && focusedKey !== lastFocusedKey) {
228
- const isSelected = state.selectedKey() === focusedKey;
229
- const optionText = focusedItem.textValue || '';
230
-
231
- // For now, we don't support sections, so isGroupChange is always false
232
- const announcement = stringFormatter().format('focusAnnouncement', {
233
- isGroupChange: false,
234
- groupTitle: '',
235
- groupCount: 0,
236
- optionText,
237
- isSelected,
238
- });
239
-
240
- announce(announcement, 'polite');
241
- }
242
-
243
- lastFocusedKey = focusedKey;
244
- });
245
-
246
- // Announce the number of available suggestions when it changes
247
- createEffect(() => {
248
- if (isServer || !stringFormatter) return;
249
-
250
- const isOpen = state.isOpen();
251
- const collection = state.collection();
252
- const optionCount = getItemCount(collection);
253
- const focusedKey = state.focusedKey();
254
-
255
- // Only announce the number of options available when the menu opens if there is no
256
- // focused item, otherwise screen readers will typically read e.g. "1 of 6".
257
- // The exception is VoiceOver since this isn't included in the message above.
258
- const didOpenWithoutFocusedItem =
259
- isOpen !== lastIsOpen &&
260
- (focusedKey == null || isAppleDevice());
261
-
262
- if (isOpen && (didOpenWithoutFocusedItem || optionCount !== lastOptionCount)) {
263
- const announcement = stringFormatter().format('countAnnouncement', { optionCount });
264
- announce(announcement, 'polite');
265
- }
266
-
267
- lastOptionCount = optionCount;
268
- lastIsOpen = isOpen;
269
- });
270
-
271
- // Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically.
272
- createEffect(() => {
273
- if (isServer || !stringFormatter) return;
274
-
275
- const selectedKey = state.selectedKey();
276
- const selectedItem = state.selectedItem();
277
-
278
- if (isAppleDevice() && state.isFocused() && selectedItem && selectedKey !== lastSelectedKey) {
279
- const optionText = selectedItem.textValue || '';
280
- const announcement = stringFormatter().format('selectedAnnouncement', { optionText });
281
- announce(announcement, 'polite');
282
- }
283
-
284
- lastSelectedKey = selectedKey;
285
- });
286
-
287
- // Hide other page content from screen readers when the listbox is open.
288
- // This requires both the input and listbox refs to be available.
289
- // Note: This feature is important for screen reader accessibility but
290
- // only works when a popoverRef/listBoxRef is provided.
291
- createEffect(() => {
292
- if (isServer) return;
293
-
294
- const isOpen = state.isOpen();
295
- const inputEl = inputRef();
296
- const listBoxEl = listBoxRef?.();
297
-
298
- // Only apply ariaHideOutside if we have both elements available
299
- // This ensures the listbox won't be accidentally hidden
300
- if (isOpen && inputEl && listBoxEl) {
301
- const cleanup = ariaHideOutside([inputEl, listBoxEl]);
302
- onCleanup(cleanup);
303
- }
304
- });
305
-
306
- // Handle press on button trigger
307
- const { pressProps } = createPress({
308
- get isDisabled() {
309
- return getProps().isDisabled ?? state.isDisabled;
310
- },
311
- onPress() {
312
- state.toggle(null, 'manual');
313
- // Focus input after toggling
314
- inputRef()?.focus();
315
- },
316
- });
317
-
318
- // Handle input change
319
- const onInputChange: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
320
- const target = e.target as HTMLInputElement;
321
- state.setInputValue(target.value);
322
- };
323
-
324
- // Keyboard navigation for input
325
- const onInputKeyDown: JSX.EventHandler<HTMLInputElement, KeyboardEvent> = (e) => {
326
- const p = getProps();
327
- if (p.isDisabled || p.isReadOnly) return;
328
-
329
- const collection = state.collection();
330
- const focusedKey = state.focusedKey();
331
- const shouldWrap = p.shouldFocusWrap ?? false;
332
-
333
- switch (e.key) {
334
- case 'Enter':
335
- if (state.isOpen() && focusedKey != null) {
336
- e.preventDefault();
337
-
338
- // Check if the focused item is a link
339
- // Link href can be in props (for components) or value (for dynamic items)
340
- const collectionItem = collection.getItem(focusedKey);
341
- const itemHref = collectionItem?.props?.href ?? (collectionItem?.value as Record<string, unknown> | null)?.href;
342
- if (itemHref) {
343
- // Find the actual anchor element in the DOM and trigger navigation
344
- const listBox = listBoxRef?.();
345
- if (listBox) {
346
- const item = listBox.querySelector(
347
- `[data-key="${CSS.escape(String(focusedKey))}"]`
348
- );
349
- if (item instanceof HTMLAnchorElement) {
350
- openLink(item, e);
351
- }
352
- }
353
- state.close();
354
- } else {
355
- state.commit();
356
- }
357
- }
358
- break;
359
-
360
- case 'Escape':
361
- if (state.isOpen()) {
362
- e.preventDefault();
363
- e.stopPropagation();
364
- state.revert();
365
- }
366
- break;
367
-
368
- case 'ArrowDown':
369
- e.preventDefault();
370
- if (!state.isOpen()) {
371
- state.open('first', 'manual');
372
- } else {
373
- // Move to next item
374
- if (focusedKey == null) {
375
- const firstKey = collection.getFirstKey();
376
- if (firstKey != null) {
377
- state.setFocusedKey(firstKey);
378
- }
379
- } else {
380
- let nextKey = collection.getKeyAfter(focusedKey);
381
- // Skip disabled keys
382
- while (nextKey != null && state.isKeyDisabled(nextKey)) {
383
- nextKey = collection.getKeyAfter(nextKey);
384
- }
385
- if (nextKey != null) {
386
- state.setFocusedKey(nextKey);
387
- } else if (shouldWrap) {
388
- // Wrap to first
389
- let firstKey = collection.getFirstKey();
390
- while (firstKey != null && state.isKeyDisabled(firstKey)) {
391
- firstKey = collection.getKeyAfter(firstKey);
392
- }
393
- if (firstKey != null) {
394
- state.setFocusedKey(firstKey);
395
- }
396
- }
397
- }
398
- }
399
- break;
400
-
401
- case 'ArrowUp':
402
- e.preventDefault();
403
- if (!state.isOpen()) {
404
- state.open('last', 'manual');
405
- } else {
406
- // Move to previous item
407
- if (focusedKey == null) {
408
- const lastKey = collection.getLastKey();
409
- if (lastKey != null) {
410
- state.setFocusedKey(lastKey);
411
- }
412
- } else {
413
- let prevKey = collection.getKeyBefore(focusedKey);
414
- // Skip disabled keys
415
- while (prevKey != null && state.isKeyDisabled(prevKey)) {
416
- prevKey = collection.getKeyBefore(prevKey);
417
- }
418
- if (prevKey != null) {
419
- state.setFocusedKey(prevKey);
420
- } else if (shouldWrap) {
421
- // Wrap to last
422
- let lastKey = collection.getLastKey();
423
- while (lastKey != null && state.isKeyDisabled(lastKey)) {
424
- lastKey = collection.getKeyBefore(lastKey);
425
- }
426
- if (lastKey != null) {
427
- state.setFocusedKey(lastKey);
428
- }
429
- }
430
- }
431
- }
432
- break;
433
-
434
- case 'Home':
435
- if (state.isOpen()) {
436
- e.preventDefault();
437
- let firstKey = collection.getFirstKey();
438
- while (firstKey != null && state.isKeyDisabled(firstKey)) {
439
- firstKey = collection.getKeyAfter(firstKey);
440
- }
441
- if (firstKey != null) {
442
- state.setFocusedKey(firstKey);
443
- }
444
- }
445
- break;
446
-
447
- case 'End':
448
- if (state.isOpen()) {
449
- e.preventDefault();
450
- let lastKey = collection.getLastKey();
451
- while (lastKey != null && state.isKeyDisabled(lastKey)) {
452
- lastKey = collection.getKeyBefore(lastKey);
453
- }
454
- if (lastKey != null) {
455
- state.setFocusedKey(lastKey);
456
- }
457
- }
458
- break;
459
-
460
- case 'Tab':
461
- // Commit on Tab if menu is open
462
- if (state.isOpen() && focusedKey != null) {
463
- state.commit();
464
- }
465
- break;
466
- }
467
- };
468
-
469
- // Handle focus events
470
- const handleFocus = (e: FocusEvent) => {
471
- state.setFocused(true);
472
- getProps().onFocus?.(e);
473
- getProps().onFocusChange?.(true);
474
- };
475
-
476
- // Track the last touch event time for iPad VoiceOver double-tap debouncing
477
- let lastEventTime = 0;
478
-
479
- const handleBlur = (e: FocusEvent) => {
480
- // Use synchronous ref checks instead of requestAnimationFrame
481
- // This matches React Aria's implementation and is more reliable
482
- const relatedTarget = e.relatedTarget as HTMLElement | null;
483
- const button = buttonRef?.();
484
- const listBox = listBoxRef?.();
485
-
486
- // Don't blur if focus is moving to the button
487
- const blurFromButton = button && button === relatedTarget;
488
-
489
- // Don't blur if focus is moving into the listbox/popover
490
- const blurIntoPopover = listBox?.contains(relatedTarget);
491
-
492
- if (blurFromButton || blurIntoPopover) {
493
- return;
494
- }
495
-
496
- // If a pointerdown happened inside the listbox, don't close
497
- // This handles the case when clicking on a non-focusable option
498
- if (isPointerDownInsideListBox) {
499
- isPointerDownInsideListBox = false;
500
- return;
501
- }
502
-
503
- // Call user's onBlur handler
504
- getProps().onBlur?.(e);
505
-
506
- state.setFocused(false);
507
- getProps().onFocusChange?.(false);
508
- };
509
-
510
- // Handle touch events for iPad VoiceOver
511
- // VoiceOver on iOS fires a touchend at the center of the element on double-tap.
512
- // We detect this and toggle the combobox manually to avoid issues with focus management.
513
- const handleTouchEnd = (e: TouchEvent) => {
514
- const p = getProps();
515
- const isDisabled = p.isDisabled ?? state.isDisabled;
516
- const isReadOnly = p.isReadOnly ?? state.isReadOnly;
517
-
518
- if (isDisabled || isReadOnly) {
519
- return;
520
- }
521
-
522
- // Debounce rapid consecutive touchend events (< 500ms)
523
- // This handles VoiceOver's double-tap behavior
524
- if (e.timeStamp - lastEventTime < 500) {
525
- e.preventDefault();
526
- inputRef()?.focus();
527
- return;
528
- }
529
-
530
- // Detect VoiceOver virtual click - it fires at the exact center of the element
531
- const rect = (e.target as Element).getBoundingClientRect();
532
- const touch = e.changedTouches[0];
533
- const centerX = Math.ceil(rect.left + 0.5 * rect.width);
534
- const centerY = Math.ceil(rect.top + 0.5 * rect.height);
535
-
536
- if (touch.clientX === centerX && touch.clientY === centerY) {
537
- e.preventDefault();
538
- inputRef()?.focus();
539
- state.toggle(null, 'manual');
540
- lastEventTime = e.timeStamp;
541
- }
542
- };
543
-
544
- return {
545
- get labelProps() {
546
- return labelProps as JSX.HTMLAttributes<HTMLElement>;
547
- },
548
- get inputProps() {
549
- const p = getProps();
550
- const isOpen = state.isOpen();
551
- const isDisabled = p.isDisabled ?? state.isDisabled;
552
- const isReadOnly = p.isReadOnly ?? state.isReadOnly;
553
- const focusedKey = state.focusedKey();
554
-
555
- return mergeProps(
556
- domProps(),
557
- focusProps as Record<string, unknown>,
558
- fieldProps as Record<string, unknown>,
559
- {
560
- id: inputId,
561
- type: 'text',
562
- role: 'combobox',
563
- get value() {
564
- return state.inputValue();
565
- },
566
- tabIndex: isDisabled ? undefined : 0,
567
- disabled: isDisabled || undefined,
568
- readOnly: isReadOnly || undefined,
569
- placeholder: p.placeholder,
570
- autoComplete: 'off',
571
- 'aria-autocomplete': p.autoComplete ?? 'list',
572
- 'aria-haspopup': 'listbox',
573
- 'aria-expanded': isOpen,
574
- 'aria-controls': isOpen ? listBoxId : undefined,
575
- 'aria-activedescendant': isOpen && focusedKey != null
576
- ? `${listBoxId}-option-${focusedKey}`
577
- : undefined,
578
- 'aria-disabled': isDisabled || undefined,
579
- 'aria-required': p.isRequired || undefined,
580
- 'aria-describedby': p['aria-describedby'] || undefined,
581
- name: p.name,
582
- onInput: onInputChange,
583
- onKeyDown: onInputKeyDown,
584
- onFocus: handleFocus,
585
- onBlur: handleBlur,
586
- onTouchEnd: handleTouchEnd,
587
- 'data-open': isOpen || undefined,
588
- 'data-disabled': isDisabled || undefined,
589
- 'data-readonly': isReadOnly || undefined,
590
- 'data-focus-visible': isFocusVisible() || undefined,
591
- } as Record<string, unknown>
592
- ) as JSX.InputHTMLAttributes<HTMLInputElement>;
593
- },
594
- get buttonProps() {
595
- const p = getProps();
596
- const isOpen = state.isOpen();
597
- const isDisabled = p.isDisabled ?? state.isDisabled;
598
-
599
- return mergeProps(
600
- pressProps as Record<string, unknown>,
601
- {
602
- id: buttonId,
603
- type: 'button',
604
- tabIndex: -1,
605
- 'aria-haspopup': 'listbox',
606
- 'aria-expanded': isOpen,
607
- 'aria-controls': isOpen ? listBoxId : undefined,
608
- 'aria-disabled': isDisabled || undefined,
609
- 'aria-label': stringFormatter?.().format('buttonLabel') ?? 'Show suggestions',
610
- 'data-open': isOpen || undefined,
611
- 'data-disabled': isDisabled || undefined,
612
- } as Record<string, unknown>
613
- ) as JSX.HTMLAttributes<HTMLElement>;
614
- },
615
- get listBoxProps() {
616
- return {
617
- id: listBoxId,
618
- role: 'listbox',
619
- 'aria-labelledby': inputId,
620
- tabIndex: -1,
621
- // Track pointerdown inside listbox to prevent blur from closing
622
- // Use capture phase because createPress calls stopPropagation on pointerdown
623
- onPointerDownCapture: () => {
624
- isPointerDownInsideListBox = true;
625
- },
626
- onMouseDownCapture: () => {
627
- // Fallback for environments without PointerEvent
628
- isPointerDownInsideListBox = true;
629
- },
630
- } as JSX.HTMLAttributes<HTMLElement>;
631
- },
632
- get descriptionProps() {
633
- return {
634
- id: descriptionId,
635
- } as JSX.HTMLAttributes<HTMLElement>;
636
- },
637
- get errorMessageProps() {
638
- return {
639
- id: errorMessageId,
640
- } as JSX.HTMLAttributes<HTMLElement>;
641
- },
642
- isFocused,
643
- isFocusVisible: () => isFocused() && isFocusVisible(),
644
- isOpen: state.isOpen,
645
- selectedItem: state.selectedItem,
646
- };
647
- }
1
+ /**
2
+ * Provides the behavior and accessibility implementation for a combobox component.
3
+ * A combobox combines a text input with a listbox, allowing users to filter a list of options.
4
+ * Based on @react-aria/combobox useComboBox.
5
+ */
6
+
7
+ import { type JSX, type Accessor, createEffect, onCleanup } from 'solid-js';
8
+ import { isServer } from 'solid-js/web';
9
+ import { createPress } from '../interactions/createPress';
10
+ import { createFocusRing } from '../interactions/createFocusRing';
11
+ import { createLabel } from '../label/createLabel';
12
+ import { filterDOMProps } from '../utils/filterDOMProps';
13
+ import { mergeProps } from '../utils/mergeProps';
14
+ import { createId } from '../ssr';
15
+ import { access, type MaybeAccessor } from '../utils/reactivity';
16
+ import { isAppleDevice } from '../utils/platform';
17
+ import { openLink } from '../utils/dom';
18
+ import { ariaHideOutside } from '../overlays/ariaHideOutside';
19
+ import { announce } from '../live-announcer';
20
+ import { createStringFormatter } from '../i18n';
21
+ import { comboBoxIntlStrings } from './intl';
22
+ import { isDevEnv } from '../utils/env';
23
+ import type { ComboBoxState, CollectionNode, Key } from '@proyecto-viviana/solid-stately';
24
+
25
+ /**
26
+ * Helper to count items in a collection
27
+ */
28
+ function getItemCount<T>(collection: { getKeys(): Iterable<Key> }): number {
29
+ let count = 0;
30
+ for (const _ of collection.getKeys()) {
31
+ count++;
32
+ }
33
+ return count;
34
+ }
35
+
36
+ export interface AriaComboBoxProps {
37
+ /** An ID for the combobox. */
38
+ id?: string;
39
+ /** Whether the combobox is disabled. */
40
+ isDisabled?: boolean;
41
+ /** Whether the combobox is required. */
42
+ isRequired?: boolean;
43
+ /** Whether the combobox is read-only. */
44
+ isReadOnly?: boolean;
45
+ /** The label for the combobox. */
46
+ label?: JSX.Element;
47
+ /** An accessible label for the combobox when no visible label is provided. */
48
+ 'aria-label'?: string;
49
+ /** The ID of an element that labels the combobox. */
50
+ 'aria-labelledby'?: string;
51
+ /** The ID of an element that describes the combobox. */
52
+ 'aria-describedby'?: string;
53
+ /** Description text for assistive technology and form help. */
54
+ description?: string;
55
+ /** Error message text for assistive technology and validation feedback. */
56
+ errorMessage?: string;
57
+ /** Whether the current value is invalid. */
58
+ isInvalid?: boolean;
59
+ /** Placeholder text for the input when no value is entered. */
60
+ placeholder?: string;
61
+ /** Whether the combobox should be auto-focused. */
62
+ autoFocus?: boolean;
63
+ /** Handler called when focus moves to the combobox input. */
64
+ onFocus?: (e: FocusEvent) => void;
65
+ /** Handler called when focus moves away from the combobox input. */
66
+ onBlur?: (e: FocusEvent) => void;
67
+ /** Handler called when the focus state changes. */
68
+ onFocusChange?: (isFocused: boolean) => void;
69
+ /** The name of the combobox, used when submitting an HTML form. */
70
+ name?: string;
71
+ /**
72
+ * Describes the type of autocomplete functionality the input should provide.
73
+ * @default 'list'
74
+ */
75
+ autoComplete?: 'list' | 'none' | 'inline' | 'both';
76
+ /** Whether focus should wrap from the last item to the first. */
77
+ shouldFocusWrap?: boolean;
78
+ }
79
+
80
+ export interface ComboBoxAria<T> {
81
+ /** Props for the label element. */
82
+ labelProps: JSX.HTMLAttributes<HTMLElement>;
83
+ /** Props for the input element. */
84
+ inputProps: JSX.InputHTMLAttributes<HTMLInputElement>;
85
+ /** Props for the trigger button element. */
86
+ buttonProps: JSX.HTMLAttributes<HTMLElement>;
87
+ /** Props for the listbox popup. */
88
+ listBoxProps: JSX.HTMLAttributes<HTMLElement>;
89
+ /** Props for the description element, if any. */
90
+ descriptionProps: JSX.HTMLAttributes<HTMLElement>;
91
+ /** Props for the error message element, if any. */
92
+ errorMessageProps: JSX.HTMLAttributes<HTMLElement>;
93
+ /** Whether the input is currently focused. */
94
+ isFocused: Accessor<boolean>;
95
+ /** Whether the input has keyboard focus. */
96
+ isFocusVisible: Accessor<boolean>;
97
+ /** Whether the listbox is currently open. */
98
+ isOpen: Accessor<boolean>;
99
+ /** The currently selected item. */
100
+ selectedItem: Accessor<CollectionNode<T> | null>;
101
+ }
102
+
103
+ // Shared data between combobox and options
104
+ const comboBoxData = new WeakMap<object, ComboBoxData>();
105
+
106
+ interface ComboBoxData {
107
+ id: string;
108
+ }
109
+
110
+ export function getComboBoxData(state: ComboBoxState<unknown>): ComboBoxData | undefined {
111
+ return comboBoxData.get(state);
112
+ }
113
+
114
+ /**
115
+ * Provides the behavior and accessibility implementation for a combobox component.
116
+ */
117
+ export function createComboBox<T>(
118
+ props: MaybeAccessor<AriaComboBoxProps>,
119
+ state: ComboBoxState<T>,
120
+ inputRef: () => HTMLInputElement | null,
121
+ buttonRef?: () => HTMLElement | null,
122
+ listBoxRef?: () => HTMLElement | null
123
+ ): ComboBoxAria<T> {
124
+ const getProps = () => access(props);
125
+ const id = createId(getProps().id);
126
+
127
+ // Development-time warning for missing accessibility labels
128
+ if (isDevEnv()) {
129
+ const p = getProps();
130
+ if (!p.label && !p['aria-label'] && !p['aria-labelledby']) {
131
+ console.warn(
132
+ '[solidaria] A ComboBox requires a label, aria-label, or aria-labelledby attribute for accessibility.'
133
+ );
134
+ }
135
+ }
136
+
137
+ // Track if a pointerdown happened inside the listbox to prevent blur from closing
138
+ let isPointerDownInsideListBox = false;
139
+
140
+ // Generate IDs for associated elements
141
+ const inputId = `${id}-input`;
142
+ const buttonId = `${id}-button`;
143
+ const listBoxId = `${id}-listbox`;
144
+ const descriptionId = `${id}-description`;
145
+ const errorMessageId = `${id}-error`;
146
+
147
+ const getAriaDescribedBy = () => {
148
+ const p = getProps();
149
+ const ids: string[] = [];
150
+ if (p['aria-describedby']) {
151
+ ids.push(p['aria-describedby']);
152
+ }
153
+ if (p.description) {
154
+ ids.push(descriptionId);
155
+ }
156
+ if (p.isInvalid && p.errorMessage) {
157
+ ids.push(errorMessageId);
158
+ }
159
+ return ids.length > 0 ? ids.join(' ') : undefined;
160
+ };
161
+
162
+ // Set up global pointerdown listener to track clicks inside listbox
163
+ // This is needed because the option's createPress stops propagation
164
+ createEffect(() => {
165
+ if (typeof document === 'undefined') return;
166
+
167
+ const handleGlobalPointerDown = (e: PointerEvent) => {
168
+ const target = e.target as HTMLElement;
169
+ // Check if the click is inside the listbox
170
+ if (target.closest(`[id="${listBoxId}"]`)) {
171
+ isPointerDownInsideListBox = true;
172
+ }
173
+ };
174
+
175
+ document.addEventListener('pointerdown', handleGlobalPointerDown, true);
176
+
177
+ onCleanup(() => {
178
+ document.removeEventListener('pointerdown', handleGlobalPointerDown, true);
179
+ });
180
+ });
181
+
182
+ // Filter DOM props
183
+ const domProps = () =>
184
+ filterDOMProps(getProps() as unknown as Record<string, unknown>, { labelable: true });
185
+
186
+ // Share data with child options
187
+ createEffect(() => {
188
+ comboBoxData.set(state, { id });
189
+
190
+ onCleanup(() => {
191
+ comboBoxData.delete(state);
192
+ });
193
+ });
194
+
195
+ // Label handling
196
+ const { labelProps, fieldProps } = createLabel({
197
+ get id() {
198
+ return inputId;
199
+ },
200
+ get label() {
201
+ return getProps().label;
202
+ },
203
+ get 'aria-label'() {
204
+ return getProps()['aria-label'];
205
+ },
206
+ get 'aria-labelledby'() {
207
+ return getProps()['aria-labelledby'];
208
+ },
209
+ labelElementType: 'label',
210
+ });
211
+
212
+ // Focus ring for keyboard focus styling
213
+ const { isFocusVisible, focusProps } = createFocusRing({
214
+ get autoFocus() {
215
+ return getProps().autoFocus;
216
+ },
217
+ });
218
+
219
+ // Track focus state from state
220
+ const isFocused = state.isFocused;
221
+
222
+ // String formatter for VoiceOver announcements
223
+ // Only create on client side
224
+ const stringFormatter = !isServer ? createStringFormatter(comboBoxIntlStrings) : null;
225
+
226
+ // Track previous values for announcements
227
+ let lastFocusedKey: Key | null = null;
228
+ let lastSelectedKey: Key | null = null;
229
+ let lastOptionCount = 0;
230
+ let lastIsOpen = false;
231
+
232
+ // VoiceOver has issues with announcing aria-activedescendant properly on change
233
+ // (especially on iOS). We use a live region announcer to announce focus changes
234
+ // manually. This matches React Aria's behavior.
235
+ createEffect(() => {
236
+ if (isServer || !stringFormatter) return;
237
+
238
+ const focusedKey = state.focusedKey();
239
+ const isOpen = state.isOpen();
240
+ const collection = state.collection();
241
+
242
+ // Get the focused item
243
+ const focusedItem = focusedKey != null && isOpen
244
+ ? collection.getItem(focusedKey)
245
+ : null;
246
+
247
+ // Announce focus changes on Apple devices
248
+ if (isAppleDevice() && focusedItem != null && focusedKey !== lastFocusedKey) {
249
+ const isSelected = state.selectedKey() === focusedKey;
250
+ const optionText = focusedItem.textValue || '';
251
+
252
+ // For now, we don't support sections, so isGroupChange is always false
253
+ const announcement = stringFormatter().format('focusAnnouncement', {
254
+ isGroupChange: false,
255
+ groupTitle: '',
256
+ groupCount: 0,
257
+ optionText,
258
+ isSelected,
259
+ });
260
+
261
+ announce(announcement, 'polite');
262
+ }
263
+
264
+ lastFocusedKey = focusedKey;
265
+ });
266
+
267
+ // Announce the number of available suggestions when it changes
268
+ createEffect(() => {
269
+ if (isServer || !stringFormatter) return;
270
+
271
+ const isOpen = state.isOpen();
272
+ const collection = state.collection();
273
+ const optionCount = getItemCount(collection);
274
+ const focusedKey = state.focusedKey();
275
+
276
+ // Only announce the number of options available when the menu opens if there is no
277
+ // focused item, otherwise screen readers will typically read e.g. "1 of 6".
278
+ // The exception is VoiceOver since this isn't included in the message above.
279
+ const didOpenWithoutFocusedItem =
280
+ isOpen !== lastIsOpen &&
281
+ (focusedKey == null || isAppleDevice());
282
+
283
+ if (isOpen && (didOpenWithoutFocusedItem || optionCount !== lastOptionCount)) {
284
+ const announcement = stringFormatter().format('countAnnouncement', { optionCount });
285
+ announce(announcement, 'polite');
286
+ }
287
+
288
+ lastOptionCount = optionCount;
289
+ lastIsOpen = isOpen;
290
+ });
291
+
292
+ // Announce when a selection occurs for VoiceOver. Other screen readers typically do this automatically.
293
+ createEffect(() => {
294
+ if (isServer || !stringFormatter) return;
295
+
296
+ const selectedKey = state.selectedKey();
297
+ const selectedItem = state.selectedItem();
298
+
299
+ if (isAppleDevice() && state.isFocused() && selectedItem && selectedKey !== lastSelectedKey) {
300
+ const optionText = selectedItem.textValue || '';
301
+ const announcement = stringFormatter().format('selectedAnnouncement', { optionText });
302
+ announce(announcement, 'polite');
303
+ }
304
+
305
+ lastSelectedKey = selectedKey;
306
+ });
307
+
308
+ // Hide other page content from screen readers when the listbox is open.
309
+ // This requires both the input and listbox refs to be available.
310
+ // Note: This feature is important for screen reader accessibility but
311
+ // only works when a popoverRef/listBoxRef is provided.
312
+ createEffect(() => {
313
+ if (isServer) return;
314
+
315
+ const isOpen = state.isOpen();
316
+ const inputEl = inputRef();
317
+ const listBoxEl = listBoxRef?.();
318
+
319
+ // Only apply ariaHideOutside if we have both elements available
320
+ // This ensures the listbox won't be accidentally hidden
321
+ if (isOpen && inputEl && listBoxEl) {
322
+ const cleanup = ariaHideOutside([inputEl, listBoxEl]);
323
+ onCleanup(cleanup);
324
+ }
325
+ });
326
+
327
+ // Handle press on button trigger
328
+ const { pressProps } = createPress({
329
+ get isDisabled() {
330
+ return getProps().isDisabled ?? state.isDisabled;
331
+ },
332
+ onPress() {
333
+ state.toggle(null, 'manual');
334
+ // Focus input after toggling
335
+ inputRef()?.focus();
336
+ },
337
+ });
338
+
339
+ // Handle input change
340
+ const onInputChange: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
341
+ const target = e.target as HTMLInputElement;
342
+ state.setInputValue(target.value);
343
+ };
344
+
345
+ // Keyboard navigation for input
346
+ const onInputKeyDown: JSX.EventHandler<HTMLInputElement, KeyboardEvent> = (e) => {
347
+ const p = getProps();
348
+ if (p.isDisabled || p.isReadOnly) return;
349
+
350
+ const collection = state.collection();
351
+ const focusedKey = state.focusedKey();
352
+ const shouldWrap = p.shouldFocusWrap ?? false;
353
+
354
+ switch (e.key) {
355
+ case 'Enter':
356
+ if (state.isOpen() && focusedKey != null) {
357
+ e.preventDefault();
358
+
359
+ // Check if the focused item is a link
360
+ // Link href can be in props (for components) or value (for dynamic items)
361
+ const collectionItem = collection.getItem(focusedKey);
362
+ const itemHref = collectionItem?.props?.href ?? (collectionItem?.value as Record<string, unknown> | null)?.href;
363
+ if (itemHref) {
364
+ // Find the actual anchor element in the DOM and trigger navigation
365
+ const listBox = listBoxRef?.();
366
+ if (listBox) {
367
+ const item = listBox.querySelector(
368
+ `[data-key="${CSS.escape(String(focusedKey))}"]`
369
+ );
370
+ if (item instanceof HTMLAnchorElement) {
371
+ openLink(item, e);
372
+ }
373
+ }
374
+ state.close();
375
+ } else {
376
+ state.commit();
377
+ }
378
+ }
379
+ break;
380
+
381
+ case 'Escape':
382
+ if (state.isOpen()) {
383
+ e.preventDefault();
384
+ e.stopPropagation();
385
+ state.revert();
386
+ }
387
+ break;
388
+
389
+ case 'ArrowDown':
390
+ e.preventDefault();
391
+ if (!state.isOpen()) {
392
+ state.open('first', 'manual');
393
+ } else {
394
+ // Move to next item
395
+ if (focusedKey == null) {
396
+ const firstKey = collection.getFirstKey();
397
+ if (firstKey != null) {
398
+ state.setFocusedKey(firstKey);
399
+ }
400
+ } else {
401
+ let nextKey = collection.getKeyAfter(focusedKey);
402
+ // Skip disabled keys
403
+ while (nextKey != null && state.isKeyDisabled(nextKey)) {
404
+ nextKey = collection.getKeyAfter(nextKey);
405
+ }
406
+ if (nextKey != null) {
407
+ state.setFocusedKey(nextKey);
408
+ } else if (shouldWrap) {
409
+ // Wrap to first
410
+ let firstKey = collection.getFirstKey();
411
+ while (firstKey != null && state.isKeyDisabled(firstKey)) {
412
+ firstKey = collection.getKeyAfter(firstKey);
413
+ }
414
+ if (firstKey != null) {
415
+ state.setFocusedKey(firstKey);
416
+ }
417
+ }
418
+ }
419
+ }
420
+ break;
421
+
422
+ case 'ArrowUp':
423
+ e.preventDefault();
424
+ if (!state.isOpen()) {
425
+ state.open('last', 'manual');
426
+ } else {
427
+ // Move to previous item
428
+ if (focusedKey == null) {
429
+ const lastKey = collection.getLastKey();
430
+ if (lastKey != null) {
431
+ state.setFocusedKey(lastKey);
432
+ }
433
+ } else {
434
+ let prevKey = collection.getKeyBefore(focusedKey);
435
+ // Skip disabled keys
436
+ while (prevKey != null && state.isKeyDisabled(prevKey)) {
437
+ prevKey = collection.getKeyBefore(prevKey);
438
+ }
439
+ if (prevKey != null) {
440
+ state.setFocusedKey(prevKey);
441
+ } else if (shouldWrap) {
442
+ // Wrap to last
443
+ let lastKey = collection.getLastKey();
444
+ while (lastKey != null && state.isKeyDisabled(lastKey)) {
445
+ lastKey = collection.getKeyBefore(lastKey);
446
+ }
447
+ if (lastKey != null) {
448
+ state.setFocusedKey(lastKey);
449
+ }
450
+ }
451
+ }
452
+ }
453
+ break;
454
+
455
+ case 'Home':
456
+ if (state.isOpen()) {
457
+ e.preventDefault();
458
+ let firstKey = collection.getFirstKey();
459
+ while (firstKey != null && state.isKeyDisabled(firstKey)) {
460
+ firstKey = collection.getKeyAfter(firstKey);
461
+ }
462
+ if (firstKey != null) {
463
+ state.setFocusedKey(firstKey);
464
+ }
465
+ }
466
+ break;
467
+
468
+ case 'End':
469
+ if (state.isOpen()) {
470
+ e.preventDefault();
471
+ let lastKey = collection.getLastKey();
472
+ while (lastKey != null && state.isKeyDisabled(lastKey)) {
473
+ lastKey = collection.getKeyBefore(lastKey);
474
+ }
475
+ if (lastKey != null) {
476
+ state.setFocusedKey(lastKey);
477
+ }
478
+ }
479
+ break;
480
+
481
+ case 'Tab':
482
+ // Commit on Tab if menu is open
483
+ if (state.isOpen() && focusedKey != null) {
484
+ state.commit();
485
+ }
486
+ break;
487
+ }
488
+ };
489
+
490
+ // Handle focus events
491
+ const handleFocus = (e: FocusEvent) => {
492
+ state.setFocused(true);
493
+ getProps().onFocus?.(e);
494
+ getProps().onFocusChange?.(true);
495
+ };
496
+
497
+ // Track the last touch event time for iPad VoiceOver double-tap debouncing
498
+ let lastEventTime = 0;
499
+
500
+ const handleBlur = (e: FocusEvent) => {
501
+ // Use synchronous ref checks instead of requestAnimationFrame
502
+ // This matches React Aria's implementation and is more reliable
503
+ const relatedTarget = e.relatedTarget as HTMLElement | null;
504
+ const button = buttonRef?.();
505
+ const listBox = listBoxRef?.();
506
+
507
+ // Don't blur if focus is moving to the button
508
+ const blurFromButton = button && button === relatedTarget;
509
+
510
+ // Don't blur if focus is moving into the listbox/popover
511
+ const blurIntoPopover = listBox?.contains(relatedTarget);
512
+
513
+ if (blurFromButton || blurIntoPopover) {
514
+ return;
515
+ }
516
+
517
+ // If a pointerdown happened inside the listbox, don't close
518
+ // This handles the case when clicking on a non-focusable option
519
+ if (isPointerDownInsideListBox) {
520
+ isPointerDownInsideListBox = false;
521
+ return;
522
+ }
523
+
524
+ // Call user's onBlur handler
525
+ getProps().onBlur?.(e);
526
+
527
+ state.setFocused(false);
528
+ getProps().onFocusChange?.(false);
529
+ };
530
+
531
+ // Handle touch events for iPad VoiceOver
532
+ // VoiceOver on iOS fires a touchend at the center of the element on double-tap.
533
+ // We detect this and toggle the combobox manually to avoid issues with focus management.
534
+ const handleTouchEnd = (e: TouchEvent) => {
535
+ const p = getProps();
536
+ const isDisabled = p.isDisabled ?? state.isDisabled;
537
+ const isReadOnly = p.isReadOnly ?? state.isReadOnly;
538
+
539
+ if (isDisabled || isReadOnly) {
540
+ return;
541
+ }
542
+
543
+ // Debounce rapid consecutive touchend events (< 500ms)
544
+ // This handles VoiceOver's double-tap behavior
545
+ if (e.timeStamp - lastEventTime < 500) {
546
+ e.preventDefault();
547
+ inputRef()?.focus();
548
+ return;
549
+ }
550
+
551
+ // Detect VoiceOver virtual click - it fires at the exact center of the element
552
+ const rect = (e.target as Element).getBoundingClientRect();
553
+ const touch = e.changedTouches[0];
554
+ const centerX = Math.ceil(rect.left + 0.5 * rect.width);
555
+ const centerY = Math.ceil(rect.top + 0.5 * rect.height);
556
+
557
+ if (touch.clientX === centerX && touch.clientY === centerY) {
558
+ e.preventDefault();
559
+ inputRef()?.focus();
560
+ state.toggle(null, 'manual');
561
+ lastEventTime = e.timeStamp;
562
+ }
563
+ };
564
+
565
+ return {
566
+ get labelProps() {
567
+ return labelProps as JSX.HTMLAttributes<HTMLElement>;
568
+ },
569
+ get inputProps() {
570
+ const p = getProps();
571
+ const isOpen = state.isOpen();
572
+ const isDisabled = p.isDisabled ?? state.isDisabled;
573
+ const isReadOnly = p.isReadOnly ?? state.isReadOnly;
574
+ const focusedKey = state.focusedKey();
575
+
576
+ return mergeProps(
577
+ domProps(),
578
+ focusProps as Record<string, unknown>,
579
+ fieldProps as Record<string, unknown>,
580
+ {
581
+ id: inputId,
582
+ type: 'text',
583
+ role: 'combobox',
584
+ get value() {
585
+ return state.inputValue();
586
+ },
587
+ tabIndex: isDisabled ? undefined : 0,
588
+ disabled: isDisabled || undefined,
589
+ readOnly: isReadOnly || undefined,
590
+ placeholder: p.placeholder,
591
+ autoComplete: 'off',
592
+ 'aria-autocomplete': p.autoComplete ?? 'list',
593
+ 'aria-haspopup': 'listbox',
594
+ 'aria-expanded': isOpen,
595
+ 'aria-controls': isOpen ? listBoxId : undefined,
596
+ 'aria-activedescendant': isOpen && focusedKey != null
597
+ ? `${listBoxId}-option-${focusedKey}`
598
+ : undefined,
599
+ 'aria-disabled': isDisabled || undefined,
600
+ 'aria-required': p.isRequired || undefined,
601
+ 'aria-invalid': p.isInvalid || undefined,
602
+ 'aria-describedby': getAriaDescribedBy(),
603
+ name: p.name,
604
+ onInput: onInputChange,
605
+ onKeyDown: onInputKeyDown,
606
+ onFocus: handleFocus,
607
+ onBlur: handleBlur,
608
+ onTouchEnd: handleTouchEnd,
609
+ 'data-open': isOpen || undefined,
610
+ 'data-disabled': isDisabled || undefined,
611
+ 'data-readonly': isReadOnly || undefined,
612
+ 'data-focus-visible': isFocusVisible() || undefined,
613
+ } as Record<string, unknown>
614
+ ) as JSX.InputHTMLAttributes<HTMLInputElement>;
615
+ },
616
+ get buttonProps() {
617
+ const p = getProps();
618
+ const isOpen = state.isOpen();
619
+ const isDisabled = p.isDisabled ?? state.isDisabled;
620
+
621
+ return mergeProps(
622
+ pressProps as Record<string, unknown>,
623
+ {
624
+ id: buttonId,
625
+ type: 'button',
626
+ tabIndex: -1,
627
+ 'aria-haspopup': 'listbox',
628
+ 'aria-expanded': isOpen,
629
+ 'aria-controls': isOpen ? listBoxId : undefined,
630
+ 'aria-disabled': isDisabled || undefined,
631
+ 'aria-label': stringFormatter?.().format('buttonLabel') ?? 'Show suggestions',
632
+ 'data-open': isOpen || undefined,
633
+ 'data-disabled': isDisabled || undefined,
634
+ } as Record<string, unknown>
635
+ ) as JSX.HTMLAttributes<HTMLElement>;
636
+ },
637
+ get listBoxProps() {
638
+ return {
639
+ id: listBoxId,
640
+ role: 'listbox',
641
+ 'aria-labelledby': inputId,
642
+ tabIndex: -1,
643
+ // Track pointerdown inside listbox to prevent blur from closing
644
+ // Use capture phase because createPress calls stopPropagation on pointerdown
645
+ onPointerDownCapture: () => {
646
+ isPointerDownInsideListBox = true;
647
+ },
648
+ onMouseDownCapture: () => {
649
+ // Fallback for environments without PointerEvent
650
+ isPointerDownInsideListBox = true;
651
+ },
652
+ } as JSX.HTMLAttributes<HTMLElement>;
653
+ },
654
+ get descriptionProps() {
655
+ return {
656
+ id: descriptionId,
657
+ } as JSX.HTMLAttributes<HTMLElement>;
658
+ },
659
+ get errorMessageProps() {
660
+ return {
661
+ id: errorMessageId,
662
+ role: 'alert',
663
+ } as JSX.HTMLAttributes<HTMLElement>;
664
+ },
665
+ isFocused,
666
+ isFocusVisible: () => isFocused() && isFocusVisible(),
667
+ isOpen: state.isOpen,
668
+ selectedItem: state.selectedItem,
669
+ };
670
+ }