@proyecto-viviana/solid-stately 0.6.0-rc.0 → 0.6.0-rc.1

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 (127) hide show
  1. package/README.md +59 -29
  2. package/dist/calendar/createCalendarState.d.ts +3 -1
  3. package/dist/calendar/createCalendarState.d.ts.map +1 -1
  4. package/dist/calendar/createDatePickerState.d.ts +2 -0
  5. package/dist/calendar/createDatePickerState.d.ts.map +1 -1
  6. package/dist/calendar/createRangeCalendarState.d.ts +2 -0
  7. package/dist/calendar/createRangeCalendarState.d.ts.map +1 -1
  8. package/dist/calendar/createTimeFieldState.d.ts +1 -1
  9. package/dist/calendar/createTimeFieldState.d.ts.map +1 -1
  10. package/dist/checkbox/createCheckboxGroupState.d.ts +2 -0
  11. package/dist/checkbox/createCheckboxGroupState.d.ts.map +1 -1
  12. package/dist/collections/createListState.d.ts.map +1 -1
  13. package/dist/collections/createMenuState.d.ts +46 -2
  14. package/dist/collections/createMenuState.d.ts.map +1 -1
  15. package/dist/collections/createSelectionState.d.ts.map +1 -1
  16. package/dist/collections/index.d.ts +1 -1
  17. package/dist/collections/index.d.ts.map +1 -1
  18. package/dist/color/createColorAreaState.d.ts +4 -0
  19. package/dist/color/createColorAreaState.d.ts.map +1 -1
  20. package/dist/color/createColorFieldState.d.ts +2 -0
  21. package/dist/color/createColorFieldState.d.ts.map +1 -1
  22. package/dist/color/createColorSliderState.d.ts +2 -0
  23. package/dist/color/createColorSliderState.d.ts.map +1 -1
  24. package/dist/color/createColorWheelState.d.ts.map +1 -1
  25. package/dist/combobox/createComboBoxState.d.ts +90 -23
  26. package/dist/combobox/createComboBoxState.d.ts.map +1 -1
  27. package/dist/combobox/index.d.ts +1 -1
  28. package/dist/combobox/index.d.ts.map +1 -1
  29. package/dist/data/createAsyncList.d.ts.map +1 -1
  30. package/dist/data/createListData.d.ts.map +1 -1
  31. package/dist/data/createTreeData.d.ts.map +1 -1
  32. package/dist/dnd/createDraggableCollectionState.d.ts +5 -1
  33. package/dist/dnd/createDraggableCollectionState.d.ts.map +1 -1
  34. package/dist/dnd/createDroppableCollectionState.d.ts +6 -0
  35. package/dist/dnd/createDroppableCollectionState.d.ts.map +1 -1
  36. package/dist/form/createFormValidationState.d.ts.map +1 -1
  37. package/dist/grid/createGridState.d.ts +2 -2
  38. package/dist/grid/createGridState.d.ts.map +1 -1
  39. package/dist/index.d.ts +5 -5
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1012 -471
  42. package/dist/index.js.map +1 -1
  43. package/dist/menu/index.d.ts +2 -1
  44. package/dist/menu/index.d.ts.map +1 -1
  45. package/dist/numberfield/createNumberFieldState.d.ts +6 -3
  46. package/dist/numberfield/createNumberFieldState.d.ts.map +1 -1
  47. package/dist/private/flags/flags.d.ts.map +1 -0
  48. package/dist/private/flags/flags.js +1 -1
  49. package/dist/private/flags/flags.js.map +1 -1
  50. package/dist/radio/createRadioGroupState.d.ts.map +1 -1
  51. package/dist/select/createSelectState.d.ts +85 -29
  52. package/dist/select/createSelectState.d.ts.map +1 -1
  53. package/dist/select/index.d.ts +1 -1
  54. package/dist/select/index.d.ts.map +1 -1
  55. package/dist/selection/SelectionManager.d.ts.map +1 -1
  56. package/dist/selection/createMultipleSelectionState.d.ts.map +1 -1
  57. package/dist/slider/createSliderState.d.ts +35 -9
  58. package/dist/slider/createSliderState.d.ts.map +1 -1
  59. package/dist/ssr/index.d.ts +0 -6
  60. package/dist/ssr/index.d.ts.map +1 -1
  61. package/dist/steplist/createStepListState.d.ts +8 -13
  62. package/dist/steplist/createStepListState.d.ts.map +1 -1
  63. package/dist/table/createTableColumnResizeState.d.ts.map +1 -1
  64. package/dist/table/createTableState.d.ts +2 -2
  65. package/dist/table/createTableState.d.ts.map +1 -1
  66. package/dist/table/createTreeGridState.d.ts +2 -2
  67. package/dist/table/createTreeGridState.d.ts.map +1 -1
  68. package/dist/table/types.d.ts +2 -2
  69. package/dist/table/types.d.ts.map +1 -1
  70. package/dist/tabs/createTabListState.d.ts.map +1 -1
  71. package/dist/tokenfield/TokenFieldValue.d.ts +28 -4
  72. package/dist/tokenfield/TokenFieldValue.d.ts.map +1 -1
  73. package/dist/tokenfield/index.d.ts +1 -1
  74. package/dist/tokenfield/index.d.ts.map +1 -1
  75. package/dist/tree/createTreeState.d.ts +2 -2
  76. package/dist/tree/createTreeState.d.ts.map +1 -1
  77. package/dist/utils/index.d.ts +1 -1
  78. package/dist/utils/index.d.ts.map +1 -1
  79. package/dist/utils/reactivity.d.ts +5 -0
  80. package/dist/utils/reactivity.d.ts.map +1 -1
  81. package/package.json +2 -2
  82. package/src/calendar/createCalendarState.ts +139 -58
  83. package/src/calendar/createDatePickerState.ts +14 -11
  84. package/src/calendar/createRangeCalendarState.ts +43 -17
  85. package/src/calendar/createTimeFieldState.ts +1 -1
  86. package/src/checkbox/createCheckboxGroupState.ts +16 -0
  87. package/src/collections/createListState.ts +4 -4
  88. package/src/collections/createMenuState.ts +134 -4
  89. package/src/collections/createSelectionState.ts +2 -2
  90. package/src/collections/index.ts +4 -0
  91. package/src/color/createColorAreaState.ts +19 -0
  92. package/src/color/createColorFieldState.ts +76 -26
  93. package/src/color/createColorSliderState.ts +25 -2
  94. package/src/color/createColorWheelState.ts +9 -5
  95. package/src/combobox/createComboBoxState.ts +371 -121
  96. package/src/combobox/index.ts +4 -0
  97. package/src/data/createAsyncList.ts +19 -4
  98. package/src/data/createListData.ts +8 -2
  99. package/src/data/createTreeData.ts +11 -3
  100. package/src/dnd/createDraggableCollectionState.ts +8 -1
  101. package/src/dnd/createDroppableCollectionState.ts +10 -0
  102. package/src/form/createFormValidationState.ts +17 -3
  103. package/src/grid/createGridState.ts +15 -8
  104. package/src/index.ts +18 -2
  105. package/src/menu/index.ts +5 -0
  106. package/src/numberfield/createNumberFieldState.ts +97 -35
  107. package/src/radio/createRadioGroupState.ts +4 -1
  108. package/src/select/createSelectState.ts +253 -131
  109. package/src/select/index.ts +8 -1
  110. package/src/selection/SelectionManager.ts +3 -1
  111. package/src/selection/createMultipleSelectionState.ts +2 -2
  112. package/src/slider/createSliderState.ts +227 -103
  113. package/src/ssr/index.ts +11 -4
  114. package/src/steplist/createStepListState.ts +156 -123
  115. package/src/table/createTableColumnResizeState.ts +51 -11
  116. package/src/table/createTableState.ts +5 -4
  117. package/src/table/createTreeGridState.ts +3 -4
  118. package/src/table/types.ts +2 -2
  119. package/src/tabs/createTabListState.ts +8 -31
  120. package/src/tokenfield/TokenFieldValue.ts +75 -16
  121. package/src/tokenfield/index.ts +1 -0
  122. package/src/tree/createTreeState.ts +15 -8
  123. package/src/utils/index.ts +1 -0
  124. package/src/utils/reactivity.ts +8 -0
  125. package/dist/flags/flags.d.ts.map +0 -1
  126. /package/dist/{flags → private/flags}/flags.d.ts +0 -0
  127. /package/src/{flags → private/flags}/flags.ts +0 -0
@@ -22,12 +22,17 @@
22
22
 
23
23
  import { createMemo, createEffect, untrack } from "solid-js";
24
24
  import type { Accessor } from "solid-js";
25
- import { createInternalSignal, access, type MaybeAccessor } from "../utils";
25
+ import { createInternalSignal, propsAccessor, type MaybeAccessor } from "../utils";
26
26
  import { createListState, type ListState } from "../collections/createListState";
27
27
  import { createOverlayTriggerState } from "../overlays";
28
28
  import { ListCollection } from "../collections/ListCollection";
29
29
  import type { Key, CollectionNode, Collection, FocusStrategy } from "../collections/types";
30
30
  import type { SelectionManager } from "../selection/SelectionManager";
31
+ import {
32
+ createFormValidationState,
33
+ type FormValidationState,
34
+ type ValidationFunction,
35
+ } from "../form";
31
36
 
32
37
  export type MenuTriggerAction = "focus" | "input" | "manual";
33
38
 
@@ -35,7 +40,37 @@ export type { FocusStrategy } from "../collections/types";
35
40
 
36
41
  export type FilterFn = (textValue: string, inputValue: string) => boolean;
37
42
 
38
- export interface ComboBoxStateProps<T = unknown> {
43
+ export type ComboBoxSelectionMode = "single" | "multiple";
44
+
45
+ /** Single mode is `Key | null`. Multiple mode is `readonly Key[]`. */
46
+ export type ComboBoxValueType<M extends ComboBoxSelectionMode> = M extends "single"
47
+ ? Key | null
48
+ : readonly Key[];
49
+
50
+ /** `onChange` receives a mutable `Key[]` in multiple mode. */
51
+ export type ComboBoxChangeValueType<M extends ComboBoxSelectionMode> = M extends "single"
52
+ ? Key | null
53
+ : Key[];
54
+
55
+ type ComboBoxValidationType<M extends ComboBoxSelectionMode> = M extends "single"
56
+ ? Key | null
57
+ : Key[];
58
+
59
+ const EMPTY_COMBOBOX_KEYS: readonly Key[] = [];
60
+
61
+ export interface ComboBoxValidationValue<M extends ComboBoxSelectionMode = "single"> {
62
+ /**
63
+ * The selected key in the ComboBox.
64
+ * @deprecated Use `value`.
65
+ */
66
+ selectedKey: Key | null;
67
+ /** The keys of the currently selected items. */
68
+ value: ComboBoxValidationType<M>;
69
+ /** The value of the ComboBox input. */
70
+ inputValue: string;
71
+ }
72
+
73
+ export interface ComboBoxStateProps<T = unknown, M extends ComboBoxSelectionMode = "single"> {
39
74
  /** The items to display in the combobox dropdown. */
40
75
  items?: T[];
41
76
  /** Default items when uncontrolled. */
@@ -48,20 +83,35 @@ export interface ComboBoxStateProps<T = unknown> {
48
83
  getDisabled?: (item: T) => boolean;
49
84
  /** Keys of disabled items. */
50
85
  disabledKeys?: Iterable<Key>;
51
- /** The selection mode for the combobox. */
52
- selectionMode?: "single" | "multiple";
53
- /** The currently selected key (controlled, single mode). */
86
+ /**
87
+ * Whether single or multiple selection is enabled.
88
+ * @default 'single'
89
+ */
90
+ selectionMode?: M;
91
+ /**
92
+ * The current value (controlled).
93
+ * Single mode is `Key | null`. Multiple mode is `readonly Key[]`.
94
+ */
95
+ value?: ComboBoxValueType<M>;
96
+ /** The default value (uncontrolled). */
97
+ defaultValue?: ComboBoxValueType<M>;
98
+ /** Handler called when the value changes. */
99
+ onChange?: (value: ComboBoxChangeValueType<M>) => void;
100
+ /**
101
+ * The currently selected key in the collection (controlled).
102
+ * @deprecated Use `value`.
103
+ */
54
104
  selectedKey?: Key | null;
55
- /** The default selected key (uncontrolled, single mode). */
105
+ /**
106
+ * The initial selected key in the collection (uncontrolled).
107
+ * @deprecated Use `defaultValue`.
108
+ */
56
109
  defaultSelectedKey?: Key | null;
57
- /** The currently selected keys (controlled, multiple mode). */
58
- selectedKeys?: Iterable<Key>;
59
- /** The default selected keys (uncontrolled, multiple mode). */
60
- defaultSelectedKeys?: Iterable<Key>;
61
- /** Handler called when the selection changes (single mode). */
110
+ /**
111
+ * Handler that is called when the selection changes.
112
+ * @deprecated Use `onChange`.
113
+ */
62
114
  onSelectionChange?: (key: Key | null) => void;
63
- /** Handler called when the selection changes (multiple mode). */
64
- onSelectionChangeMultiple?: (keys: Set<Key>) => void;
65
115
  /** The current input value (controlled). */
66
116
  inputValue?: string;
67
117
  /** The default input value (uncontrolled). */
@@ -90,9 +140,25 @@ export interface ComboBoxStateProps<T = unknown> {
90
140
  menuTrigger?: MenuTriggerAction;
91
141
  /** Whether to close the menu on blur. */
92
142
  shouldCloseOnBlur?: boolean;
143
+ /** Whether the combobox is invalid (controlled). */
144
+ isInvalid?: boolean;
145
+ /** @deprecated Use isInvalid instead. */
146
+ validationState?: "valid" | "invalid";
147
+ /** Custom validation function. */
148
+ validate?: ValidationFunction<ComboBoxValidationValue<M> | null>;
149
+ /**
150
+ * Whether to use native HTML form validation or ARIA validation semantics.
151
+ * @default "native"
152
+ */
153
+ validationBehavior?: "aria" | "native";
154
+ /** Field name(s) for server error lookup. */
155
+ name?: string | string[];
93
156
  }
94
157
 
95
- export interface ComboBoxState<T = unknown> {
158
+ export interface ComboBoxState<
159
+ T = unknown,
160
+ M extends ComboBoxSelectionMode = "single",
161
+ > extends FormValidationState {
96
162
  /** The collection of items (may be filtered). */
97
163
  readonly collection: Accessor<Collection<T>>;
98
164
  /**
@@ -109,19 +175,40 @@ export interface ComboBoxState<T = unknown> {
109
175
  close(): void;
110
176
  /** Toggle the combobox dropdown. */
111
177
  toggle(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void;
112
- /** The currently selected key (single mode). */
178
+ /**
179
+ * The key for the first selected item. Null in multiple mode.
180
+ * @deprecated Use `value`.
181
+ */
113
182
  readonly selectedKey: Accessor<Key | null>;
114
- /** The default selected key. */
183
+ /**
184
+ * The default selected key.
185
+ * @deprecated Use `defaultValue`.
186
+ */
115
187
  readonly defaultSelectedKey: Key | null;
116
- /** The currently selected item (single mode). */
188
+ /**
189
+ * The value of the first selected item.
190
+ * @deprecated Use `selectedItems`.
191
+ */
117
192
  readonly selectedItem: Accessor<CollectionNode<T> | null>;
118
- /** Set the selected key. */
193
+ /**
194
+ * Sets the selected key.
195
+ * @deprecated Use `setValue`.
196
+ */
119
197
  setSelectedKey(key: Key | null): void;
120
- /** The currently selected keys (multiple mode). */
198
+ /** The current combobox value. */
199
+ readonly value: Accessor<ComboBoxValueType<M>>;
200
+ /** The default combobox value. */
201
+ readonly defaultValue: ComboBoxValueType<M>;
202
+ /** Sets the combobox value. */
203
+ setValue(value: Key | readonly Key[] | null): void;
204
+ /**
205
+ * Selected keys derived from `value`.
206
+ * Kept for the headless Backspace path and the tag/list adapters.
207
+ */
121
208
  readonly selectedKeys: Accessor<Set<Key>>;
122
- /** Replace the selected keys (multiple mode). */
123
- setSelectedKeys(keys: Set<Key>): void;
124
- /** The currently selected items (multiple mode). */
209
+ /** Replace the selected keys. */
210
+ setSelectedKeys(keys: Iterable<Key>): void;
211
+ /** The currently selected items. */
125
212
  readonly selectedItems: Accessor<CollectionNode<T>[]>;
126
213
  /** Remove a selected key (multiple mode). */
127
214
  removeSelectedKey(key: Key): void;
@@ -150,7 +237,7 @@ export interface ComboBoxState<T = unknown> {
150
237
  /** Select a key and close the menu (for ListState compatibility). */
151
238
  select(key: Key): void;
152
239
  /** The selection mode. */
153
- readonly selectionMode: Accessor<"single" | "multiple">;
240
+ readonly selectionMode: Accessor<M>;
154
241
  /** Check if a key is selected. */
155
242
  isSelected(key: Key): boolean;
156
243
  /** Whether the combobox is disabled. */
@@ -172,17 +259,17 @@ export const defaultContainsFilter: FilterFn = (textValue, inputValue) => {
172
259
  * Creates state for a combobox component.
173
260
  * Combines list state with input value management and filtering.
174
261
  */
175
- export function createComboBoxState<T = unknown>(
176
- props: MaybeAccessor<ComboBoxStateProps<T>>,
177
- ): ComboBoxState<T> {
178
- const getProps = () => access(props);
262
+ export function createComboBoxState<T = unknown, M extends ComboBoxSelectionMode = "single">(
263
+ props: MaybeAccessor<ComboBoxStateProps<T, M>>,
264
+ ): ComboBoxState<T, M> {
265
+ const getProps = propsAccessor(props);
179
266
 
180
267
  // Extract options with defaults
181
268
  const menuTrigger = () => getProps().menuTrigger ?? "input";
182
269
  const allowsEmptyCollection = () => getProps().allowsEmptyCollection ?? false;
183
270
  const allowsCustomValue = () => getProps().allowsCustomValue ?? false;
184
271
  const shouldCloseOnBlur = () => getProps().shouldCloseOnBlur ?? true;
185
- const isMultiple = () => getProps().selectionMode === "multiple";
272
+ const isMultiple = () => (getProps().selectionMode ?? "single") === "multiple";
186
273
 
187
274
  // Track focus strategy for list navigation
188
275
  const [focusStrategy, setFocusStrategy] = createInternalSignal<FocusStrategy | null>(null);
@@ -193,31 +280,37 @@ export function createComboBoxState<T = unknown>(
193
280
  // Track the menu open trigger
194
281
  let menuOpenTrigger: MenuTriggerAction = "focus";
195
282
 
196
- // ---- Multi-select State ----
197
- const isMultiSelectionControlled = () => getProps().selectedKeys !== undefined;
198
- const [internalSelectedKeys, setInternalSelectedKeys] = createInternalSignal<Set<Key>>(
199
- new Set(getProps().defaultSelectedKeys ?? []),
200
- );
283
+ // ---- Value ----
284
+ // One signal matches upstream ValueType<M>. `selectedKey` stays as a
285
+ // deprecated single-mode view. Multiple mode reads and writes `value`.
286
+ type StoredComboBoxValue = Key | readonly Key[] | null;
201
287
 
202
- const selectedKeys: Accessor<Set<Key>> = () => {
203
- return isMultiSelectionControlled()
204
- ? new Set(getProps().selectedKeys ?? [])
205
- : internalSelectedKeys();
288
+ const controlledPropValue = (): StoredComboBoxValue | undefined => {
289
+ const propsNow = getProps();
290
+ if (propsNow.value !== undefined) {
291
+ return propsNow.value as StoredComboBoxValue;
292
+ }
293
+ if (!isMultiple() && propsNow.selectedKey !== undefined) {
294
+ return propsNow.selectedKey;
295
+ }
296
+ return undefined;
206
297
  };
207
298
 
208
- const setSelectedKeys = (keys: Set<Key>) => {
209
- if (!isMultiSelectionControlled()) {
210
- setInternalSelectedKeys(new Set(keys));
299
+ const isValueControlled = () => controlledPropValue() !== undefined;
300
+
301
+ const initialStoredValue = (): StoredComboBoxValue => {
302
+ const propsNow = getProps();
303
+ if (propsNow.defaultValue !== undefined) {
304
+ return propsNow.defaultValue as StoredComboBoxValue;
211
305
  }
212
- getProps().onSelectionChangeMultiple?.(keys);
306
+ if ((propsNow.selectionMode ?? "single") === "multiple") {
307
+ return [];
308
+ }
309
+ return propsNow.defaultSelectedKey ?? null;
213
310
  };
214
311
 
215
- // ---- Selection State (single mode) ----
216
- // Note: Selection state is initialized first because input value may depend on it
217
- const isSelectionControlled = () => getProps().selectedKey !== undefined;
218
- const [internalSelectedKey, setInternalSelectedKey] = createInternalSignal<Key | null>(
219
- getProps().defaultSelectedKey ?? null,
220
- );
312
+ const [internalValue, setInternalValue] =
313
+ createInternalSignal<StoredComboBoxValue>(initialStoredValue());
221
314
 
222
315
  // ---- Input Value State ----
223
316
  // Initialized after selection so we can derive from selected item if needed
@@ -247,20 +340,124 @@ export function createComboBoxState<T = unknown>(
247
340
  // Track last committed input value
248
341
  const [lastValue, setLastValue] = createInternalSignal(inputValue());
249
342
 
343
+ const storedValue = (): StoredComboBoxValue => {
344
+ const controlled = controlledPropValue();
345
+ return controlled !== undefined ? controlled : internalValue();
346
+ };
347
+
348
+ // `Array.isArray` narrows `readonly Key[]` to `any[]` and then fails to
349
+ // exclude that array from the scalar branch. A string/number check keeps
350
+ // the caller's readonly list intact.
351
+ const displayValue = (): Key | readonly Key[] | null => {
352
+ const current = storedValue();
353
+ if (!isMultiple()) {
354
+ return isKeyList(current) ? (current[0] ?? null) : current;
355
+ }
356
+ if (isKeyList(current)) return current;
357
+ if (current == null) return EMPTY_COMBOBOX_KEYS;
358
+ return [current];
359
+ };
360
+
250
361
  const selectedKey: Accessor<Key | null> = () => {
251
- return isSelectionControlled() ? (getProps().selectedKey ?? null) : internalSelectedKey();
362
+ if (isMultiple()) return null;
363
+ const current = displayValue();
364
+ return isKeyList(current) ? (current[0] ?? null) : current;
252
365
  };
253
366
 
254
- const setSelectedKey = (key: Key | null) => {
367
+ const selectedKeys: Accessor<Set<Key>> = () => {
368
+ const current = displayValue();
369
+ if (isKeyList(current)) return new Set(current);
370
+ return current == null ? new Set() : new Set([current]);
371
+ };
372
+
373
+ const setValue = (next: Key | readonly Key[] | null) => {
255
374
  // Solid 2 batches writes onto a microtask, so a fully controlled
256
- // onSelectionChange that updates selectedKey then inputValue settles in
375
+ // onSelectionChange that updates the value then inputValue settles in
257
376
  // one flush instead of closing then auto-opening.
258
- if (!isSelectionControlled()) {
259
- setInternalSelectedKey(key);
377
+ if (!isMultiple()) {
378
+ const key: Key | null =
379
+ typeof next === "string" || typeof next === "number"
380
+ ? next
381
+ : next === null
382
+ ? null
383
+ : (next[0] ?? null);
384
+ const previous = untrack(selectedKey);
385
+ if (!isValueControlled()) {
386
+ setInternalValue(key);
387
+ }
388
+ getProps().onChange?.(key as ComboBoxChangeValueType<M>);
389
+ if (key !== previous) {
390
+ getProps().onSelectionChange?.(key);
391
+ }
392
+ return;
393
+ }
394
+
395
+ // Copy into state so the caller's list is never stored or mutated.
396
+ // `onChange` gets a second array, so a callback can mutate its argument
397
+ // without changing state or the input.
398
+ const keys: Key[] =
399
+ typeof next === "string" || typeof next === "number"
400
+ ? [next]
401
+ : next === null
402
+ ? []
403
+ : [...next];
404
+ if (!isValueControlled()) {
405
+ setInternalValue(keys);
260
406
  }
261
- getProps().onSelectionChange?.(key);
407
+ getProps().onChange?.([...keys] as ComboBoxChangeValueType<M>);
262
408
  };
263
409
 
410
+ const setSelectedKey = (key: Key | null) => {
411
+ setValue(key);
412
+ };
413
+
414
+ const defaultValue: ComboBoxValueType<M> = (() => {
415
+ const propsNow = getProps();
416
+ const resolved =
417
+ propsNow.defaultValue !== undefined
418
+ ? propsNow.defaultValue
419
+ : isMultiple()
420
+ ? (EMPTY_COMBOBOX_KEYS as unknown as ComboBoxValueType<M>)
421
+ : ((propsNow.defaultSelectedKey ?? null) as ComboBoxValueType<M>);
422
+ return (resolved ?? untrack(displayValue)) as ComboBoxValueType<M>;
423
+ })();
424
+
425
+ const defaultSelectedKey =
426
+ getProps().defaultSelectedKey ?? (isMultiple() ? null : (untrack(displayValue) as Key | null));
427
+
428
+ const validationValue = createMemo<ComboBoxValidationValue<M> | null>(() => {
429
+ const dVal = displayValue();
430
+ if (isKeyList(dVal) && dVal.length === 0) {
431
+ return null;
432
+ }
433
+ return {
434
+ inputValue: inputValue(),
435
+ value: (isKeyList(dVal) ? [...dVal] : dVal) as ComboBoxValidationType<M>,
436
+ selectedKey: selectedKey(),
437
+ };
438
+ });
439
+
440
+ const validation = createFormValidationState({
441
+ get value() {
442
+ return validationValue();
443
+ },
444
+ get isInvalid() {
445
+ return getProps().isInvalid;
446
+ },
447
+ get validationState() {
448
+ return getProps().validationState;
449
+ },
450
+ get validate() {
451
+ return getProps().validate;
452
+ },
453
+ get name() {
454
+ return getProps().name;
455
+ },
456
+ get validationBehavior() {
457
+ return getProps().validationBehavior ?? "native";
458
+ },
459
+ });
460
+
264
461
  // ---- Overlay State ----
265
462
  // Assigned after createListState; onOpenChange only fires after both exist
266
463
  // except a defaultOpen init, which we skip via the optional call.
@@ -303,34 +500,37 @@ export function createComboBoxState<T = unknown>(
303
500
  get selectionMode() {
304
501
  return isMultiple() ? ("multiple" as const) : ("single" as const);
305
502
  },
306
- disallowEmptySelection: false,
503
+ // RAC useComboBoxState.ts:254-255. Single selection cannot be toggled
504
+ // empty. Duplicate events re-emit the same key so a click on the selected
505
+ // option reaches the reset-and-close branch below. Multiple selection
506
+ // still allows clearing the last key.
507
+ get disallowEmptySelection() {
508
+ return !isMultiple();
509
+ },
510
+ allowDuplicateSelectionEvents: true,
307
511
  get selectedKeys() {
308
- if (isMultiple()) {
309
- return Array.from(selectedKeys());
310
- }
311
- const key = selectedKey();
312
- return key != null ? [key] : [];
512
+ return convertValue(displayValue());
313
513
  },
314
514
  onSelectionChange(keys) {
315
515
  if (keys === "all") return;
316
516
 
317
517
  if (isMultiple()) {
318
- // In multiple mode, toggle selections
319
- const newKeys = new Set(keys);
320
- setSelectedKeys(newKeys);
518
+ setValue([...keys]);
321
519
  return;
322
520
  }
323
521
 
324
522
  const key = keys.size > 0 ? Array.from(keys)[0] : null;
325
523
 
326
- // If same key selected, just reset input and close
524
+ // RAC useComboBoxState.ts:265-270 — same key still notifies, then
525
+ // resets the input and closes (click on the already selected option).
327
526
  if (key === selectedKey()) {
527
+ getProps().onSelectionChange?.(key);
328
528
  resetInputValue();
329
529
  closeMenu();
330
530
  return;
331
531
  }
332
532
 
333
- setSelectedKey(key);
533
+ setValue(key);
334
534
  },
335
535
  });
336
536
 
@@ -374,13 +574,16 @@ export function createComboBoxState<T = unknown>(
374
574
  // Initialize input value from selected item if not already set
375
575
  // This runs once on creation
376
576
  if (!inputInitialized && !isInputControlled()) {
377
- const defaultKey = getProps().defaultSelectedKey;
378
- if (defaultKey != null && !getProps().defaultInputValue) {
379
- // Get the text value from the collection for the default selected key
380
- const item = originalCollection().getItem(defaultKey);
381
- if (item) {
382
- setInternalInputValue(item.textValue);
383
- setLastValue(item.textValue);
577
+ // RAC getDefaultInputValue: an explicit empty defaultInputValue wins.
578
+ // Multiple mode does not seed the input from the first selected item.
579
+ if (getProps().defaultInputValue == null && !isMultiple()) {
580
+ const key = selectedKey();
581
+ if (key != null) {
582
+ const item = originalCollection().getItem(key);
583
+ if (item) {
584
+ setInternalInputValue(item.textValue);
585
+ setLastValue(item.textValue);
586
+ }
384
587
  }
385
588
  }
386
589
  inputInitialized = true;
@@ -388,8 +591,8 @@ export function createComboBoxState<T = unknown>(
388
591
 
389
592
  // ---- Helper Functions ----
390
593
  const resetInputValue = () => {
391
- const item = selectedItem();
392
- const textValue = item?.textValue ?? "";
594
+ const key = selectedKey();
595
+ const textValue = key != null ? (originalCollection().getItem(key)?.textValue ?? "") : "";
393
596
  setLastValue(textValue);
394
597
  setInputValue(textValue);
395
598
  };
@@ -483,21 +686,35 @@ export function createComboBoxState<T = unknown>(
483
686
 
484
687
  // ---- Commit/Revert Logic ----
485
688
  const commitCustomValue = () => {
486
- setSelectedKey(null);
689
+ if (isMultiple()) {
690
+ // Custom text is independent of the selected items.
691
+ setLastValue(inputValue());
692
+ closeMenu();
693
+ return;
694
+ }
695
+ setValue(null);
487
696
  closeMenu();
488
697
  };
489
698
 
490
- const commitSelection = () => {
491
- // If both are controlled, just call onSelectionChange
492
- if (isSelectionControlled() && isInputControlled()) {
493
- getProps().onSelectionChange?.(selectedKey());
494
- const item = selectedItem();
699
+ const commitSelection = (shouldForceSelectionChange = false) => {
700
+ // Both the value and the input are controlled: notify, then close.
701
+ // Otherwise reset the input for the caller.
702
+ if (isValueControlled() && isInputControlled()) {
703
+ const key = selectedKey();
704
+ const itemText = key != null ? (originalCollection().getItem(key)?.textValue ?? "") : "";
705
+ if (shouldForceSelectionChange || isMultiple() || inputValue() !== itemText) {
706
+ getProps().onSelectionChange?.(key);
707
+ const current = displayValue();
708
+ const changeValue = (
709
+ isKeyList(current) ? [...current] : current
710
+ ) as ComboBoxChangeValueType<M>;
711
+ getProps().onChange?.(changeValue);
712
+ }
495
713
  // RAC useComboBoxState.ts:543-545 — stop the auto-open-on-input effect
496
714
  // from reopening after this close.
497
- setLastValue(item?.textValue ?? "");
715
+ setLastValue(itemText);
498
716
  closeMenu();
499
717
  } else {
500
- // Reset input to selected item's text
501
718
  resetInputValue();
502
719
  closeMenu();
503
720
  }
@@ -520,21 +737,16 @@ export function createComboBoxState<T = unknown>(
520
737
  const commit = () => {
521
738
  const focusedKey = listState.focusedKey();
522
739
 
523
- if (isMultiple()) {
524
- // In multiple mode, toggle the focused item without closing
525
- if (overlayState.isOpen() && focusedKey != null) {
526
- select(focusedKey);
527
- }
528
- return;
529
- }
530
-
531
740
  if (overlayState.isOpen() && focusedKey != null) {
532
- // RAC useComboBoxState.ts:564-572 — already-selected key goes through
533
- // commitSelection. A new key is `selectionManager.select()` without
534
- // close; the open/close effect then closes while the menu is still
535
- // open, so auto-open (input !== last && !isOpen) is skipped.
536
- if (selectedKey() === focusedKey) {
537
- commitSelection();
741
+ // RAC useComboBoxState.ts:564-572 — an already-selected single key goes
742
+ // through commitSelection. A new single key is `selectionManager.select()`
743
+ // without close; the open/close effect then closes while the menu is
744
+ // still open, so auto-open (input !== last && !isOpen) is skipped.
745
+ // Multiple toggles through setValue and leaves the menu open.
746
+ if (!isMultiple() && selectedKey() === focusedKey) {
747
+ commitSelection(true);
748
+ } else if (isMultiple()) {
749
+ select(focusedKey);
538
750
  } else {
539
751
  listState.selectionManager.select(focusedKey);
540
752
  }
@@ -554,12 +766,29 @@ export function createComboBoxState<T = unknown>(
554
766
  // ---- Focus Handling ----
555
767
  const [isFocused, setIsFocused] = createInternalSignal(false);
556
768
 
557
- // RAC useComboBoxState.ts:578-593 also keeps `valueOnFocus` and, on blur,
558
- // calls `validation.commitValidation()` when the input value or the display
559
- // value moved while focused. This hook has no validation state at all, so
560
- // the binding was written and never read; #560 owes the whole wiring.
769
+ let valueOnFocus: [string, Key | readonly Key[] | null] = [
770
+ untrack(inputValue),
771
+ untrack(displayValue) as Key | readonly Key[] | null,
772
+ ];
773
+
774
+ const hasMoved = (a: Key | readonly Key[] | null, b: Key | readonly Key[] | null): boolean => {
775
+ if (a === b) return false;
776
+ if (Array.isArray(a) && Array.isArray(b)) {
777
+ if (a.length !== b.length) return true;
778
+ for (let i = 0; i < a.length; i++) {
779
+ if (a[i] !== b[i]) return true;
780
+ }
781
+ return false;
782
+ }
783
+ return true;
784
+ };
785
+
786
+ // RAC useComboBoxState.ts:578-593 keeps `valueOnFocus = useRef([inputValue, displayValue])`
787
+ // and, on blur, calls `validation.commitValidation()` when the input value or the display
788
+ // value moved while focused.
561
789
  const setFocused = (focused: boolean) => {
562
790
  if (focused) {
791
+ valueOnFocus = [inputValue(), displayValue() as Key | readonly Key[] | null];
563
792
  if (menuTrigger() === "focus" && !getProps().isReadOnly) {
564
793
  open(null, "focus");
565
794
  }
@@ -567,6 +796,12 @@ export function createComboBoxState<T = unknown>(
567
796
  if (shouldCloseOnBlur()) {
568
797
  commitValue();
569
798
  }
799
+ if (
800
+ inputValue() !== valueOnFocus[0] ||
801
+ hasMoved(displayValue() as Key | readonly Key[] | null, valueOnFocus[1])
802
+ ) {
803
+ validation.commitValidation();
804
+ }
570
805
  }
571
806
  setIsFocused(focused);
572
807
  };
@@ -593,7 +828,8 @@ export function createComboBoxState<T = unknown>(
593
828
  trigger: menuTrigger(),
594
829
  showingAll: showAllItems(),
595
830
  inputControlled: isInputControlled(),
596
- selectionControlled: isSelectionControlled(),
831
+ selectionControlled: isValueControlled(),
832
+ multiple: isMultiple(),
597
833
  }),
598
834
  ({
599
835
  input,
@@ -607,6 +843,7 @@ export function createComboBoxState<T = unknown>(
607
843
  showingAll,
608
844
  inputControlled,
609
845
  selectionControlled,
846
+ multiple,
610
847
  }) => {
611
848
  // Auto-open when typing
612
849
  if (
@@ -634,9 +871,10 @@ export function createComboBoxState<T = unknown>(
634
871
  listState.setFocusedKey(null);
635
872
  setShowAllItems(false);
636
873
 
637
- // Clear selection when input is cleared (if not fully controlled)
638
- if (input === "" && (!inputControlled || !selectionControlled)) {
639
- setSelectedKey(null);
874
+ // Clear selection when the input is cleared. Fully controlled single
875
+ // values, and every multiple value, stay put.
876
+ if (!multiple && input === "" && (!inputControlled || !selectionControlled)) {
877
+ setValue(null);
640
878
  }
641
879
 
642
880
  setLastValue(input);
@@ -671,7 +909,7 @@ export function createComboBoxState<T = unknown>(
671
909
  key,
672
910
  textValue: item?.textValue ?? "",
673
911
  inputControlled: isInputControlled(),
674
- selectionControlled: isSelectionControlled(),
912
+ selectionControlled: isValueControlled(),
675
913
  };
676
914
  },
677
915
  (data) => {
@@ -691,49 +929,51 @@ export function createComboBoxState<T = unknown>(
691
929
  // These methods allow createOption to work with ComboBoxState
692
930
  const select = (key: Key) => {
693
931
  if (isMultiple()) {
694
- // Toggle selection in multiple mode
695
932
  const current = new Set(selectedKeys());
696
933
  if (current.has(key)) {
697
934
  current.delete(key);
698
935
  } else {
699
936
  current.add(key);
700
937
  }
701
- setSelectedKeys(current);
702
- // Don't close menu and don't reset input in multi-select mode
938
+ setValue([...current]);
703
939
  } else {
704
- setSelectedKey(key);
940
+ setValue(key);
705
941
  closeMenu();
706
942
  }
707
943
  };
708
944
 
945
+ const setSelectedKeys = (keys: Iterable<Key>) => {
946
+ setValue([...keys]);
947
+ };
948
+
709
949
  const removeSelectedKey = (key: Key) => {
710
- if (isMultiple()) {
711
- const current = new Set(selectedKeys());
712
- current.delete(key);
713
- setSelectedKeys(current);
714
- }
950
+ if (!isMultiple()) return;
951
+ setValue([...selectedKeys()].filter((selected) => selected !== key));
715
952
  };
716
953
 
717
- const selectionMode: Accessor<"single" | "multiple"> = () => getProps().selectionMode ?? "single";
954
+ const selectionMode: Accessor<M> = () => (getProps().selectionMode ?? "single") as M;
718
955
 
719
- const isSelected = (key: Key) => {
720
- if (isMultiple()) {
721
- return selectedKeys().has(key);
722
- }
723
- return selectedKey() === key;
724
- };
956
+ const isSelected = (key: Key) => selectedKeys().has(key);
725
957
 
726
958
  // ---- Return State ----
727
959
  return {
960
+ realtimeValidation: validation.realtimeValidation,
961
+ displayValidation: validation.displayValidation,
962
+ updateValidation: validation.updateValidation,
963
+ resetValidation: validation.resetValidation,
964
+ commitValidation: validation.commitValidation,
728
965
  collection: displayedCollection,
729
966
  isOpen: overlayState.isOpen,
730
967
  open,
731
968
  close: commitValue,
732
969
  toggle,
733
970
  selectedKey,
734
- defaultSelectedKey: getProps().defaultSelectedKey ?? null,
971
+ defaultSelectedKey,
735
972
  selectedItem,
736
973
  setSelectedKey,
974
+ value: () => displayValue() as ComboBoxValueType<M>,
975
+ defaultValue,
976
+ setValue,
737
977
  selectedKeys,
738
978
  setSelectedKeys,
739
979
  selectedItems,
@@ -766,6 +1006,16 @@ export function createComboBoxState<T = unknown>(
766
1006
  };
767
1007
  }
768
1008
 
1009
+ function isKeyList(value: Key | readonly Key[] | null): value is readonly Key[] {
1010
+ return value !== null && typeof value !== "string" && typeof value !== "number";
1011
+ }
1012
+
1013
+ function convertValue(value: Key | readonly Key[] | null | undefined): readonly Key[] {
1014
+ if (value == null) return [];
1015
+ if (typeof value === "string" || typeof value === "number") return [value];
1016
+ return value;
1017
+ }
1018
+
769
1019
  /**
770
1020
  * Filter a collection based on input value.
771
1021
  * RAC `useComboBoxState.ts:644-672` copies nodes into a new `ListCollection`,