@proyecto-viviana/solid-stately 0.2.4 → 0.3.0

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 (243) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -283
  3. package/dist/autocomplete/createAutocompleteState.d.ts +3 -2
  4. package/dist/autocomplete/index.d.ts +1 -1
  5. package/dist/calendar/createCalendarState.d.ts +22 -7
  6. package/dist/calendar/createDateFieldState.d.ts +30 -7
  7. package/dist/calendar/createDatePickerState.d.ts +60 -0
  8. package/dist/calendar/createRangeCalendarState.d.ts +27 -8
  9. package/dist/calendar/createTimeFieldState.d.ts +34 -7
  10. package/dist/calendar/index.d.ts +7 -6
  11. package/dist/checkbox/createCheckboxGroupState.d.ts +12 -3
  12. package/dist/checkbox/index.d.ts +1 -1
  13. package/dist/collections/ListCollection.d.ts +1 -1
  14. package/dist/collections/createListState.d.ts +8 -8
  15. package/dist/collections/createMenuState.d.ts +7 -7
  16. package/dist/collections/createSelectionState.d.ts +5 -5
  17. package/dist/collections/index.d.ts +5 -5
  18. package/dist/collections/types.d.ts +23 -12
  19. package/dist/color/Color.d.ts +1 -1
  20. package/dist/color/createColorAreaState.d.ts +4 -2
  21. package/dist/color/createColorFieldState.d.ts +38 -12
  22. package/dist/color/createColorSliderState.d.ts +9 -2
  23. package/dist/color/createColorWheelState.d.ts +13 -2
  24. package/dist/color/getColorChannels.d.ts +20 -0
  25. package/dist/color/index.d.ts +6 -6
  26. package/dist/color/types.d.ts +4 -4
  27. package/dist/combobox/createComboBoxState.d.ts +27 -13
  28. package/dist/combobox/index.d.ts +1 -1
  29. package/dist/data/createAsyncList.d.ts +111 -0
  30. package/dist/data/createListData.d.ts +65 -0
  31. package/dist/data/createTreeData.d.ts +65 -0
  32. package/dist/data/index.d.ts +3 -0
  33. package/dist/datepicker/index.d.ts +8 -0
  34. package/dist/disclosure/createDisclosureState.d.ts +2 -2
  35. package/dist/disclosure/index.d.ts +1 -1
  36. package/dist/dnd/createDragState.d.ts +2 -2
  37. package/dist/dnd/createDraggableCollectionState.d.ts +2 -2
  38. package/dist/dnd/createDropState.d.ts +2 -2
  39. package/dist/dnd/createDroppableCollectionState.d.ts +3 -3
  40. package/dist/dnd/index.d.ts +5 -5
  41. package/dist/dnd/types.d.ts +16 -16
  42. package/dist/form/createFormValidationState.d.ts +3 -3
  43. package/dist/form/index.d.ts +1 -1
  44. package/dist/grid/createGridState.d.ts +2 -2
  45. package/dist/grid/index.d.ts +2 -2
  46. package/dist/grid/types.d.ts +17 -11
  47. package/dist/index.d.ts +31 -25
  48. package/dist/index.js +7990 -6264
  49. package/dist/index.js.map +1 -7
  50. package/dist/menu/index.d.ts +8 -0
  51. package/dist/numberfield/createNumberFieldState.d.ts +2 -2
  52. package/dist/numberfield/index.d.ts +1 -1
  53. package/dist/overlays/createOverlayTriggerState.d.ts +2 -2
  54. package/dist/overlays/index.d.ts +1 -1
  55. package/dist/radio/createRadioGroupState.d.ts +13 -4
  56. package/dist/radio/index.d.ts +1 -1
  57. package/dist/searchfield/createSearchFieldState.d.ts +2 -2
  58. package/dist/searchfield/index.d.ts +2 -2
  59. package/dist/select/createSelectState.d.ts +20 -3
  60. package/dist/select/index.d.ts +1 -1
  61. package/dist/selection/index.d.ts +11 -0
  62. package/dist/slider/createSliderState.d.ts +3 -3
  63. package/dist/slider/index.d.ts +2 -2
  64. package/dist/steplist/createStepListState.d.ts +48 -0
  65. package/dist/steplist/index.d.ts +1 -0
  66. package/dist/table/TableCollection.d.ts +3 -3
  67. package/dist/table/createTableColumnResizeState.d.ts +54 -0
  68. package/dist/table/createTableState.d.ts +2 -2
  69. package/dist/table/index.d.ts +5 -3
  70. package/dist/table/types.d.ts +10 -10
  71. package/dist/tabs/createTabListState.d.ts +5 -5
  72. package/dist/tabs/index.d.ts +1 -1
  73. package/dist/textfield/createTextFieldState.d.ts +2 -2
  74. package/dist/textfield/index.d.ts +1 -1
  75. package/dist/toast/createToastState.d.ts +15 -3
  76. package/dist/toast/index.d.ts +1 -1
  77. package/dist/toggle/createToggleGroupState.d.ts +45 -0
  78. package/dist/toggle/createToggleState.d.ts +2 -2
  79. package/dist/toggle/index.d.ts +2 -1
  80. package/dist/tooltip/createTooltipTriggerState.d.ts +2 -2
  81. package/dist/tooltip/index.d.ts +1 -1
  82. package/dist/tree/TreeCollection.d.ts +5 -4
  83. package/dist/tree/createTreeState.d.ts +2 -2
  84. package/dist/tree/index.d.ts +3 -3
  85. package/dist/tree/types.d.ts +19 -13
  86. package/dist/utils/index.d.ts +1 -1
  87. package/dist/utils/reactivity.d.ts +1 -1
  88. package/package.json +30 -30
  89. package/src/autocomplete/createAutocompleteState.d.ts +19 -17
  90. package/src/autocomplete/createAutocompleteState.ts +25 -32
  91. package/src/autocomplete/index.d.ts +6 -2
  92. package/src/autocomplete/index.ts +1 -1
  93. package/src/calendar/createCalendarState.d.ts +139 -117
  94. package/src/calendar/createCalendarState.ts +221 -74
  95. package/src/calendar/createDateFieldState.d.ts +130 -95
  96. package/src/calendar/createDateFieldState.ts +286 -151
  97. package/src/calendar/createDatePickerState.ts +347 -0
  98. package/src/calendar/createRangeCalendarState.d.ts +154 -129
  99. package/src/calendar/createRangeCalendarState.ts +293 -79
  100. package/src/calendar/createTimeFieldState.d.ts +81 -79
  101. package/src/calendar/createTimeFieldState.ts +187 -108
  102. package/src/calendar/index.d.ts +80 -7
  103. package/src/calendar/index.ts +21 -12
  104. package/src/checkbox/createCheckboxGroupState.d.ts +57 -55
  105. package/src/checkbox/createCheckboxGroupState.ts +57 -30
  106. package/src/checkbox/index.d.ts +6 -2
  107. package/src/checkbox/index.ts +1 -1
  108. package/src/collections/ListCollection.d.ts +25 -22
  109. package/src/collections/ListCollection.ts +152 -146
  110. package/src/collections/createListState.d.ts +73 -58
  111. package/src/collections/createListState.ts +267 -264
  112. package/src/collections/createMenuState.d.ts +36 -31
  113. package/src/collections/createMenuState.ts +112 -106
  114. package/src/collections/createSelectionState.d.ts +78 -65
  115. package/src/collections/createSelectionState.ts +340 -336
  116. package/src/collections/index.d.ts +38 -6
  117. package/src/collections/index.ts +41 -46
  118. package/src/collections/types.d.ts +101 -101
  119. package/src/collections/types.ts +181 -169
  120. package/src/color/Color.d.ts +20 -5
  121. package/src/color/Color.ts +1054 -951
  122. package/src/color/createColorAreaState.d.ts +67 -65
  123. package/src/color/createColorAreaState.ts +301 -293
  124. package/src/color/createColorFieldState.d.ts +46 -44
  125. package/src/color/createColorFieldState.ts +428 -292
  126. package/src/color/createColorSliderState.d.ts +58 -56
  127. package/src/color/createColorSliderState.ts +272 -241
  128. package/src/color/createColorWheelState.d.ts +42 -40
  129. package/src/color/createColorWheelState.ts +248 -211
  130. package/src/color/getColorChannels.ts +36 -0
  131. package/src/color/index.d.ts +36 -7
  132. package/src/color/index.ts +44 -47
  133. package/src/color/types.d.ts +68 -63
  134. package/src/color/types.ts +119 -127
  135. package/src/combobox/createComboBoxState.d.ts +107 -105
  136. package/src/combobox/createComboBoxState.ts +790 -703
  137. package/src/combobox/index.d.ts +10 -2
  138. package/src/combobox/index.ts +13 -13
  139. package/src/data/createAsyncList.ts +403 -0
  140. package/src/data/createListData.ts +298 -0
  141. package/src/data/createTreeData.ts +646 -0
  142. package/src/data/index.ts +25 -0
  143. package/src/datepicker/index.ts +32 -0
  144. package/src/disclosure/createDisclosureState.d.ts +47 -43
  145. package/src/disclosure/createDisclosureState.ts +9 -23
  146. package/src/disclosure/index.d.ts +10 -2
  147. package/src/disclosure/index.ts +1 -1
  148. package/src/dnd/createDragState.d.ts +51 -42
  149. package/src/dnd/createDragState.ts +151 -153
  150. package/src/dnd/createDraggableCollectionState.d.ts +52 -41
  151. package/src/dnd/createDraggableCollectionState.ts +158 -165
  152. package/src/dnd/createDropState.d.ts +64 -45
  153. package/src/dnd/createDropState.ts +202 -212
  154. package/src/dnd/createDroppableCollectionState.d.ts +88 -59
  155. package/src/dnd/createDroppableCollectionState.ts +354 -357
  156. package/src/dnd/index.d.ts +54 -6
  157. package/src/dnd/index.ts +63 -76
  158. package/src/dnd/types.d.ts +167 -156
  159. package/src/dnd/types.ts +316 -317
  160. package/src/form/createFormValidationState.d.ts +47 -45
  161. package/src/form/createFormValidationState.ts +356 -389
  162. package/src/form/index.d.ts +16 -2
  163. package/src/form/index.ts +15 -15
  164. package/src/grid/createGridState.d.ts +7 -4
  165. package/src/grid/createGridState.ts +44 -51
  166. package/src/grid/index.d.ts +3 -3
  167. package/src/grid/index.ts +2 -8
  168. package/src/grid/types.d.ts +136 -129
  169. package/src/grid/types.ts +24 -17
  170. package/src/index.d.ts +310 -26
  171. package/src/index.ts +87 -79
  172. package/src/menu/index.ts +17 -0
  173. package/src/numberfield/createNumberFieldState.d.ts +56 -54
  174. package/src/numberfield/createNumberFieldState.ts +422 -383
  175. package/src/numberfield/index.d.ts +6 -2
  176. package/src/numberfield/index.ts +5 -5
  177. package/src/overlays/createOverlayTriggerState.d.ts +22 -20
  178. package/src/overlays/createOverlayTriggerState.ts +66 -67
  179. package/src/overlays/index.d.ts +6 -2
  180. package/src/overlays/index.ts +5 -5
  181. package/src/radio/createRadioGroupState.d.ts +54 -52
  182. package/src/radio/createRadioGroupState.ts +66 -30
  183. package/src/radio/index.d.ts +7 -2
  184. package/src/radio/index.ts +1 -1
  185. package/src/searchfield/createSearchFieldState.d.ts +16 -14
  186. package/src/searchfield/createSearchFieldState.ts +56 -62
  187. package/src/searchfield/index.d.ts +3 -3
  188. package/src/searchfield/index.ts +2 -5
  189. package/src/select/createSelectState.d.ts +63 -61
  190. package/src/select/createSelectState.ts +290 -181
  191. package/src/select/index.d.ts +2 -2
  192. package/src/select/index.ts +1 -5
  193. package/src/selection/index.ts +27 -0
  194. package/src/slider/createSliderState.d.ts +60 -60
  195. package/src/slider/createSliderState.ts +233 -211
  196. package/src/slider/index.d.ts +3 -3
  197. package/src/slider/index.ts +2 -6
  198. package/src/ssr/index.d.ts +1 -1
  199. package/src/ssr/index.ts +2 -2
  200. package/src/steplist/createStepListState.ts +191 -0
  201. package/src/steplist/index.ts +5 -0
  202. package/src/table/TableCollection.d.ts +42 -40
  203. package/src/table/TableCollection.ts +61 -64
  204. package/src/table/createTableColumnResizeState.ts +242 -0
  205. package/src/table/createTableState.d.ts +7 -4
  206. package/src/table/createTableState.ts +10 -7
  207. package/src/table/index.d.ts +14 -4
  208. package/src/table/index.ts +10 -3
  209. package/src/table/types.d.ts +98 -92
  210. package/src/table/types.ts +17 -13
  211. package/src/tabs/createTabListState.d.ts +59 -57
  212. package/src/tabs/createTabListState.ts +58 -39
  213. package/src/tabs/index.d.ts +8 -2
  214. package/src/tabs/index.ts +1 -1
  215. package/src/textfield/createTextFieldState.d.ts +16 -14
  216. package/src/textfield/createTextFieldState.ts +7 -18
  217. package/src/textfield/index.d.ts +6 -2
  218. package/src/textfield/index.ts +1 -1
  219. package/src/toast/createToastState.d.ts +92 -81
  220. package/src/toast/createToastState.ts +56 -44
  221. package/src/toast/index.d.ts +12 -2
  222. package/src/toast/index.ts +1 -1
  223. package/src/toggle/createToggleGroupState.ts +117 -0
  224. package/src/toggle/createToggleState.d.ts +19 -19
  225. package/src/toggle/createToggleState.ts +2 -16
  226. package/src/toggle/index.d.ts +2 -2
  227. package/src/toggle/index.ts +6 -4
  228. package/src/tooltip/createTooltipTriggerState.d.ts +17 -17
  229. package/src/tooltip/createTooltipTriggerState.ts +184 -183
  230. package/src/tooltip/index.d.ts +7 -2
  231. package/src/tooltip/index.ts +6 -6
  232. package/src/tree/TreeCollection.d.ts +26 -23
  233. package/src/tree/TreeCollection.ts +215 -175
  234. package/src/tree/createTreeState.d.ts +7 -4
  235. package/src/tree/createTreeState.ts +390 -392
  236. package/src/tree/index.d.ts +10 -4
  237. package/src/tree/index.ts +13 -13
  238. package/src/tree/types.d.ts +131 -126
  239. package/src/tree/types.ts +183 -174
  240. package/src/utils/index.d.ts +2 -2
  241. package/src/utils/index.ts +1 -1
  242. package/src/utils/reactivity.d.ts +2 -2
  243. package/src/utils/reactivity.ts +3 -3
@@ -1,703 +1,790 @@
1
- /**
2
- * State management for ComboBox components.
3
- * Based on @react-stately/combobox useComboBoxState.
4
- *
5
- * ComboBox combines a text input with a dropdown list, allowing users to
6
- * either type to filter options or select from a list.
7
- */
8
-
9
- import { createSignal, createMemo, createEffect, type Accessor } from 'solid-js';
10
- import { access, type MaybeAccessor } from '../utils';
11
- import { createListState } from '../collections/createListState';
12
- import { createOverlayTriggerState } from '../overlays';
13
- import type { Key, CollectionNode, Collection, FocusStrategy } from '../collections/types';
14
-
15
- // ============================================
16
- // TYPES
17
- // ============================================
18
-
19
- export type MenuTriggerAction = 'focus' | 'input' | 'manual';
20
-
21
- // Re-export FocusStrategy for convenience
22
- export type { FocusStrategy } from '../collections/types';
23
-
24
- export type FilterFn = (textValue: string, inputValue: string) => boolean;
25
-
26
- export interface ComboBoxStateProps<T = unknown> {
27
- /** The items to display in the combobox dropdown. */
28
- items: T[];
29
- /** Default items when uncontrolled. */
30
- defaultItems?: T[];
31
- /** Function to get the key for an item. */
32
- getKey?: (item: T) => Key;
33
- /** Function to get the text value for an item. */
34
- getTextValue?: (item: T) => string;
35
- /** Function to check if an item is disabled. */
36
- getDisabled?: (item: T) => boolean;
37
- /** Keys of disabled items. */
38
- disabledKeys?: Iterable<Key>;
39
- /** The currently selected key (controlled). */
40
- selectedKey?: Key | null;
41
- /** The default selected key (uncontrolled). */
42
- defaultSelectedKey?: Key | null;
43
- /** Handler called when the selection changes. */
44
- onSelectionChange?: (key: Key | null) => void;
45
- /** The current input value (controlled). */
46
- inputValue?: string;
47
- /** The default input value (uncontrolled). */
48
- defaultInputValue?: string;
49
- /** Handler called when the input value changes. */
50
- onInputChange?: (value: string) => void;
51
- /** Whether the combobox is open (controlled). */
52
- isOpen?: boolean;
53
- /** Whether the combobox is open by default (uncontrolled). */
54
- defaultOpen?: boolean;
55
- /** Handler called when the open state changes. */
56
- onOpenChange?: (isOpen: boolean, trigger?: MenuTriggerAction) => void;
57
- /** Whether the combobox is disabled. */
58
- isDisabled?: boolean;
59
- /** Whether the combobox is read-only. */
60
- isReadOnly?: boolean;
61
- /** Whether the combobox is required. */
62
- isRequired?: boolean;
63
- /** The filter function to use when filtering items. */
64
- defaultFilter?: FilterFn;
65
- /** Whether to allow the menu to open when there are no items. */
66
- allowsEmptyCollection?: boolean;
67
- /** Whether to allow custom values that don't match any option. */
68
- allowsCustomValue?: boolean;
69
- /** What triggers the menu to open. */
70
- menuTrigger?: MenuTriggerAction;
71
- /** Whether to close the menu on blur. */
72
- shouldCloseOnBlur?: boolean;
73
- }
74
-
75
- export interface ComboBoxState<T = unknown> {
76
- /** The collection of items (may be filtered). */
77
- readonly collection: Accessor<Collection<T>>;
78
- /** Whether the combobox dropdown is open. */
79
- readonly isOpen: Accessor<boolean>;
80
- /** Open the combobox dropdown. */
81
- open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void;
82
- /** Close the combobox dropdown. */
83
- close(): void;
84
- /** Toggle the combobox dropdown. */
85
- toggle(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void;
86
- /** The currently selected key. */
87
- readonly selectedKey: Accessor<Key | null>;
88
- /** The default selected key. */
89
- readonly defaultSelectedKey: Key | null;
90
- /** The currently selected item. */
91
- readonly selectedItem: Accessor<CollectionNode<T> | null>;
92
- /** Set the selected key. */
93
- setSelectedKey(key: Key | null): void;
94
- /** The current input value. */
95
- readonly inputValue: Accessor<string>;
96
- /** The default input value. */
97
- readonly defaultInputValue: string;
98
- /** Set the input value. */
99
- setInputValue(value: string): void;
100
- /** The currently focused key in the list. */
101
- readonly focusedKey: Accessor<Key | null>;
102
- /** Set the focused key. */
103
- setFocusedKey(key: Key | null): void;
104
- /** Whether the combobox input has focus. */
105
- readonly isFocused: Accessor<boolean>;
106
- /** Set whether the combobox has focus. */
107
- setFocused(isFocused: boolean): void;
108
- /** The focus strategy to use when opening. */
109
- readonly focusStrategy: Accessor<FocusStrategy | null>;
110
- /** Commit the current selection (select focused item or custom value). */
111
- commit(): void;
112
- /** Revert input to the selected item's text and close menu. */
113
- revert(): void;
114
- /** Whether a specific key is disabled. */
115
- isKeyDisabled(key: Key): boolean;
116
- /** Select a key and close the menu (for ListState compatibility). */
117
- select(key: Key): void;
118
- /** The selection mode (always 'single' for combobox). */
119
- readonly selectionMode: Accessor<'single'>;
120
- /** Check if a key is selected. */
121
- isSelected(key: Key): boolean;
122
- /** Whether the combobox is disabled. */
123
- readonly isDisabled: boolean;
124
- /** Whether the combobox is read-only. */
125
- readonly isReadOnly: boolean;
126
- /** Whether the combobox is required. */
127
- readonly isRequired: boolean;
128
- }
129
-
130
- // ============================================
131
- // DEFAULT FILTER
132
- // ============================================
133
-
134
- /**
135
- * Default filter function that does case-insensitive "contains" matching.
136
- */
137
- export const defaultContainsFilter: FilterFn = (textValue, inputValue) => {
138
- return textValue.toLowerCase().includes(inputValue.toLowerCase());
139
- };
140
-
141
- // ============================================
142
- // IMPLEMENTATION
143
- // ============================================
144
-
145
- /**
146
- * Creates state for a combobox component.
147
- * Combines list state with input value management and filtering.
148
- */
149
- export function createComboBoxState<T = unknown>(
150
- props: MaybeAccessor<ComboBoxStateProps<T>>
151
- ): ComboBoxState<T> {
152
- const getProps = () => access(props);
153
-
154
- // Extract options with defaults
155
- const menuTrigger = () => getProps().menuTrigger ?? 'input';
156
- const allowsEmptyCollection = () => getProps().allowsEmptyCollection ?? false;
157
- const allowsCustomValue = () => getProps().allowsCustomValue ?? false;
158
- const shouldCloseOnBlur = () => getProps().shouldCloseOnBlur ?? true;
159
-
160
- // Track focus strategy for list navigation
161
- const [focusStrategy, setFocusStrategy] = createSignal<FocusStrategy | null>(null);
162
-
163
- // Track whether we're showing all items (vs filtered)
164
- const [showAllItems, setShowAllItems] = createSignal(false);
165
-
166
- // Track the menu open trigger
167
- let menuOpenTrigger: MenuTriggerAction = 'focus';
168
-
169
- // ---- Selection State ----
170
- // Note: Selection state is initialized first because input value may depend on it
171
- const isSelectionControlled = () => getProps().selectedKey !== undefined;
172
- const [internalSelectedKey, setInternalSelectedKey] = createSignal<Key | null>(
173
- getProps().defaultSelectedKey ?? null
174
- );
175
-
176
- // ---- Input Value State ----
177
- // Initialized after selection so we can derive from selected item if needed
178
- const isInputControlled = () => getProps().inputValue !== undefined;
179
-
180
- // We'll set the proper initial value after collection is created
181
- const [internalInputValue, setInternalInputValue] = createSignal(
182
- getProps().defaultInputValue ?? ''
183
- );
184
- // Track if we've initialized input from selection
185
- let inputInitialized = false;
186
-
187
- const inputValue: Accessor<string> = () => {
188
- return isInputControlled() ? (getProps().inputValue ?? '') : internalInputValue();
189
- };
190
-
191
- const setInputValue = (value: string) => {
192
- if (!isInputControlled()) {
193
- setInternalInputValue(value);
194
- }
195
- getProps().onInputChange?.(value);
196
- };
197
-
198
- // Track last committed input value
199
- const [lastValue, setLastValue] = createSignal(inputValue());
200
-
201
- const selectedKey: Accessor<Key | null> = () => {
202
- return isSelectionControlled() ? (getProps().selectedKey ?? null) : internalSelectedKey();
203
- };
204
-
205
- const setSelectedKey = (key: Key | null) => {
206
- if (!isSelectionControlled()) {
207
- setInternalSelectedKey(key);
208
- }
209
- getProps().onSelectionChange?.(key);
210
- };
211
-
212
- // ---- Overlay State ----
213
- const overlayState = createOverlayTriggerState({
214
- get isOpen() {
215
- return getProps().isOpen;
216
- },
217
- get defaultOpen() {
218
- return getProps().defaultOpen;
219
- },
220
- onOpenChange(isOpen: boolean) {
221
- getProps().onOpenChange?.(isOpen, isOpen ? menuOpenTrigger : undefined);
222
- },
223
- });
224
-
225
- // ---- List State (unfiltered collection) ----
226
- const listState = createListState<T>({
227
- get items() {
228
- // Use items or defaultItems
229
- return getProps().items ?? getProps().defaultItems ?? [];
230
- },
231
- get getKey() {
232
- return getProps().getKey;
233
- },
234
- get getTextValue() {
235
- return getProps().getTextValue;
236
- },
237
- get getDisabled() {
238
- return getProps().getDisabled;
239
- },
240
- get disabledKeys() {
241
- return getProps().disabledKeys;
242
- },
243
- selectionMode: 'single',
244
- disallowEmptySelection: false,
245
- get selectedKeys() {
246
- const key = selectedKey();
247
- return key != null ? [key] : [];
248
- },
249
- onSelectionChange(keys) {
250
- if (keys === 'all') return;
251
- const key = keys.size > 0 ? Array.from(keys)[0] : null;
252
-
253
- // If same key selected, just reset input and close
254
- if (key === selectedKey()) {
255
- resetInputValue();
256
- closeMenu();
257
- return;
258
- }
259
-
260
- setSelectedKey(key);
261
- },
262
- });
263
-
264
- // ---- Filtered Collection ----
265
- const originalCollection = listState.collection;
266
-
267
- const filteredCollection = createMemo<Collection<T>>(() => {
268
- const collection = originalCollection();
269
- const input = inputValue();
270
- const filter = getProps().defaultFilter;
271
-
272
- // If no filter function provided, return original collection
273
- if (!filter) {
274
- return collection;
275
- }
276
-
277
- // Filter the collection based on input value
278
- return filterCollection(collection, input, filter);
279
- });
280
-
281
- // The displayed collection depends on showAllItems flag
282
- // Always show filtered collection (or all items if showAllItems is true)
283
- const displayedCollection = createMemo<Collection<T>>(() => {
284
- return showAllItems() ? originalCollection() : filteredCollection();
285
- });
286
-
287
- // ---- Selected Item ----
288
- const selectedItem: Accessor<CollectionNode<T> | null> = () => {
289
- const key = selectedKey();
290
- if (key == null) return null;
291
- return originalCollection().getItem(key);
292
- };
293
-
294
- // Initialize input value from selected item if not already set
295
- // This runs once on creation
296
- if (!inputInitialized && !isInputControlled()) {
297
- const defaultKey = getProps().defaultSelectedKey;
298
- if (defaultKey != null && !getProps().defaultInputValue) {
299
- // Get the text value from the collection for the default selected key
300
- const item = originalCollection().getItem(defaultKey);
301
- if (item) {
302
- setInternalInputValue(item.textValue);
303
- setLastValue(item.textValue);
304
- }
305
- }
306
- inputInitialized = true;
307
- }
308
-
309
- // ---- Helper Functions ----
310
- const resetInputValue = () => {
311
- const item = selectedItem();
312
- const textValue = item?.textValue ?? '';
313
- setLastValue(textValue);
314
- setInputValue(textValue);
315
- };
316
-
317
- const closeMenu = () => {
318
- if (overlayState.isOpen()) {
319
- overlayState.close();
320
- }
321
- };
322
-
323
- // ---- Open/Toggle Logic ----
324
- const open = (strategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => {
325
- const displayAll = trigger === 'manual' || (trigger === 'focus' && menuTrigger() === 'focus');
326
-
327
- // Check if we should open
328
- const filtered = filteredCollection();
329
- const original = originalCollection();
330
- const canOpen = allowsEmptyCollection() ||
331
- filtered.size > 0 ||
332
- (displayAll && original.size > 0);
333
-
334
- if (!canOpen) return;
335
-
336
- if (displayAll && !overlayState.isOpen()) {
337
- setShowAllItems(true);
338
- }
339
-
340
- menuOpenTrigger = trigger ?? 'focus';
341
- setFocusStrategy(strategy);
342
- overlayState.open();
343
- };
344
-
345
- const toggle = (strategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => {
346
- const displayAll = trigger === 'manual' || (trigger === 'focus' && menuTrigger() === 'focus');
347
-
348
- // Check if we can open (if closed)
349
- const filtered = filteredCollection();
350
- const original = originalCollection();
351
- const canOpen = allowsEmptyCollection() ||
352
- filtered.size > 0 ||
353
- (displayAll && original.size > 0);
354
-
355
- if (!canOpen && !overlayState.isOpen()) return;
356
-
357
- if (displayAll && !overlayState.isOpen()) {
358
- setShowAllItems(true);
359
- }
360
-
361
- if (!overlayState.isOpen()) {
362
- menuOpenTrigger = trigger ?? 'focus';
363
- }
364
-
365
- setFocusStrategy(strategy);
366
- overlayState.toggle();
367
- };
368
-
369
- // ---- Commit/Revert Logic ----
370
- const commitCustomValue = () => {
371
- setSelectedKey(null);
372
- closeMenu();
373
- };
374
-
375
- const commitSelection = () => {
376
- // If both are controlled, just call onSelectionChange
377
- if (isSelectionControlled() && isInputControlled()) {
378
- getProps().onSelectionChange?.(selectedKey());
379
- const item = selectedItem();
380
- setLastValue(item?.textValue ?? '');
381
- closeMenu();
382
- } else {
383
- // Reset input to selected item's text
384
- resetInputValue();
385
- closeMenu();
386
- }
387
- };
388
-
389
- const commitValue = () => {
390
- if (allowsCustomValue()) {
391
- const item = selectedItem();
392
- const itemText = item?.textValue ?? '';
393
- if (inputValue() === itemText) {
394
- commitSelection();
395
- } else {
396
- commitCustomValue();
397
- }
398
- } else {
399
- commitSelection();
400
- }
401
- };
402
-
403
- const commit = () => {
404
- const focusedKey = listState.focusedKey();
405
-
406
- if (overlayState.isOpen() && focusedKey != null) {
407
- // If focused key is already selected, just commit
408
- if (selectedKey() === focusedKey) {
409
- commitSelection();
410
- } else {
411
- // Select the focused item
412
- setSelectedKey(focusedKey);
413
- }
414
- } else {
415
- commitValue();
416
- }
417
- };
418
-
419
- const revert = () => {
420
- if (allowsCustomValue() && selectedKey() == null) {
421
- commitCustomValue();
422
- } else {
423
- commitSelection();
424
- }
425
- };
426
-
427
- // ---- Focus Handling ----
428
- const [isFocused, setIsFocused] = createSignal(false);
429
- let valueOnFocus = '';
430
-
431
- const setFocused = (focused: boolean) => {
432
- if (focused) {
433
- valueOnFocus = inputValue();
434
- if (menuTrigger() === 'focus' && !getProps().isReadOnly) {
435
- open(null, 'focus');
436
- }
437
- } else {
438
- if (shouldCloseOnBlur()) {
439
- commitValue();
440
- }
441
- }
442
- setIsFocused(focused);
443
- };
444
-
445
- // ---- Effects for Auto Open/Close ----
446
- createEffect(() => {
447
- const input = inputValue();
448
- const filtered = filteredCollection();
449
- const isOpen = overlayState.isOpen();
450
- const last = lastValue();
451
- const focused = isFocused();
452
-
453
- // Auto-open when typing
454
- if (
455
- focused &&
456
- (filtered.size > 0 || allowsEmptyCollection()) &&
457
- !isOpen &&
458
- input !== last &&
459
- menuTrigger() !== 'manual'
460
- ) {
461
- open(null, 'input');
462
- }
463
-
464
- // Auto-close when empty (unless showing all)
465
- if (
466
- !showAllItems() &&
467
- !allowsEmptyCollection() &&
468
- isOpen &&
469
- filtered.size === 0
470
- ) {
471
- closeMenu();
472
- }
473
-
474
- // Clear focused key when input changes
475
- if (input !== last) {
476
- listState.setFocusedKey(null);
477
- setShowAllItems(false);
478
-
479
- // Clear selection when input is cleared (if not fully controlled)
480
- if (input === '' && (!isInputControlled() || !isSelectionControlled())) {
481
- setSelectedKey(null);
482
- }
483
-
484
- setLastValue(input);
485
- }
486
- });
487
-
488
- // Update input when selection changes
489
- createEffect(() => {
490
- const key = selectedKey();
491
- const item = key != null ? originalCollection().getItem(key) : null;
492
- const textValue = item?.textValue ?? '';
493
-
494
- // Only update if selection changed and not fully controlled
495
- if (!isInputControlled() || !isSelectionControlled()) {
496
- if (key != null && textValue !== inputValue()) {
497
- setInputValue(textValue);
498
- setLastValue(textValue);
499
- }
500
- }
501
- });
502
-
503
- // Close when selection changes
504
- createEffect((prevKey: Key | null | undefined) => {
505
- const key = selectedKey();
506
- if (key != null && key !== prevKey) {
507
- closeMenu();
508
- }
509
- return key;
510
- }, undefined);
511
-
512
- // ---- Selection Methods for ListState compatibility ----
513
- // These methods allow createOption to work with ComboBoxState
514
- const select = (key: Key) => {
515
- setSelectedKey(key);
516
- closeMenu();
517
- };
518
-
519
- const selectionMode: Accessor<'single'> = () => 'single';
520
- const isSelected = (key: Key) => selectedKey() === key;
521
-
522
- // ---- Return State ----
523
- return {
524
- collection: displayedCollection,
525
- isOpen: overlayState.isOpen,
526
- open,
527
- close: commitValue,
528
- toggle,
529
- selectedKey,
530
- defaultSelectedKey: getProps().defaultSelectedKey ?? null,
531
- selectedItem,
532
- setSelectedKey,
533
- inputValue,
534
- defaultInputValue: getProps().defaultInputValue ?? '',
535
- setInputValue,
536
- focusedKey: listState.focusedKey,
537
- setFocusedKey: listState.setFocusedKey,
538
- isFocused,
539
- setFocused,
540
- focusStrategy,
541
- commit,
542
- revert,
543
- // Selection state methods for ListState compatibility
544
- select,
545
- selectionMode,
546
- isSelected,
547
- isKeyDisabled: (key: Key) => listState.isDisabled(key),
548
- get isDisabled() {
549
- return getProps().isDisabled ?? false;
550
- },
551
- get isReadOnly() {
552
- return getProps().isReadOnly ?? false;
553
- },
554
- get isRequired() {
555
- return getProps().isRequired ?? false;
556
- },
557
- };
558
- }
559
-
560
- // ============================================
561
- // COLLECTION FILTERING
562
- // ============================================
563
-
564
- /**
565
- * Filter a collection based on input value.
566
- */
567
- function filterCollection<T>(
568
- collection: Collection<T>,
569
- inputValue: string,
570
- filter: FilterFn
571
- ): Collection<T> {
572
- if (!inputValue) {
573
- return collection;
574
- }
575
-
576
- const filteredItems: CollectionNode<T>[] = [];
577
-
578
- for (const item of collection) {
579
- if (item.type === 'section') {
580
- // Filter section children
581
- const filteredChildren: CollectionNode<T>[] = [];
582
- if (item.childNodes) {
583
- for (const child of item.childNodes) {
584
- if (child.type === 'item' && filter(child.textValue, inputValue)) {
585
- filteredChildren.push(child);
586
- }
587
- }
588
- }
589
- // Only include section if it has matching children
590
- if (filteredChildren.length > 0) {
591
- filteredItems.push({
592
- ...item,
593
- childNodes: filteredChildren,
594
- });
595
- }
596
- } else if (item.type === 'item') {
597
- if (filter(item.textValue, inputValue)) {
598
- filteredItems.push(item);
599
- }
600
- }
601
- }
602
-
603
- // Create a new collection from filtered items
604
- return createFilteredCollection(filteredItems, collection);
605
- }
606
-
607
- /**
608
- * Create a filtered collection wrapper.
609
- */
610
- function createFilteredCollection<T>(
611
- items: CollectionNode<T>[],
612
- original: Collection<T>
613
- ): Collection<T> {
614
- const itemMap = new Map<Key, CollectionNode<T>>();
615
-
616
- for (const item of items) {
617
- itemMap.set(item.key, item);
618
- if (item.childNodes) {
619
- for (const child of item.childNodes) {
620
- itemMap.set(child.key, child);
621
- }
622
- }
623
- }
624
-
625
- return {
626
- get size() {
627
- let count = 0;
628
- for (const item of items) {
629
- if (item.type === 'item') {
630
- count++;
631
- } else if (item.childNodes) {
632
- count += Array.from(item.childNodes).filter(c => c.type === 'item').length;
633
- }
634
- }
635
- return count;
636
- },
637
- getItem(key: Key) {
638
- return itemMap.get(key) ?? null;
639
- },
640
- getKeys() {
641
- return itemMap.keys();
642
- },
643
- getFirstKey() {
644
- for (const item of items) {
645
- if (item.type === 'item') return item.key;
646
- if (item.childNodes) {
647
- for (const child of item.childNodes) {
648
- if (child.type === 'item') return child.key;
649
- }
650
- }
651
- }
652
- return null;
653
- },
654
- getLastKey() {
655
- for (let i = items.length - 1; i >= 0; i--) {
656
- const item = items[i];
657
- if (item.type === 'item') return item.key;
658
- if (item.childNodes) {
659
- const children = Array.from(item.childNodes);
660
- for (let j = children.length - 1; j >= 0; j--) {
661
- if (children[j].type === 'item') return children[j].key;
662
- }
663
- }
664
- }
665
- return null;
666
- },
667
- getKeyBefore(key: Key) {
668
- return original.getKeyBefore(key);
669
- },
670
- getKeyAfter(key: Key) {
671
- return original.getKeyAfter(key);
672
- },
673
- at(index: number) {
674
- // Flatten items for indexing
675
- let currentIndex = 0;
676
- for (const item of items) {
677
- if (item.type === 'item') {
678
- if (currentIndex === index) return item;
679
- currentIndex++;
680
- } else if (item.childNodes) {
681
- for (const child of item.childNodes) {
682
- if (child.type === 'item') {
683
- if (currentIndex === index) return child;
684
- currentIndex++;
685
- }
686
- }
687
- }
688
- }
689
- return null;
690
- },
691
- getChildren(key: Key) {
692
- const item = itemMap.get(key);
693
- return item?.childNodes ?? [];
694
- },
695
- getTextValue(key: Key) {
696
- const item = itemMap.get(key);
697
- return item?.textValue ?? '';
698
- },
699
- [Symbol.iterator]() {
700
- return items[Symbol.iterator]();
701
- },
702
- };
703
- }
1
+ /**
2
+ * State management for ComboBox components.
3
+ * Based on @react-stately/combobox useComboBoxState.
4
+ *
5
+ * ComboBox combines a text input with a dropdown list, allowing users to
6
+ * either type to filter options or select from a list.
7
+ */
8
+
9
+ import { createSignal, createMemo, createEffect, type Accessor } from "solid-js";
10
+ import { access, type MaybeAccessor } from "../utils";
11
+ import { createListState } from "../collections/createListState";
12
+ import { createOverlayTriggerState } from "../overlays";
13
+ import type { Key, CollectionNode, Collection, FocusStrategy } from "../collections/types";
14
+
15
+ export type MenuTriggerAction = "focus" | "input" | "manual";
16
+
17
+ export type { FocusStrategy } from "../collections/types";
18
+
19
+ export type FilterFn = (textValue: string, inputValue: string) => boolean;
20
+
21
+ export interface ComboBoxStateProps<T = unknown> {
22
+ /** The items to display in the combobox dropdown. */
23
+ items?: T[];
24
+ /** Default items when uncontrolled. */
25
+ defaultItems?: T[];
26
+ /** Function to get the key for an item. */
27
+ getKey?: (item: T) => Key;
28
+ /** Function to get the text value for an item. */
29
+ getTextValue?: (item: T) => string;
30
+ /** Function to check if an item is disabled. */
31
+ getDisabled?: (item: T) => boolean;
32
+ /** Keys of disabled items. */
33
+ disabledKeys?: Iterable<Key>;
34
+ /** The selection mode for the combobox. */
35
+ selectionMode?: "single" | "multiple";
36
+ /** The currently selected key (controlled, single mode). */
37
+ selectedKey?: Key | null;
38
+ /** The default selected key (uncontrolled, single mode). */
39
+ defaultSelectedKey?: Key | null;
40
+ /** The currently selected keys (controlled, multiple mode). */
41
+ selectedKeys?: Iterable<Key>;
42
+ /** The default selected keys (uncontrolled, multiple mode). */
43
+ defaultSelectedKeys?: Iterable<Key>;
44
+ /** Handler called when the selection changes (single mode). */
45
+ onSelectionChange?: (key: Key | null) => void;
46
+ /** Handler called when the selection changes (multiple mode). */
47
+ onSelectionChangeMultiple?: (keys: Set<Key>) => void;
48
+ /** The current input value (controlled). */
49
+ inputValue?: string;
50
+ /** The default input value (uncontrolled). */
51
+ defaultInputValue?: string;
52
+ /** Handler called when the input value changes. */
53
+ onInputChange?: (value: string) => void;
54
+ /** Whether the combobox is open (controlled). */
55
+ isOpen?: boolean;
56
+ /** Whether the combobox is open by default (uncontrolled). */
57
+ defaultOpen?: boolean;
58
+ /** Handler called when the open state changes. */
59
+ onOpenChange?: (isOpen: boolean, trigger?: MenuTriggerAction) => void;
60
+ /** Whether the combobox is disabled. */
61
+ isDisabled?: boolean;
62
+ /** Whether the combobox is read-only. */
63
+ isReadOnly?: boolean;
64
+ /** Whether the combobox is required. */
65
+ isRequired?: boolean;
66
+ /** The filter function to use when filtering items. */
67
+ defaultFilter?: FilterFn;
68
+ /** Whether to allow the menu to open when there are no items. */
69
+ allowsEmptyCollection?: boolean;
70
+ /** Whether to allow custom values that don't match any option. */
71
+ allowsCustomValue?: boolean;
72
+ /** What triggers the menu to open. */
73
+ menuTrigger?: MenuTriggerAction;
74
+ /** Whether to close the menu on blur. */
75
+ shouldCloseOnBlur?: boolean;
76
+ }
77
+
78
+ export interface ComboBoxState<T = unknown> {
79
+ /** The collection of items (may be filtered). */
80
+ readonly collection: Accessor<Collection<T>>;
81
+ /** Whether the combobox dropdown is open. */
82
+ readonly isOpen: Accessor<boolean>;
83
+ /** Open the combobox dropdown. */
84
+ open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void;
85
+ /** Close the combobox dropdown. */
86
+ close(): void;
87
+ /** Toggle the combobox dropdown. */
88
+ toggle(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void;
89
+ /** The currently selected key (single mode). */
90
+ readonly selectedKey: Accessor<Key | null>;
91
+ /** The default selected key. */
92
+ readonly defaultSelectedKey: Key | null;
93
+ /** The currently selected item (single mode). */
94
+ readonly selectedItem: Accessor<CollectionNode<T> | null>;
95
+ /** Set the selected key. */
96
+ setSelectedKey(key: Key | null): void;
97
+ /** The currently selected keys (multiple mode). */
98
+ readonly selectedKeys: Accessor<Set<Key>>;
99
+ /** The currently selected items (multiple mode). */
100
+ readonly selectedItems: Accessor<CollectionNode<T>[]>;
101
+ /** Remove a selected key (multiple mode). */
102
+ removeSelectedKey(key: Key): void;
103
+ /** The current input value. */
104
+ readonly inputValue: Accessor<string>;
105
+ /** The default input value. */
106
+ readonly defaultInputValue: string;
107
+ /** Set the input value. */
108
+ setInputValue(value: string): void;
109
+ /** The currently focused key in the list. */
110
+ readonly focusedKey: Accessor<Key | null>;
111
+ /** Set the focused key. */
112
+ setFocusedKey(key: Key | null): void;
113
+ /** Whether the combobox input has focus. */
114
+ readonly isFocused: Accessor<boolean>;
115
+ /** Set whether the combobox has focus. */
116
+ setFocused(isFocused: boolean): void;
117
+ /** The focus strategy to use when opening. */
118
+ readonly focusStrategy: Accessor<FocusStrategy | null>;
119
+ /** Commit the current selection (select focused item or custom value). */
120
+ commit(): void;
121
+ /** Revert input to the selected item's text and close menu. */
122
+ revert(): void;
123
+ /** Whether a specific key is disabled. */
124
+ isKeyDisabled(key: Key): boolean;
125
+ /** Select a key and close the menu (for ListState compatibility). */
126
+ select(key: Key): void;
127
+ /** The selection mode. */
128
+ readonly selectionMode: Accessor<"single" | "multiple">;
129
+ /** Check if a key is selected. */
130
+ isSelected(key: Key): boolean;
131
+ /** Whether the combobox is disabled. */
132
+ readonly isDisabled: boolean;
133
+ /** Whether the combobox is read-only. */
134
+ readonly isReadOnly: boolean;
135
+ /** Whether the combobox is required. */
136
+ readonly isRequired: boolean;
137
+ }
138
+
139
+ /**
140
+ * Default filter function that does case-insensitive "contains" matching.
141
+ */
142
+ export const defaultContainsFilter: FilterFn = (textValue, inputValue) => {
143
+ return textValue.toLowerCase().includes(inputValue.toLowerCase());
144
+ };
145
+
146
+ /**
147
+ * Creates state for a combobox component.
148
+ * Combines list state with input value management and filtering.
149
+ */
150
+ export function createComboBoxState<T = unknown>(
151
+ props: MaybeAccessor<ComboBoxStateProps<T>>,
152
+ ): ComboBoxState<T> {
153
+ const getProps = () => access(props);
154
+
155
+ // Extract options with defaults
156
+ const menuTrigger = () => getProps().menuTrigger ?? "input";
157
+ const allowsEmptyCollection = () => getProps().allowsEmptyCollection ?? false;
158
+ const allowsCustomValue = () => getProps().allowsCustomValue ?? false;
159
+ const shouldCloseOnBlur = () => getProps().shouldCloseOnBlur ?? true;
160
+ const isMultiple = () => getProps().selectionMode === "multiple";
161
+
162
+ // Track focus strategy for list navigation
163
+ const [focusStrategy, setFocusStrategy] = createSignal<FocusStrategy | null>(null);
164
+
165
+ // Track whether we're showing all items (vs filtered)
166
+ const [showAllItems, setShowAllItems] = createSignal(false);
167
+
168
+ // Track the menu open trigger
169
+ let menuOpenTrigger: MenuTriggerAction = "focus";
170
+
171
+ // ---- Multi-select State ----
172
+ const isMultiSelectionControlled = () => getProps().selectedKeys !== undefined;
173
+ const [internalSelectedKeys, setInternalSelectedKeys] = createSignal<Set<Key>>(
174
+ new Set(getProps().defaultSelectedKeys ?? []),
175
+ );
176
+
177
+ const selectedKeys: Accessor<Set<Key>> = () => {
178
+ return isMultiSelectionControlled()
179
+ ? new Set(getProps().selectedKeys ?? [])
180
+ : internalSelectedKeys();
181
+ };
182
+
183
+ const setSelectedKeys = (keys: Set<Key>) => {
184
+ if (!isMultiSelectionControlled()) {
185
+ setInternalSelectedKeys(new Set(keys));
186
+ }
187
+ getProps().onSelectionChangeMultiple?.(keys);
188
+ };
189
+
190
+ // ---- Selection State (single mode) ----
191
+ // Note: Selection state is initialized first because input value may depend on it
192
+ const isSelectionControlled = () => getProps().selectedKey !== undefined;
193
+ const [internalSelectedKey, setInternalSelectedKey] = createSignal<Key | null>(
194
+ getProps().defaultSelectedKey ?? null,
195
+ );
196
+
197
+ // ---- Input Value State ----
198
+ // Initialized after selection so we can derive from selected item if needed
199
+ const isInputControlled = () => getProps().inputValue !== undefined;
200
+
201
+ // We'll set the proper initial value after collection is created
202
+ const [internalInputValue, setInternalInputValue] = createSignal(
203
+ getProps().defaultInputValue ?? "",
204
+ );
205
+ // Track if we've initialized input from selection
206
+ let inputInitialized = false;
207
+
208
+ const inputValue: Accessor<string> = () => {
209
+ return isInputControlled() ? (getProps().inputValue ?? "") : internalInputValue();
210
+ };
211
+
212
+ const setInputValue = (value: string) => {
213
+ if (!isInputControlled()) {
214
+ setInternalInputValue(value);
215
+ }
216
+ getProps().onInputChange?.(value);
217
+ };
218
+
219
+ // Track last committed input value
220
+ const [lastValue, setLastValue] = createSignal(inputValue());
221
+
222
+ const selectedKey: Accessor<Key | null> = () => {
223
+ return isSelectionControlled() ? (getProps().selectedKey ?? null) : internalSelectedKey();
224
+ };
225
+
226
+ const setSelectedKey = (key: Key | null) => {
227
+ if (!isSelectionControlled()) {
228
+ setInternalSelectedKey(key);
229
+ }
230
+ getProps().onSelectionChange?.(key);
231
+ };
232
+
233
+ // ---- Overlay State ----
234
+ const overlayState = createOverlayTriggerState({
235
+ get isOpen() {
236
+ return getProps().isOpen;
237
+ },
238
+ get defaultOpen() {
239
+ return getProps().defaultOpen;
240
+ },
241
+ onOpenChange(isOpen: boolean) {
242
+ getProps().onOpenChange?.(isOpen, isOpen ? menuOpenTrigger : undefined);
243
+ },
244
+ });
245
+
246
+ // ---- List State (unfiltered collection) ----
247
+ const listState = createListState<T>({
248
+ get items() {
249
+ // Use items or defaultItems
250
+ return getProps().items ?? getProps().defaultItems ?? [];
251
+ },
252
+ get getKey() {
253
+ return getProps().getKey;
254
+ },
255
+ get getTextValue() {
256
+ return getProps().getTextValue;
257
+ },
258
+ get getDisabled() {
259
+ return getProps().getDisabled;
260
+ },
261
+ get disabledKeys() {
262
+ return getProps().disabledKeys;
263
+ },
264
+ get selectionMode() {
265
+ return isMultiple() ? ("multiple" as const) : ("single" as const);
266
+ },
267
+ disallowEmptySelection: false,
268
+ get selectedKeys() {
269
+ if (isMultiple()) {
270
+ return Array.from(selectedKeys());
271
+ }
272
+ const key = selectedKey();
273
+ return key != null ? [key] : [];
274
+ },
275
+ onSelectionChange(keys) {
276
+ if (keys === "all") return;
277
+
278
+ if (isMultiple()) {
279
+ // In multiple mode, toggle selections
280
+ const newKeys = new Set(keys);
281
+ setSelectedKeys(newKeys);
282
+ return;
283
+ }
284
+
285
+ const key = keys.size > 0 ? Array.from(keys)[0] : null;
286
+
287
+ // If same key selected, just reset input and close
288
+ if (key === selectedKey()) {
289
+ resetInputValue();
290
+ closeMenu();
291
+ return;
292
+ }
293
+
294
+ setSelectedKey(key);
295
+ },
296
+ });
297
+
298
+ // ---- Filtered Collection ----
299
+ const originalCollection = listState.collection;
300
+
301
+ const filteredCollection = createMemo<Collection<T>>(() => {
302
+ const collection = originalCollection();
303
+ const input = inputValue();
304
+ const filter = getProps().defaultFilter;
305
+
306
+ // If no filter function provided, return original collection
307
+ if (!filter) {
308
+ return collection;
309
+ }
310
+
311
+ // Filter the collection based on input value
312
+ return filterCollection(collection, input, filter);
313
+ });
314
+
315
+ // The displayed collection depends on showAllItems flag
316
+ // Always show filtered collection (or all items if showAllItems is true)
317
+ const displayedCollection = createMemo<Collection<T>>(() => {
318
+ return showAllItems() ? originalCollection() : filteredCollection();
319
+ });
320
+
321
+ // ---- Selected Item(s) ----
322
+ const selectedItem: Accessor<CollectionNode<T> | null> = () => {
323
+ const key = selectedKey();
324
+ if (key == null) return null;
325
+ return originalCollection().getItem(key);
326
+ };
327
+
328
+ const selectedItems = createMemo<CollectionNode<T>[]>(() => {
329
+ const keys = selectedKeys();
330
+ const collection = originalCollection();
331
+ const items: CollectionNode<T>[] = [];
332
+ for (const key of keys) {
333
+ const item = collection.getItem(key);
334
+ if (item) items.push(item);
335
+ }
336
+ return items;
337
+ });
338
+
339
+ // Initialize input value from selected item if not already set
340
+ // This runs once on creation
341
+ if (!inputInitialized && !isInputControlled()) {
342
+ const defaultKey = getProps().defaultSelectedKey;
343
+ if (defaultKey != null && !getProps().defaultInputValue) {
344
+ // Get the text value from the collection for the default selected key
345
+ const item = originalCollection().getItem(defaultKey);
346
+ if (item) {
347
+ setInternalInputValue(item.textValue);
348
+ setLastValue(item.textValue);
349
+ }
350
+ }
351
+ inputInitialized = true;
352
+ }
353
+
354
+ // ---- Helper Functions ----
355
+ const resetInputValue = () => {
356
+ const item = selectedItem();
357
+ const textValue = item?.textValue ?? "";
358
+ setLastValue(textValue);
359
+ setInputValue(textValue);
360
+ };
361
+
362
+ const closeMenu = () => {
363
+ if (overlayState.isOpen()) {
364
+ overlayState.close();
365
+ }
366
+ };
367
+
368
+ // ---- Open/Toggle Logic ----
369
+ const open = (strategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => {
370
+ const displayAll = trigger === "manual" || (trigger === "focus" && menuTrigger() === "focus");
371
+
372
+ // Check if we should open
373
+ const filtered = filteredCollection();
374
+ const original = originalCollection();
375
+ const canOpen =
376
+ allowsEmptyCollection() || filtered.size > 0 || (displayAll && original.size > 0);
377
+
378
+ if (!canOpen) return;
379
+
380
+ if (displayAll && !overlayState.isOpen()) {
381
+ setShowAllItems(true);
382
+ }
383
+
384
+ menuOpenTrigger = trigger ?? "focus";
385
+ setFocusStrategy(strategy);
386
+ if (!overlayState.isOpen()) {
387
+ const key = selectedKey();
388
+ const nextFocusedKey =
389
+ key != null && !listState.isDisabled(key)
390
+ ? key
391
+ : strategy === "first"
392
+ ? displayedCollection().getFirstKey()
393
+ : strategy === "last"
394
+ ? displayedCollection().getLastKey()
395
+ : null;
396
+ listState.setFocused(true);
397
+ listState.setFocusedKey(nextFocusedKey);
398
+ }
399
+ overlayState.open();
400
+ };
401
+
402
+ const toggle = (strategy: FocusStrategy | null = null, trigger?: MenuTriggerAction) => {
403
+ const displayAll = trigger === "manual" || (trigger === "focus" && menuTrigger() === "focus");
404
+
405
+ // Check if we can open (if closed)
406
+ const filtered = filteredCollection();
407
+ const original = originalCollection();
408
+ const canOpen =
409
+ allowsEmptyCollection() || filtered.size > 0 || (displayAll && original.size > 0);
410
+
411
+ if (!canOpen && !overlayState.isOpen()) return;
412
+
413
+ if (displayAll && !overlayState.isOpen()) {
414
+ setShowAllItems(true);
415
+ }
416
+
417
+ if (!overlayState.isOpen()) {
418
+ menuOpenTrigger = trigger ?? "focus";
419
+ }
420
+
421
+ setFocusStrategy(strategy);
422
+ overlayState.toggle();
423
+ };
424
+
425
+ // ---- Commit/Revert Logic ----
426
+ const commitCustomValue = () => {
427
+ setSelectedKey(null);
428
+ closeMenu();
429
+ };
430
+
431
+ const commitSelection = () => {
432
+ // If both are controlled, just call onSelectionChange
433
+ if (isSelectionControlled() && isInputControlled()) {
434
+ getProps().onSelectionChange?.(selectedKey());
435
+ const item = selectedItem();
436
+ setLastValue(item?.textValue ?? "");
437
+ closeMenu();
438
+ } else {
439
+ // Reset input to selected item's text
440
+ resetInputValue();
441
+ closeMenu();
442
+ }
443
+ };
444
+
445
+ const commitValue = () => {
446
+ if (allowsCustomValue()) {
447
+ const item = selectedItem();
448
+ const itemText = item?.textValue ?? "";
449
+ if (inputValue() === itemText) {
450
+ commitSelection();
451
+ } else {
452
+ commitCustomValue();
453
+ }
454
+ } else {
455
+ commitSelection();
456
+ }
457
+ };
458
+
459
+ const commit = () => {
460
+ const focusedKey = listState.focusedKey();
461
+
462
+ if (isMultiple()) {
463
+ // In multiple mode, toggle the focused item without closing
464
+ if (overlayState.isOpen() && focusedKey != null) {
465
+ select(focusedKey);
466
+ }
467
+ return;
468
+ }
469
+
470
+ if (overlayState.isOpen() && focusedKey != null) {
471
+ // If focused key is already selected, just commit
472
+ if (selectedKey() === focusedKey) {
473
+ commitSelection();
474
+ } else {
475
+ // Select the focused item
476
+ setSelectedKey(focusedKey);
477
+ closeMenu();
478
+ }
479
+ } else {
480
+ commitValue();
481
+ }
482
+ };
483
+
484
+ const revert = () => {
485
+ if (allowsCustomValue() && selectedKey() == null) {
486
+ commitCustomValue();
487
+ } else {
488
+ commitSelection();
489
+ }
490
+ };
491
+
492
+ // ---- Focus Handling ----
493
+ const [isFocused, setIsFocused] = createSignal(false);
494
+ let valueOnFocus = "";
495
+
496
+ const setFocused = (focused: boolean) => {
497
+ if (focused) {
498
+ valueOnFocus = inputValue();
499
+ if (menuTrigger() === "focus" && !getProps().isReadOnly) {
500
+ open(null, "focus");
501
+ }
502
+ } else {
503
+ if (shouldCloseOnBlur()) {
504
+ commitValue();
505
+ }
506
+ }
507
+ setIsFocused(focused);
508
+ };
509
+
510
+ // ---- Effects for Auto Open/Close ----
511
+ createEffect(() => {
512
+ const input = inputValue();
513
+ const filtered = filteredCollection();
514
+ const isOpen = overlayState.isOpen();
515
+ const last = lastValue();
516
+ const focused = isFocused();
517
+
518
+ // Auto-open when typing
519
+ if (
520
+ focused &&
521
+ (filtered.size > 0 || allowsEmptyCollection()) &&
522
+ !isOpen &&
523
+ input !== last &&
524
+ menuTrigger() !== "manual"
525
+ ) {
526
+ open(null, "input");
527
+ }
528
+
529
+ // Auto-close when empty (unless showing all)
530
+ if (!showAllItems() && !allowsEmptyCollection() && isOpen && filtered.size === 0) {
531
+ closeMenu();
532
+ }
533
+
534
+ // Clear focused key when input changes
535
+ if (input !== last) {
536
+ listState.setFocusedKey(null);
537
+ setShowAllItems(false);
538
+
539
+ // Clear selection when input is cleared (if not fully controlled)
540
+ if (input === "" && (!isInputControlled() || !isSelectionControlled())) {
541
+ setSelectedKey(null);
542
+ }
543
+
544
+ setLastValue(input);
545
+ }
546
+ });
547
+
548
+ // Update input when selection changes (only in single mode)
549
+ createEffect(() => {
550
+ if (isMultiple()) return;
551
+ const key = selectedKey();
552
+ const item = key != null ? originalCollection().getItem(key) : null;
553
+ const textValue = item?.textValue ?? "";
554
+
555
+ // Only update if selection changed and not fully controlled
556
+ if (!isInputControlled() || !isSelectionControlled()) {
557
+ if (key != null && textValue !== inputValue()) {
558
+ setInputValue(textValue);
559
+ setLastValue(textValue);
560
+ }
561
+ }
562
+ });
563
+
564
+ // Close when selection changes (only in single mode)
565
+ createEffect((prevKey: Key | null | undefined) => {
566
+ if (isMultiple()) return undefined;
567
+ const key = selectedKey();
568
+ if (key != null && key !== prevKey) {
569
+ closeMenu();
570
+ }
571
+ return key;
572
+ }, undefined);
573
+
574
+ // ---- Selection Methods for ListState compatibility ----
575
+ // These methods allow createOption to work with ComboBoxState
576
+ const select = (key: Key) => {
577
+ if (isMultiple()) {
578
+ // Toggle selection in multiple mode
579
+ const current = new Set(selectedKeys());
580
+ if (current.has(key)) {
581
+ current.delete(key);
582
+ } else {
583
+ current.add(key);
584
+ }
585
+ setSelectedKeys(current);
586
+ // Don't close menu and don't reset input in multi-select mode
587
+ } else {
588
+ setSelectedKey(key);
589
+ closeMenu();
590
+ }
591
+ };
592
+
593
+ const removeSelectedKey = (key: Key) => {
594
+ if (isMultiple()) {
595
+ const current = new Set(selectedKeys());
596
+ current.delete(key);
597
+ setSelectedKeys(current);
598
+ }
599
+ };
600
+
601
+ const selectionMode: Accessor<"single" | "multiple"> = () => getProps().selectionMode ?? "single";
602
+
603
+ const isSelected = (key: Key) => {
604
+ if (isMultiple()) {
605
+ return selectedKeys().has(key);
606
+ }
607
+ return selectedKey() === key;
608
+ };
609
+
610
+ // ---- Return State ----
611
+ return {
612
+ collection: displayedCollection,
613
+ isOpen: overlayState.isOpen,
614
+ open,
615
+ close: commitValue,
616
+ toggle,
617
+ selectedKey,
618
+ defaultSelectedKey: getProps().defaultSelectedKey ?? null,
619
+ selectedItem,
620
+ setSelectedKey,
621
+ selectedKeys,
622
+ selectedItems,
623
+ removeSelectedKey,
624
+ inputValue,
625
+ defaultInputValue: getProps().defaultInputValue ?? "",
626
+ setInputValue,
627
+ focusedKey: listState.focusedKey,
628
+ setFocusedKey: listState.setFocusedKey,
629
+ isFocused,
630
+ setFocused,
631
+ focusStrategy,
632
+ commit,
633
+ revert,
634
+ // Selection state methods for ListState compatibility
635
+ select,
636
+ selectionMode,
637
+ isSelected,
638
+ isKeyDisabled: (key: Key) => listState.isDisabled(key),
639
+ get isDisabled() {
640
+ return getProps().isDisabled ?? false;
641
+ },
642
+ get isReadOnly() {
643
+ return getProps().isReadOnly ?? false;
644
+ },
645
+ get isRequired() {
646
+ return getProps().isRequired ?? false;
647
+ },
648
+ };
649
+ }
650
+
651
+ /**
652
+ * Filter a collection based on input value.
653
+ */
654
+ function filterCollection<T>(
655
+ collection: Collection<T>,
656
+ inputValue: string,
657
+ filter: FilterFn,
658
+ ): Collection<T> {
659
+ if (!inputValue) {
660
+ return collection;
661
+ }
662
+
663
+ const filteredItems: CollectionNode<T>[] = [];
664
+
665
+ for (const item of collection) {
666
+ if (item.type === "section") {
667
+ // Filter section children
668
+ const filteredChildren: CollectionNode<T>[] = [];
669
+ if (item.childNodes) {
670
+ for (const child of item.childNodes) {
671
+ if (child.type === "item" && filter(child.textValue, inputValue)) {
672
+ filteredChildren.push(child);
673
+ }
674
+ }
675
+ }
676
+ // Only include section if it has matching children
677
+ if (filteredChildren.length > 0) {
678
+ filteredItems.push({
679
+ ...item,
680
+ childNodes: filteredChildren,
681
+ });
682
+ }
683
+ } else if (item.type === "item") {
684
+ if (filter(item.textValue, inputValue)) {
685
+ filteredItems.push(item);
686
+ }
687
+ }
688
+ }
689
+
690
+ // Create a new collection from filtered items
691
+ return createFilteredCollection(filteredItems, collection);
692
+ }
693
+
694
+ /**
695
+ * Create a filtered collection wrapper.
696
+ */
697
+ function createFilteredCollection<T>(
698
+ items: CollectionNode<T>[],
699
+ original: Collection<T>,
700
+ ): Collection<T> {
701
+ const itemMap = new Map<Key, CollectionNode<T>>();
702
+
703
+ for (const item of items) {
704
+ itemMap.set(item.key, item);
705
+ if (item.childNodes) {
706
+ for (const child of item.childNodes) {
707
+ itemMap.set(child.key, child);
708
+ }
709
+ }
710
+ }
711
+
712
+ return {
713
+ get size() {
714
+ let count = 0;
715
+ for (const item of items) {
716
+ if (item.type === "item") {
717
+ count++;
718
+ } else if (item.childNodes) {
719
+ count += Array.from(item.childNodes).filter((c) => c.type === "item").length;
720
+ }
721
+ }
722
+ return count;
723
+ },
724
+ getItem(key: Key) {
725
+ return itemMap.get(key) ?? null;
726
+ },
727
+ getKeys() {
728
+ return itemMap.keys();
729
+ },
730
+ getFirstKey() {
731
+ for (const item of items) {
732
+ if (item.type === "item") return item.key;
733
+ if (item.childNodes) {
734
+ for (const child of item.childNodes) {
735
+ if (child.type === "item") return child.key;
736
+ }
737
+ }
738
+ }
739
+ return null;
740
+ },
741
+ getLastKey() {
742
+ for (let i = items.length - 1; i >= 0; i--) {
743
+ const item = items[i];
744
+ if (item.type === "item") return item.key;
745
+ if (item.childNodes) {
746
+ const children = Array.from(item.childNodes);
747
+ for (let j = children.length - 1; j >= 0; j--) {
748
+ if (children[j].type === "item") return children[j].key;
749
+ }
750
+ }
751
+ }
752
+ return null;
753
+ },
754
+ getKeyBefore(key: Key) {
755
+ return original.getKeyBefore(key);
756
+ },
757
+ getKeyAfter(key: Key) {
758
+ return original.getKeyAfter(key);
759
+ },
760
+ at(index: number) {
761
+ // Flatten items for indexing
762
+ let currentIndex = 0;
763
+ for (const item of items) {
764
+ if (item.type === "item") {
765
+ if (currentIndex === index) return item;
766
+ currentIndex++;
767
+ } else if (item.childNodes) {
768
+ for (const child of item.childNodes) {
769
+ if (child.type === "item") {
770
+ if (currentIndex === index) return child;
771
+ currentIndex++;
772
+ }
773
+ }
774
+ }
775
+ }
776
+ return null;
777
+ },
778
+ getChildren(key: Key) {
779
+ const item = itemMap.get(key);
780
+ return item?.childNodes ?? [];
781
+ },
782
+ getTextValue(key: Key) {
783
+ const item = itemMap.get(key);
784
+ return item?.textValue ?? "";
785
+ },
786
+ [Symbol.iterator]() {
787
+ return items[Symbol.iterator]();
788
+ },
789
+ };
790
+ }