@proyecto-viviana/solid-stately 0.3.0 → 0.4.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 (63) hide show
  1. package/dist/calendar/IncompleteDate.d.ts +61 -0
  2. package/dist/calendar/createCalendarState.d.ts +20 -10
  3. package/dist/calendar/createDateFieldState.d.ts +10 -3
  4. package/dist/calendar/createRangeCalendarState.d.ts +10 -2
  5. package/dist/calendar/index.d.ts +1 -1
  6. package/dist/collections/createListState.d.ts +4 -1
  7. package/dist/collections/createSelectionState.d.ts +17 -5
  8. package/dist/collections/getChildNodes.d.ts +11 -0
  9. package/dist/collections/index.d.ts +2 -1
  10. package/dist/combobox/createComboBoxState.d.ts +7 -0
  11. package/dist/dnd/index.d.ts +1 -1
  12. package/dist/dnd/types.d.ts +3 -1
  13. package/dist/grid/types.d.ts +17 -3
  14. package/dist/index.d.ts +6 -6
  15. package/dist/index.js +1570 -425
  16. package/dist/index.js.map +1 -1
  17. package/dist/select/createSelectState.d.ts +6 -0
  18. package/dist/selection/Selection.d.ts +16 -0
  19. package/dist/selection/SelectionManager.d.ts +110 -0
  20. package/dist/selection/createMultipleSelectionState.d.ts +59 -0
  21. package/dist/selection/index.d.ts +1 -0
  22. package/dist/table/TableCollection.d.ts +30 -0
  23. package/dist/table/createTreeGridState.d.ts +15 -0
  24. package/dist/table/index.d.ts +2 -1
  25. package/dist/table/types.d.ts +58 -0
  26. package/dist/toast/createToastState.d.ts +16 -0
  27. package/dist/toast/index.d.ts +1 -1
  28. package/dist/tree/types.d.ts +14 -6
  29. package/package.json +1 -1
  30. package/src/calendar/IncompleteDate.ts +430 -0
  31. package/src/calendar/createCalendarState.ts +104 -37
  32. package/src/calendar/createDateFieldState.ts +262 -261
  33. package/src/calendar/createRangeCalendarState.ts +131 -19
  34. package/src/calendar/index.ts +2 -0
  35. package/src/collections/createListState.ts +50 -19
  36. package/src/collections/createMenuState.ts +3 -0
  37. package/src/collections/createSelectionState.ts +38 -28
  38. package/src/collections/getChildNodes.ts +98 -0
  39. package/src/collections/index.ts +9 -0
  40. package/src/combobox/createComboBoxState.ts +8 -0
  41. package/src/dnd/index.d.ts +1 -0
  42. package/src/dnd/index.ts +1 -0
  43. package/src/dnd/types.d.ts +3 -1
  44. package/src/dnd/types.ts +4 -1
  45. package/src/grid/createGridState.ts +36 -2
  46. package/src/grid/types.ts +17 -3
  47. package/src/index.d.ts +1 -0
  48. package/src/index.ts +11 -0
  49. package/src/select/createSelectState.ts +15 -2
  50. package/src/selection/Selection.ts +28 -0
  51. package/src/selection/SelectionManager.ts +526 -0
  52. package/src/selection/createMultipleSelectionState.ts +247 -0
  53. package/src/selection/index.ts +6 -0
  54. package/src/table/TableCollection.ts +213 -43
  55. package/src/table/createTableState.ts +20 -0
  56. package/src/table/createTreeGridState.ts +146 -0
  57. package/src/table/index.ts +3 -0
  58. package/src/table/types.ts +66 -0
  59. package/src/tabs/createTabListState.ts +11 -9
  60. package/src/toast/createToastState.ts +43 -10
  61. package/src/toast/index.ts +1 -0
  62. package/src/tree/createTreeState.ts +37 -6
  63. package/src/tree/types.ts +14 -6
@@ -21,6 +21,8 @@ import {
21
21
  endOfMonth,
22
22
  startOfWeek,
23
23
  getWeeksInMonth,
24
+ maxDate,
25
+ minDate,
24
26
  DateFormatter,
25
27
  createCalendar as intlCreateCalendar,
26
28
  toCalendar as intlToCalendar,
@@ -68,8 +70,12 @@ export interface RangeCalendarStateProps<T extends DateValue = DateValue> {
68
70
  locale?: MaybeAccessor<string | undefined>;
69
71
  /** Creates a calendar object for a locale calendar identifier. */
70
72
  createCalendar?: (identifier: CalendarIdentifier) => InternationalizedCalendar;
71
- /** Callback to determine if a date is unavailable. */
72
- isDateUnavailable?: (date: DateValue) => boolean;
73
+ /**
74
+ * Callback that determines if a date is unavailable. The second argument is the
75
+ * current selection anchor (the first selected date), so availability can be
76
+ * derived from it. Mirrors @react-stately/calendar 1.18.
77
+ */
78
+ isDateUnavailable?: (date: DateValue, anchorDate: CalendarDate | null) => boolean;
73
79
  /** The number of months to display at once. */
74
80
  visibleMonths?: MaybeAccessor<number | undefined>;
75
81
  /** Controls how the initial visible months are aligned around the current value. */
@@ -95,6 +101,10 @@ export interface RangeCalendarState<T extends DateValue = DateValue> {
95
101
  focusedDate: Accessor<CalendarDate>;
96
102
  /** Sets the focused date. */
97
103
  setFocusedDate: (date: CalendarDate) => void;
104
+ /** The minimum allowed date that a user may select. */
105
+ minValue: Accessor<DateValue | null>;
106
+ /** The maximum allowed date that a user may select. */
107
+ maxValue: Accessor<DateValue | null>;
98
108
  /** The anchor date when selecting a range (first click). */
99
109
  anchorDate: Accessor<CalendarDate | null>;
100
110
  /** Sets the anchor date. */
@@ -189,6 +199,8 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
189
199
  ): RangeCalendarState<T> {
190
200
  const timeZone = getLocalTimeZone();
191
201
  const locale = createMemo(() => access(props.locale) ?? "en-US");
202
+ const minValueState = createMemo(() => access(props.minValue) ?? null);
203
+ const maxValueState = createMemo(() => access(props.maxValue) ?? null);
192
204
  const resolvedOptions = createMemo(() => new DateFormatter(locale()).resolvedOptions());
193
205
  const calendar = createMemo(() =>
194
206
  (props.createCalendar ?? intlCreateCalendar)(resolvedOptions().calendar as CalendarIdentifier),
@@ -200,17 +212,56 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
200
212
  const toDisplayCalendarDate = (date: DateValue): CalendarDate =>
201
213
  intlToCalendar(toCalendarDate(date), calendar());
202
214
 
215
+ // The anchor date is the first selected date during a range drag. Declared up
216
+ // here (ahead of constrainDate) because the available-range derivation below
217
+ // depends on it, and constrainDate consults that range.
218
+ const [anchorDate, setAnchorDate] = createSignal<CalendarDate | null>(null);
219
+
220
+ // Mirrors @react-stately/calendar useRangeCalendarState getAvailableRange: once
221
+ // a range selection is anchored and unavailable dates exist, contiguous ranges
222
+ // are required (unless allowsNonContiguousRanges), so the selectable span is
223
+ // bounded by the nearest unavailable date on each side of the anchor.
224
+ const getAvailableRange = (
225
+ anchor: CalendarDate | null,
226
+ ): { start?: CalendarDate; end?: CalendarDate } | null => {
227
+ if (anchor && props.isDateUnavailable && !props.allowsNonContiguousRanges) {
228
+ const predicate = (date: CalendarDate): boolean => props.isDateUnavailable!(date, anchor);
229
+ return {
230
+ start: nextUnavailableDate(anchor, predicate, visibleMonths, -1),
231
+ end: nextUnavailableDate(anchor, predicate, visibleMonths, 1),
232
+ };
233
+ }
234
+ return null;
235
+ };
236
+
237
+ const availableRange = createMemo(() => getAvailableRange(anchorDate()));
238
+
239
+ // minValue/maxValue narrowed by the available range, in display-calendar space
240
+ // (what every cell/navigation bound below compares against). Mirrors upstream's
241
+ // maxDate(minValue, availableRange?.start) / minDate(maxValue, availableRange?.end);
242
+ // maxDate/minDate ignore undefined operands, so an absent bound passes through.
243
+ const effectiveMinValue = createMemo<CalendarDate | null>(() => {
244
+ const min = access(props.minValue);
245
+ const minCal = min != null ? toDisplayCalendarDate(min) : undefined;
246
+ return maxDate(minCal, availableRange()?.start) ?? null;
247
+ });
248
+ const effectiveMaxValue = createMemo<CalendarDate | null>(() => {
249
+ const max = access(props.maxValue);
250
+ const maxCal = max != null ? toDisplayCalendarDate(max) : undefined;
251
+ return minDate(maxCal, availableRange()?.end) ?? null;
252
+ });
253
+
203
254
  const constrainDate = (date: CalendarDate): CalendarDate => {
204
- const minValue = access(props.minValue);
205
- const maxValue = access(props.maxValue);
255
+ const min = effectiveMinValue();
256
+ const max = effectiveMaxValue();
206
257
 
207
258
  let constrained = date;
208
259
 
209
- if (minValue && constrained.compare(toDisplayCalendarDate(minValue)) < 0) {
210
- constrained = toDisplayCalendarDate(minValue);
260
+ if (min && constrained.compare(min) < 0) {
261
+ constrained = min;
211
262
  }
212
- if (maxValue && constrained.compare(toDisplayCalendarDate(maxValue)) > 0) {
213
- constrained = toDisplayCalendarDate(maxValue);
263
+ if (max && constrained.compare(max) > 0) {
264
+ constrained = max;
214
265
  }
215
266
 
216
267
  return constrained;
@@ -319,7 +370,6 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
319
370
  const [visibleRangeStart, setVisibleRangeStart] = createSignal<CalendarDate>(
320
371
  alignVisibleRangeStart(initialFocusedDate),
321
372
  );
322
- const [anchorDate, setAnchorDate] = createSignal<CalendarDate | null>(null);
323
373
  const [isFocused, setFocused] = createSignal(false);
324
374
  const [isDragging, setDragging] = createSignal(false);
325
375
 
@@ -344,7 +394,27 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
344
394
  const isDisabled = createMemo(() => access(props.isDisabled) ?? false);
345
395
  const isReadOnly = createMemo(() => access(props.isReadOnly) ?? false);
346
396
  const validationState = createMemo(() => access(props.validationState));
347
- const isValueInvalid = createMemo(() => validationState() === "invalid");
397
+ // A committed range is invalid when (not mid-drag) either endpoint is
398
+ // unavailable or falls outside [minValue, maxValue]. Mirrors upstream
399
+ // useRangeCalendarState's isInvalid derivation. Calls props.isDateUnavailable
400
+ // directly (with a null anchor) rather than the closures defined further down,
401
+ // since this memo runs eagerly during init.
402
+ const isInvalidSelection = createMemo(() => {
403
+ const v = sourceValue();
404
+ if (!v || anchorDate()) return false;
405
+ if (props.isDateUnavailable?.(v.start, null) || props.isDateUnavailable?.(v.end, null)) {
406
+ return true;
407
+ }
408
+ const min = access(props.minValue);
409
+ const max = access(props.maxValue);
410
+ const minCal = min != null ? toDisplayCalendarDate(min) : null;
411
+ const maxCal = max != null ? toDisplayCalendarDate(max) : null;
412
+ return (
413
+ isDateOutsideRange(toDisplayCalendarDate(v.start), minCal, maxCal) ||
414
+ isDateOutsideRange(toDisplayCalendarDate(v.end), minCal, maxCal)
415
+ );
416
+ });
417
+ const isValueInvalid = createMemo(() => validationState() === "invalid" || isInvalidSelection());
348
418
 
349
419
  // Highlighted range during selection
350
420
  const highlightedRange = createMemo<RangeValue<CalendarDate> | null>(() => {
@@ -518,7 +588,7 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
518
588
 
519
589
  // Check if a date is unavailable
520
590
  const isCellUnavailable = (date: DateValue): boolean => {
521
- return props.isDateUnavailable?.(date) ?? false;
591
+ return props.isDateUnavailable?.(date, anchorDate()) ?? false;
522
592
  };
523
593
 
524
594
  // Check if a date is disabled
@@ -526,23 +596,23 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
526
596
  if (isDisabled()) return true;
527
597
  if (props.isDateDisabled?.(date)) return true;
528
598
 
529
- const minValue = access(props.minValue);
530
- const maxValue = access(props.maxValue);
599
+ const min = effectiveMinValue();
600
+ const max = effectiveMaxValue();
531
601
  const calDate = toDisplayCalendarDate(date);
532
602
 
533
- if (minValue && calDate.compare(toDisplayCalendarDate(minValue)) < 0) return true;
534
- if (maxValue && calDate.compare(toDisplayCalendarDate(maxValue)) > 0) return true;
603
+ if (min && calDate.compare(min) < 0) return true;
604
+ if (max && calDate.compare(max) > 0) return true;
535
605
 
536
606
  return false;
537
607
  };
538
608
 
539
609
  const isDateOutsideAllowedRange = (date: DateValue): boolean => {
540
- const minValue = access(props.minValue);
541
- const maxValue = access(props.maxValue);
610
+ const min = effectiveMinValue();
611
+ const max = effectiveMaxValue();
542
612
  const calDate = toDisplayCalendarDate(date);
543
613
 
544
- if (minValue && calDate.compare(toDisplayCalendarDate(minValue)) < 0) return true;
545
- if (maxValue && calDate.compare(toDisplayCalendarDate(maxValue)) > 0) return true;
614
+ if (min && calDate.compare(min) < 0) return true;
615
+ if (max && calDate.compare(max) > 0) return true;
546
616
 
547
617
  return false;
548
618
  };
@@ -694,6 +764,8 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
694
764
  visibleRange,
695
765
  timeZone,
696
766
  locale,
767
+ minValue: minValueState,
768
+ maxValue: maxValueState,
697
769
  firstDayOfWeek: firstDayOfWeekName,
698
770
  validationState,
699
771
  isValueInvalid,
@@ -747,3 +819,43 @@ function convertValue(newValue: CalendarDate, oldValue?: DateValue | null): Date
747
819
 
748
820
  return localValue;
749
821
  }
822
+
823
+ /** Whether a date falls outside the closed [minValue, maxValue] interval. */
824
+ function isDateOutsideRange(
825
+ date: CalendarDate,
826
+ minValue?: CalendarDate | null,
827
+ maxValue?: CalendarDate | null,
828
+ ): boolean {
829
+ return (
830
+ (minValue != null && date.compare(minValue) < 0) ||
831
+ (maxValue != null && date.compare(maxValue) > 0)
832
+ );
833
+ }
834
+
835
+ /**
836
+ * Walks outward from the anchor one day at a time in `dir` until it reaches an
837
+ * unavailable date, then returns the last available date before it — or
838
+ * undefined when no unavailable date is found within `visibleMonths` of the
839
+ * anchor (no narrowing needed on that side). Mirrors @react-stately/calendar
840
+ * nextUnavailableDate.
841
+ */
842
+ function nextUnavailableDate(
843
+ anchorDate: CalendarDate,
844
+ isDateUnavailable: (date: CalendarDate) => boolean,
845
+ visibleMonths: number,
846
+ dir: number,
847
+ ): CalendarDate | undefined {
848
+ let nextDate = anchorDate.add({ days: dir });
849
+ const minBound = anchorDate.subtract({ months: visibleMonths });
850
+ const maxBound = anchorDate.add({ months: visibleMonths });
851
+ while (
852
+ (dir < 0 ? nextDate.compare(minBound) >= 0 : nextDate.compare(maxBound) <= 0) &&
853
+ !isDateUnavailable(nextDate)
854
+ ) {
855
+ nextDate = nextDate.add({ days: dir });
856
+ }
857
+ if (isDateUnavailable(nextDate)) {
858
+ return nextDate.add({ days: -dir });
859
+ }
860
+ return undefined;
861
+ }
@@ -2,6 +2,8 @@ export {
2
2
  createCalendarState,
3
3
  type CalendarStateProps,
4
4
  type CalendarState,
5
+ type CalendarSelectionMode,
6
+ type CalendarValueType,
5
7
  type ValidationState,
6
8
  type CalendarPageBehavior,
7
9
  type CalendarSelectionAlignment,
@@ -4,10 +4,12 @@
4
4
  * Based on @react-stately/list.
5
5
  */
6
6
 
7
- import { createSignal, createMemo, type Accessor } from "solid-js";
7
+ import { createMemo, type Accessor } from "solid-js";
8
8
  import { access, type MaybeAccessor } from "../utils";
9
9
  import { ListCollection } from "./ListCollection";
10
- import { createSelectionState, type SelectionState } from "./createSelectionState";
10
+ import type { SelectionState, SelectionPressEvent } from "./createSelectionState";
11
+ import { createMultipleSelectionState } from "../selection/createMultipleSelectionState";
12
+ import { SelectionManager } from "../selection/SelectionManager";
11
13
  import type {
12
14
  Collection,
13
15
  CollectionItemLike,
@@ -51,6 +53,8 @@ export interface ListStateProps<T = unknown> {
51
53
  export interface ListState<T = unknown> extends SelectionState {
52
54
  /** The collection of items. */
53
55
  readonly collection: Accessor<Collection<T>>;
56
+ /** The collection-aware selection manager (single source of truth for selection). */
57
+ readonly selectionManager: SelectionManager<T>;
54
58
  /** Whether the collection is focused. */
55
59
  readonly isFocused: Accessor<boolean>;
56
60
  /** Set the focused state. */
@@ -116,8 +120,9 @@ export function createListState<T = unknown>(
116
120
  return [...new Set([...propsDisabled, ...itemDisabled])];
117
121
  });
118
122
 
119
- // Create selection state
120
- const selectionState = createSelectionState({
123
+ // Raw selection + focus state (the lower layer). This owns the selected keys,
124
+ // focus, selection behavior, and disabled keys, but is not collection-aware.
125
+ const state = createMultipleSelectionState({
121
126
  get selectionMode() {
122
127
  return getProps().selectionMode;
123
128
  },
@@ -147,24 +152,50 @@ export function createListState<T = unknown>(
147
152
  },
148
153
  });
149
154
 
150
- // Focus state
151
- const [isFocused, setIsFocused] = createSignal(false);
152
- const [focusedKey, setFocusedKeyInternal] = createSignal<Key | null>(null);
153
- const [childFocusStrategy, setChildFocusStrategy] = createSignal<FocusStrategy | null>(null);
154
-
155
- const setFocusedKey = (key: Key | null, childStrategy?: FocusStrategy) => {
156
- setFocusedKeyInternal(key);
157
- setChildFocusStrategy(childStrategy ?? null);
158
- };
155
+ // The collection-aware selection manager (the upper layer): the single source
156
+ // of truth for every selection operation. It reads the collection live so it
157
+ // stays reactive as the collection memo recomputes.
158
+ const selectionManager = new SelectionManager<T>(() => collection(), state);
159
159
 
160
160
  return {
161
161
  collection,
162
- isFocused,
163
- setFocused: setIsFocused,
164
- focusedKey,
165
- setFocusedKey,
166
- childFocusStrategy,
167
- ...selectionState,
162
+ selectionManager,
163
+
164
+ // Focus — delegate to the raw state (the single source of truth). The
165
+ // setter routes through the manager so it inherits the existence guard and
166
+ // the upstream 'first' child-focus default.
167
+ isFocused: () => state.isFocused,
168
+ setFocused: (isFocused: boolean) => state.setFocused(isFocused),
169
+ focusedKey: () => state.focusedKey,
170
+ setFocusedKey: (key: Key | null, childStrategy?: FocusStrategy) =>
171
+ selectionManager.setFocusedKey(key, childStrategy),
172
+ childFocusStrategy: () => state.childFocusStrategy,
173
+
174
+ // Flattened selection surface — delegates to the manager so the list path
175
+ // shares one faithful engine. extendSelection keeps its legacy second
176
+ // argument for back-compat, but ignores it (the manager reads the
177
+ // collection itself).
178
+ selectionMode: () => selectionManager.selectionMode,
179
+ selectionBehavior: () => selectionManager.selectionBehavior,
180
+ disallowEmptySelection: () => selectionManager.disallowEmptySelection,
181
+ selectedKeys: () => state.selectedKeys,
182
+ disabledKeys: () => selectionManager.disabledKeys,
183
+ disabledBehavior: () => selectionManager.disabledBehavior,
184
+ isEmpty: () => selectionManager.isEmpty,
185
+ isSelectAll: () => selectionManager.isSelectAll,
186
+ isSelected: (key: Key) => selectionManager.isSelected(key),
187
+ isDisabled: (key: Key) => selectionManager.isDisabled(key),
188
+ setSelectionBehavior: (behavior: SelectionBehavior) =>
189
+ selectionManager.setSelectionBehavior(behavior),
190
+ toggleSelection: (key: Key) => selectionManager.toggleSelection(key),
191
+ replaceSelection: (key: Key) => selectionManager.replaceSelection(key),
192
+ setSelectedKeys: (keys: Iterable<Key>) => selectionManager.setSelectedKeys(keys),
193
+ selectAll: () => selectionManager.selectAll(),
194
+ clearSelection: () => selectionManager.clearSelection(),
195
+ toggleSelectAll: () => selectionManager.toggleSelectAll(),
196
+ extendSelection: (toKey: Key, _collection?: Collection) =>
197
+ selectionManager.extendSelection(toKey),
198
+ select: (key: Key, e?: SelectionPressEvent) => selectionManager.select(key, e),
168
199
  };
169
200
  }
170
201
 
@@ -69,6 +69,9 @@ export function createMenuState<T = unknown>(
69
69
  get selectionBehavior() {
70
70
  return getProps().selectionBehavior;
71
71
  },
72
+ get allowDuplicateSelectionEvents() {
73
+ return getProps().allowDuplicateSelectionEvents;
74
+ },
72
75
  });
73
76
 
74
77
  const close = () => {
@@ -74,11 +74,24 @@ export interface SelectionState {
74
74
  /** Extend selection to a key (for shift-click). */
75
75
  extendSelection(toKey: Key, collection: Collection): void;
76
76
  /** Select a key based on interaction. */
77
- select(
78
- key: Key,
79
- e?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean },
80
- collection?: Collection,
81
- ): void;
77
+ select(key: Key, e?: SelectionPressEvent): void;
78
+ }
79
+
80
+ /**
81
+ * The press/pointer interaction consulted by {@link SelectionState.select}.
82
+ * Mirrors react-stately's `SelectionManager.select(e)`: only `pointerType` is
83
+ * read here (touch / virtual force toggle, since those devices have no modifier
84
+ * keys). Modifier keys (ctrl / meta / shift) are resolved one layer up in the
85
+ * aria-layer `onSelect` (solidaria `selectItem`), matching upstream's split
86
+ * between react-stately and react-aria. The modifier fields are accepted so a
87
+ * full press/pointer event is assignable, but they are not read here.
88
+ */
89
+ export interface SelectionPressEvent {
90
+ pointerType?: "mouse" | "pen" | "touch" | "keyboard" | "virtual";
91
+ shiftKey?: boolean;
92
+ ctrlKey?: boolean;
93
+ metaKey?: boolean;
94
+ altKey?: boolean;
82
95
  }
83
96
 
84
97
  /**
@@ -268,33 +281,30 @@ export function createSelectionState(
268
281
  updateSelection(new Set(rangeKeys));
269
282
  };
270
283
 
271
- const select = (
272
- key: Key,
273
- e?: { shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean },
274
- collection?: Collection,
275
- ) => {
276
- if (isDisabled(key)) return;
284
+ const select = (key: Key, e?: SelectionPressEvent) => {
285
+ // Mirrors react-stately's SelectionManager.select: only pointerType +
286
+ // selectionBehavior are consulted here. Modifier-key handling (ctrl / meta
287
+ // ⇒ toggle, shift ⇒ extend) lives one layer up in solidaria's aria-layer
288
+ // `selectItem`, which is where the platform-aware isCtrlKeyPressed is
289
+ // available. The primitives self-guard disabled keys, so no guard is
290
+ // needed here (matching upstream).
277
291
  if (selectionMode() === "none") return;
278
292
 
279
- const mode = selectionMode();
280
- const behavior = selectionBehavior();
281
-
282
- if (mode === "single") {
283
- if (behavior === "replace" || !isSelected(key)) {
293
+ if (selectionMode() === "single") {
294
+ // Single mode ignores selectionBehavior. Re-selecting the current item
295
+ // deselects it when empty selection is allowed; otherwise it stays
296
+ // selected (replace).
297
+ if (isSelected(key) && !disallowEmptySelection()) {
298
+ toggleSelection(key);
299
+ } else {
284
300
  replaceSelection(key);
285
- } else if (!disallowEmptySelection()) {
286
- clearSelection();
287
301
  }
288
- return;
289
- }
290
-
291
- // Multiple selection
292
- if (e?.shiftKey && collection) {
293
- extendSelection(key, collection);
294
- return;
295
- }
296
-
297
- if (e?.ctrlKey || e?.metaKey || behavior === "toggle") {
302
+ } else if (
303
+ selectionBehavior() === "toggle" ||
304
+ (e != null && (e.pointerType === "touch" || e.pointerType === "virtual"))
305
+ ) {
306
+ // touch / virtual (VoiceOver) have no modifier keys, so multi-select is
307
+ // only reachable by toggling.
298
308
  toggleSelection(key);
299
309
  } else {
300
310
  replaceSelection(key);
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Helpers for traversing collection nodes and comparing their document order.
3
+ *
4
+ * Based on @react-stately/collections.
5
+ */
6
+
7
+ import type { Collection, CollectionNode } from "./types";
8
+
9
+ export function getChildNodes<T>(
10
+ node: CollectionNode<T>,
11
+ collection: Collection<T>,
12
+ ): Iterable<CollectionNode<T>> {
13
+ // New API: call collection.getChildren with the node key.
14
+ if (typeof collection.getChildren === "function") {
15
+ return collection.getChildren(node.key);
16
+ }
17
+
18
+ // Old API: access childNodes directly.
19
+ return node.childNodes;
20
+ }
21
+
22
+ export function getFirstItem<T>(iterable: Iterable<T>): T | undefined {
23
+ return getNthItem(iterable, 0);
24
+ }
25
+
26
+ export function getNthItem<T>(iterable: Iterable<T>, index: number): T | undefined {
27
+ if (index < 0) {
28
+ return undefined;
29
+ }
30
+
31
+ let i = 0;
32
+ for (const item of iterable) {
33
+ if (i === index) {
34
+ return item;
35
+ }
36
+
37
+ i++;
38
+ }
39
+ }
40
+
41
+ export function getLastItem<T>(iterable: Iterable<T>): T | undefined {
42
+ let lastItem: T | undefined = undefined;
43
+ for (const value of iterable) {
44
+ lastItem = value;
45
+ }
46
+
47
+ return lastItem;
48
+ }
49
+
50
+ export function compareNodeOrder<T>(
51
+ collection: Collection<T>,
52
+ a: CollectionNode<T>,
53
+ b: CollectionNode<T>,
54
+ ): number {
55
+ // If the two nodes have the same parent, compare their indices.
56
+ if (a.parentKey === b.parentKey) {
57
+ return a.index - b.index;
58
+ }
59
+
60
+ // Otherwise, collect all of the ancestors from each node, and find the first one that doesn't match starting from the root.
61
+ // Include the base nodes in case we are comparing nodes of different levels so that we can compare the higher node to the lower level node's
62
+ // ancestor of the same level
63
+ const aAncestors = [...getAncestors(collection, a), a];
64
+ const bAncestors = [...getAncestors(collection, b), b];
65
+ const firstNonMatchingAncestor = aAncestors
66
+ .slice(0, bAncestors.length)
67
+ .findIndex((a, i) => a !== bAncestors[i]);
68
+ if (firstNonMatchingAncestor !== -1) {
69
+ // Compare the indices of two children within the common ancestor.
70
+ a = aAncestors[firstNonMatchingAncestor];
71
+ b = bAncestors[firstNonMatchingAncestor];
72
+ return a.index - b.index;
73
+ }
74
+
75
+ // If there isn't a non matching ancestor, we might be in a case where one of the nodes is the ancestor of the other.
76
+ if (aAncestors.findIndex((node) => node === b) >= 0) {
77
+ return 1;
78
+ } else if (bAncestors.findIndex((node) => node === a) >= 0) {
79
+ return -1;
80
+ }
81
+
82
+ // 🤷
83
+ return -1;
84
+ }
85
+
86
+ function getAncestors<T>(collection: Collection<T>, node: CollectionNode<T>): CollectionNode<T>[] {
87
+ const parents: CollectionNode<T>[] = [];
88
+
89
+ let currNode: CollectionNode<T> | null = node;
90
+ while (currNode?.parentKey != null) {
91
+ currNode = collection.getItem(currNode.parentKey);
92
+ if (currNode) {
93
+ parents.unshift(currNode);
94
+ }
95
+ }
96
+
97
+ return parents;
98
+ }
@@ -16,10 +16,19 @@ export type {
16
16
 
17
17
  export { ListCollection, createListCollection } from "./ListCollection";
18
18
 
19
+ export {
20
+ getChildNodes,
21
+ getFirstItem,
22
+ getNthItem,
23
+ getLastItem,
24
+ compareNodeOrder,
25
+ } from "./getChildNodes";
26
+
19
27
  export {
20
28
  createSelectionState,
21
29
  type SelectionStateProps,
22
30
  type SelectionState,
31
+ type SelectionPressEvent,
23
32
  } from "./createSelectionState";
24
33
 
25
34
  export {
@@ -11,6 +11,7 @@ import { access, type MaybeAccessor } from "../utils";
11
11
  import { createListState } from "../collections/createListState";
12
12
  import { createOverlayTriggerState } from "../overlays";
13
13
  import type { Key, CollectionNode, Collection, FocusStrategy } from "../collections/types";
14
+ import type { SelectionManager } from "../selection/SelectionManager";
14
15
 
15
16
  export type MenuTriggerAction = "focus" | "input" | "manual";
16
17
 
@@ -78,6 +79,12 @@ export interface ComboBoxStateProps<T = unknown> {
78
79
  export interface ComboBoxState<T = unknown> {
79
80
  /** The collection of items (may be filtered). */
80
81
  readonly collection: Accessor<Collection<T>>;
82
+ /**
83
+ * The collection-aware selection manager. Mirrors upstream `useComboBoxState`,
84
+ * which returns the `useListState` selection manager bound to the unfiltered
85
+ * collection (selection spans all items; filtering is display-only).
86
+ */
87
+ readonly selectionManager: SelectionManager<T>;
81
88
  /** Whether the combobox dropdown is open. */
82
89
  readonly isOpen: Accessor<boolean>;
83
90
  /** Open the combobox dropdown. */
@@ -624,6 +631,7 @@ export function createComboBoxState<T = unknown>(
624
631
  inputValue,
625
632
  defaultInputValue: getProps().defaultInputValue ?? "",
626
633
  setInputValue,
634
+ selectionManager: listState.selectionManager,
627
635
  focusedKey: listState.focusedKey,
628
636
  setFocusedKey: listState.setFocusedKey,
629
637
  isFocused,
@@ -32,6 +32,7 @@ export type {
32
32
  DroppableCollectionRootDropEvent,
33
33
  DroppableCollectionOnItemDropEvent,
34
34
  DroppableCollectionReorderEvent,
35
+ DragType,
35
36
  DragTypes,
36
37
  DropTargetDelegate,
37
38
  DraggableCollectionStartEvent,
package/src/dnd/index.ts CHANGED
@@ -33,6 +33,7 @@ export type {
33
33
  DroppableCollectionRootDropEvent,
34
34
  DroppableCollectionOnItemDropEvent,
35
35
  DroppableCollectionReorderEvent,
36
+ DragType,
36
37
  DragTypes,
37
38
  DropTargetDelegate,
38
39
  DraggableCollectionStartEvent,
@@ -174,10 +174,12 @@ export interface DroppableCollectionReorderEvent {
174
174
  /** The drop target. */
175
175
  target: ItemDropTarget;
176
176
  }
177
+ /** A drag type: either a MIME type string or a symbol such as the directory drag type. */
178
+ export type DragType = string | symbol;
177
179
  /** Interface for checking drag types. */
178
180
  export interface DragTypes {
179
181
  /** Returns whether the drag contains data of the given type. */
180
- has(type: string | symbol): boolean;
182
+ has(type: DragType | DragType[]): boolean;
181
183
  }
182
184
  /** Delegate for determining drop targets within a collection. */
183
185
  export interface DropTargetDelegate {
package/src/dnd/types.ts CHANGED
@@ -205,10 +205,13 @@ export interface DroppableCollectionReorderEvent {
205
205
  target: ItemDropTarget;
206
206
  }
207
207
 
208
+ /** A drag type: either a MIME type string or a symbol such as the directory drag type. */
209
+ export type DragType = string | symbol;
210
+
208
211
  /** Interface for checking drag types. */
209
212
  export interface DragTypes {
210
213
  /** Returns whether the drag contains data of the given type. */
211
- has(type: string | symbol): boolean;
214
+ has(type: DragType | DragType[]): boolean;
212
215
  }
213
216
 
214
217
  /** Delegate for determining drop targets within a collection. */