@proyecto-viviana/solid-stately 0.2.4 → 0.2.7
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.
- package/LICENSE +21 -0
- package/dist/autocomplete/createAutocompleteState.d.ts +2 -1
- package/dist/checkbox/createCheckboxGroupState.d.ts +10 -1
- package/dist/collections/types.d.ts +11 -0
- package/dist/color/getColorChannels.d.ts +20 -0
- package/dist/data/createAsyncList.d.ts +111 -0
- package/dist/data/createListData.d.ts +65 -0
- package/dist/data/createTreeData.d.ts +61 -0
- package/dist/data/index.d.ts +3 -0
- package/dist/datepicker/index.d.ts +10 -0
- package/dist/grid/types.d.ts +5 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3737 -2697
- package/dist/index.js.map +1 -7
- package/dist/menu/index.d.ts +8 -0
- package/dist/radio/createRadioGroupState.d.ts +10 -1
- package/dist/select/createSelectState.d.ts +17 -0
- package/dist/selection/index.d.ts +11 -0
- package/dist/toast/createToastState.d.ts +7 -1
- package/dist/toggle/createToggleGroupState.d.ts +45 -0
- package/dist/toggle/index.d.ts +1 -0
- package/dist/tree/TreeCollection.d.ts +3 -2
- package/package.json +6 -5
- package/src/autocomplete/createAutocompleteState.ts +10 -11
- package/src/calendar/createDateFieldState.ts +24 -1
- package/src/checkbox/createCheckboxGroupState.ts +42 -6
- package/src/collections/ListCollection.ts +152 -146
- package/src/collections/createListState.ts +266 -264
- package/src/collections/createMenuState.ts +106 -106
- package/src/collections/createSelectionState.ts +336 -336
- package/src/collections/index.ts +46 -46
- package/src/collections/types.ts +181 -169
- package/src/color/Color.ts +951 -951
- package/src/color/createColorAreaState.ts +293 -293
- package/src/color/createColorFieldState.ts +292 -292
- package/src/color/createColorSliderState.ts +241 -241
- package/src/color/createColorWheelState.ts +211 -211
- package/src/color/getColorChannels.ts +34 -0
- package/src/color/index.ts +47 -47
- package/src/color/types.ts +127 -127
- package/src/combobox/createComboBoxState.ts +703 -703
- package/src/combobox/index.ts +13 -13
- package/src/data/createAsyncList.ts +377 -0
- package/src/data/createListData.ts +298 -0
- package/src/data/createTreeData.ts +433 -0
- package/src/data/index.ts +25 -0
- package/src/datepicker/index.ts +36 -0
- package/src/disclosure/createDisclosureState.ts +4 -4
- package/src/dnd/createDragState.ts +153 -153
- package/src/dnd/createDraggableCollectionState.ts +165 -165
- package/src/dnd/createDropState.ts +212 -212
- package/src/dnd/createDroppableCollectionState.ts +357 -357
- package/src/dnd/index.ts +76 -76
- package/src/dnd/types.ts +317 -317
- package/src/form/createFormValidationState.ts +389 -389
- package/src/form/index.ts +15 -15
- package/src/grid/types.ts +5 -0
- package/src/index.ts +49 -0
- package/src/menu/index.ts +19 -0
- package/src/numberfield/createNumberFieldState.ts +427 -383
- package/src/numberfield/index.ts +5 -5
- package/src/overlays/createOverlayTriggerState.ts +67 -67
- package/src/overlays/index.ts +5 -5
- package/src/radio/createRadioGroupState.ts +44 -6
- package/src/searchfield/createSearchFieldState.ts +62 -62
- package/src/searchfield/index.ts +5 -5
- package/src/select/createSelectState.ts +290 -181
- package/src/select/index.ts +5 -5
- package/src/selection/index.ts +28 -0
- package/src/slider/createSliderState.ts +211 -211
- package/src/slider/index.ts +6 -6
- package/src/tabs/createTabListState.ts +37 -11
- package/src/toast/createToastState.d.ts +6 -1
- package/src/toast/createToastState.ts +8 -1
- package/src/toggle/createToggleGroupState.ts +127 -0
- package/src/toggle/index.ts +6 -0
- package/src/tooltip/createTooltipTriggerState.ts +183 -183
- package/src/tooltip/index.ts +6 -6
- package/src/tree/TreeCollection.ts +208 -175
- package/src/tree/createTreeState.ts +392 -392
- package/src/tree/index.ts +13 -13
- package/src/tree/types.ts +174 -174
|
@@ -1,392 +1,392 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tree state management for Tree components.
|
|
3
|
-
* Based on @react-stately/tree/useTreeState.
|
|
4
|
-
*
|
|
5
|
-
* Manages expansion state, selection, and focus for hierarchical tree data.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { createSignal, createEffect, createMemo, on, type Accessor } from 'solid-js';
|
|
9
|
-
import type {
|
|
10
|
-
TreeState,
|
|
11
|
-
TreeStateOptions,
|
|
12
|
-
TreeCollection,
|
|
13
|
-
TreeNode,
|
|
14
|
-
} from './types';
|
|
15
|
-
import type { Key, FocusStrategy } from '../collections/types';
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Creates state management for a tree component.
|
|
19
|
-
* Handles expansion, selection, focus management, and keyboard navigation state.
|
|
20
|
-
*/
|
|
21
|
-
export function createTreeState<T extends object, C extends TreeCollection<T> = TreeCollection<T>>(
|
|
22
|
-
options: Accessor<TreeStateOptions<T, C>>
|
|
23
|
-
): TreeState<T, C> {
|
|
24
|
-
const getOptions = () => options();
|
|
25
|
-
|
|
26
|
-
// Disabled keys as a Set
|
|
27
|
-
const disabledKeys = createMemo(() => {
|
|
28
|
-
const keys = getOptions().disabledKeys;
|
|
29
|
-
return keys ? new Set(keys) : new Set<Key>();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
// Expansion state (uncontrolled)
|
|
33
|
-
const [internalExpandedKeys, setInternalExpandedKeys] = createSignal<Set<Key>>(
|
|
34
|
-
getInitialExpandedKeys(getOptions().defaultExpandedKeys)
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
// Computed expanded keys (controlled or uncontrolled)
|
|
38
|
-
const expandedKeys = createMemo(() => {
|
|
39
|
-
const opts = getOptions();
|
|
40
|
-
if (opts.expandedKeys !== undefined) {
|
|
41
|
-
return new Set(opts.expandedKeys);
|
|
42
|
-
}
|
|
43
|
-
return internalExpandedKeys();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
// Collection - rebuilt when expanded keys change
|
|
47
|
-
const collection = createMemo(() => {
|
|
48
|
-
const opts = getOptions();
|
|
49
|
-
return opts.collectionFactory(expandedKeys());
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
// Focus state
|
|
53
|
-
const [isFocused, setIsFocused] = createSignal(false);
|
|
54
|
-
const [focusedKey, setFocusedKeyInternal] = createSignal<Key | null>(null);
|
|
55
|
-
const [childFocusStrategy, setChildFocusStrategy] = createSignal<FocusStrategy | null>(null);
|
|
56
|
-
const [isKeyboardNavigationDisabled, setKeyboardNavigationDisabled] = createSignal(false);
|
|
57
|
-
|
|
58
|
-
// Selection state
|
|
59
|
-
const [internalSelectedKeys, setInternalSelectedKeys] = createSignal<'all' | Set<Key>>(
|
|
60
|
-
getInitialSelection(getOptions().defaultSelectedKeys)
|
|
61
|
-
);
|
|
62
|
-
const [anchorKey, setAnchorKey] = createSignal<Key | null>(null);
|
|
63
|
-
|
|
64
|
-
// Computed selection
|
|
65
|
-
const selectedKeys = createMemo(() => {
|
|
66
|
-
const opts = getOptions();
|
|
67
|
-
if (opts.selectedKeys !== undefined) {
|
|
68
|
-
return normalizeSelection(opts.selectedKeys);
|
|
69
|
-
}
|
|
70
|
-
return internalSelectedKeys();
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
const selectionMode = createMemo(() => getOptions().selectionMode ?? 'none');
|
|
74
|
-
const selectionBehavior = createMemo(() => getOptions().selectionBehavior ?? 'toggle');
|
|
75
|
-
const disallowEmptySelection = createMemo(() => getOptions().disallowEmptySelection ?? false);
|
|
76
|
-
const disabledBehavior = createMemo(() => getOptions().disabledBehavior ?? 'all');
|
|
77
|
-
|
|
78
|
-
// Set focused key
|
|
79
|
-
const setFocusedKey = (key: Key | null, strategy: FocusStrategy = 'first') => {
|
|
80
|
-
setFocusedKeyInternal(key);
|
|
81
|
-
setChildFocusStrategy(strategy);
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
// Reset focused key if the item is removed from visible collection
|
|
85
|
-
let cachedCollection: C | null = null;
|
|
86
|
-
|
|
87
|
-
createEffect(
|
|
88
|
-
on(collection, (coll) => {
|
|
89
|
-
const currentFocusedKey = focusedKey();
|
|
90
|
-
|
|
91
|
-
if (currentFocusedKey != null && cachedCollection) {
|
|
92
|
-
// Check if the focused item is still visible
|
|
93
|
-
const visibleKeys = [...coll.getKeys()];
|
|
94
|
-
if (!visibleKeys.includes(currentFocusedKey)) {
|
|
95
|
-
// The focused item was collapsed or removed, find a new one to focus
|
|
96
|
-
// Try to find the parent and focus it
|
|
97
|
-
const node = cachedCollection.getItem(currentFocusedKey);
|
|
98
|
-
if (node?.parentKey != null) {
|
|
99
|
-
const parent = coll.getItem(node.parentKey);
|
|
100
|
-
if (parent && visibleKeys.includes(parent.key)) {
|
|
101
|
-
setFocusedKeyInternal(parent.key);
|
|
102
|
-
cachedCollection = coll;
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Otherwise, find the nearest visible sibling or previous item
|
|
108
|
-
const rows = coll.rows;
|
|
109
|
-
if (rows.length > 0) {
|
|
110
|
-
// Find the closest item to where the focused item was
|
|
111
|
-
const cachedNode = cachedCollection.getItem(currentFocusedKey);
|
|
112
|
-
if (cachedNode?.rowIndex !== undefined) {
|
|
113
|
-
const newIndex = Math.min(cachedNode.rowIndex, rows.length - 1);
|
|
114
|
-
const newNode = rows[newIndex];
|
|
115
|
-
if (newNode && !disabledKeys().has(newNode.key)) {
|
|
116
|
-
setFocusedKeyInternal(newNode.key);
|
|
117
|
-
cachedCollection = coll;
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
// Fall back to first non-disabled item
|
|
122
|
-
for (const row of rows) {
|
|
123
|
-
if (!disabledKeys().has(row.key)) {
|
|
124
|
-
setFocusedKeyInternal(row.key);
|
|
125
|
-
break;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
} else {
|
|
129
|
-
setFocusedKeyInternal(null);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
cachedCollection = coll;
|
|
135
|
-
})
|
|
136
|
-
);
|
|
137
|
-
|
|
138
|
-
// Selection methods
|
|
139
|
-
const isSelected = (key: Key): boolean => {
|
|
140
|
-
const keys = selectedKeys();
|
|
141
|
-
if (keys === 'all') return true;
|
|
142
|
-
return keys.has(key);
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
const isDisabled = (key: Key): boolean => {
|
|
146
|
-
return disabledKeys().has(key);
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const isExpanded = (key: Key): boolean => {
|
|
150
|
-
return expandedKeys().has(key);
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
const updateSelection = (newSelection: 'all' | Set<Key>) => {
|
|
154
|
-
const opts = getOptions();
|
|
155
|
-
|
|
156
|
-
// Controlled mode
|
|
157
|
-
if (opts.selectedKeys !== undefined) {
|
|
158
|
-
opts.onSelectionChange?.(newSelection);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Uncontrolled mode
|
|
163
|
-
const current = internalSelectedKeys();
|
|
164
|
-
const isDifferent =
|
|
165
|
-
current === 'all' ||
|
|
166
|
-
newSelection === 'all' ||
|
|
167
|
-
current.size !== (newSelection as Set<Key>).size ||
|
|
168
|
-
![...current].every((k) => (newSelection as Set<Key>).has(k));
|
|
169
|
-
|
|
170
|
-
if (isDifferent) {
|
|
171
|
-
setInternalSelectedKeys(newSelection);
|
|
172
|
-
opts.onSelectionChange?.(newSelection);
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
const toggleSelection = (key: Key) => {
|
|
177
|
-
if (isDisabled(key) && disabledBehavior() === 'all') return;
|
|
178
|
-
if (selectionMode() === 'none') return;
|
|
179
|
-
|
|
180
|
-
const current = selectedKeys();
|
|
181
|
-
|
|
182
|
-
if (selectionMode() === 'single') {
|
|
183
|
-
if (isSelected(key) && !disallowEmptySelection()) {
|
|
184
|
-
updateSelection(new Set());
|
|
185
|
-
} else {
|
|
186
|
-
updateSelection(new Set([key]));
|
|
187
|
-
}
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Multiple selection
|
|
192
|
-
if (current === 'all') {
|
|
193
|
-
// Can't toggle when all selected without knowing all keys
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const newSelection = new Set(current);
|
|
198
|
-
if (newSelection.has(key)) {
|
|
199
|
-
if (newSelection.size > 1 || !disallowEmptySelection()) {
|
|
200
|
-
newSelection.delete(key);
|
|
201
|
-
}
|
|
202
|
-
} else {
|
|
203
|
-
newSelection.add(key);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
updateSelection(newSelection);
|
|
207
|
-
setAnchorKey(key);
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const replaceSelection = (key: Key) => {
|
|
211
|
-
if (isDisabled(key) && disabledBehavior() === 'all') return;
|
|
212
|
-
if (selectionMode() === 'none') return;
|
|
213
|
-
|
|
214
|
-
updateSelection(new Set([key]));
|
|
215
|
-
setAnchorKey(key);
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
const extendSelection = (toKey: Key) => {
|
|
219
|
-
if (isDisabled(toKey) && disabledBehavior() === 'all') return;
|
|
220
|
-
if (selectionMode() !== 'multiple') {
|
|
221
|
-
replaceSelection(toKey);
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const anchor = anchorKey();
|
|
226
|
-
if (!anchor) {
|
|
227
|
-
replaceSelection(toKey);
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const coll = collection();
|
|
232
|
-
const rows = coll.rows.filter((r) => r.type === 'item');
|
|
233
|
-
const keys = rows.map((r) => r.key);
|
|
234
|
-
const anchorIndex = keys.indexOf(anchor);
|
|
235
|
-
const toIndex = keys.indexOf(toKey);
|
|
236
|
-
|
|
237
|
-
if (anchorIndex === -1 || toIndex === -1) {
|
|
238
|
-
replaceSelection(toKey);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const start = Math.min(anchorIndex, toIndex);
|
|
243
|
-
const end = Math.max(anchorIndex, toIndex);
|
|
244
|
-
const rangeKeys = keys.slice(start, end + 1).filter((k) => !isDisabled(k));
|
|
245
|
-
|
|
246
|
-
updateSelection(new Set(rangeKeys));
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
const selectAll = () => {
|
|
250
|
-
if (selectionMode() !== 'multiple') return;
|
|
251
|
-
updateSelection('all');
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
const clearSelection = () => {
|
|
255
|
-
if (disallowEmptySelection()) return;
|
|
256
|
-
updateSelection(new Set());
|
|
257
|
-
};
|
|
258
|
-
|
|
259
|
-
const toggleSelectAll = () => {
|
|
260
|
-
if (selectionMode() !== 'multiple') return;
|
|
261
|
-
|
|
262
|
-
if (selectedKeys() === 'all') {
|
|
263
|
-
clearSelection();
|
|
264
|
-
} else {
|
|
265
|
-
selectAll();
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
// Expansion methods
|
|
270
|
-
const updateExpandedKeys = (newKeys: Set<Key>) => {
|
|
271
|
-
const opts = getOptions();
|
|
272
|
-
|
|
273
|
-
// Controlled mode
|
|
274
|
-
if (opts.expandedKeys !== undefined) {
|
|
275
|
-
opts.onExpandedChange?.(newKeys);
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Uncontrolled mode
|
|
280
|
-
setInternalExpandedKeys(newKeys);
|
|
281
|
-
opts.onExpandedChange?.(newKeys);
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
const toggleKey = (key: Key) => {
|
|
285
|
-
const node = collection().getItem(key);
|
|
286
|
-
if (!node || !node.isExpandable) return;
|
|
287
|
-
if (isDisabled(key) && disabledBehavior() === 'all') return;
|
|
288
|
-
|
|
289
|
-
const current = expandedKeys();
|
|
290
|
-
const newKeys = new Set(current);
|
|
291
|
-
|
|
292
|
-
if (newKeys.has(key)) {
|
|
293
|
-
newKeys.delete(key);
|
|
294
|
-
} else {
|
|
295
|
-
newKeys.add(key);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
updateExpandedKeys(newKeys);
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
const expandKey = (key: Key) => {
|
|
302
|
-
const node = collection().getItem(key);
|
|
303
|
-
if (!node || !node.isExpandable) return;
|
|
304
|
-
if (isDisabled(key) && disabledBehavior() === 'all') return;
|
|
305
|
-
|
|
306
|
-
const current = expandedKeys();
|
|
307
|
-
if (current.has(key)) return;
|
|
308
|
-
|
|
309
|
-
const newKeys = new Set(current);
|
|
310
|
-
newKeys.add(key);
|
|
311
|
-
updateExpandedKeys(newKeys);
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
const collapseKey = (key: Key) => {
|
|
315
|
-
const node = collection().getItem(key);
|
|
316
|
-
if (!node || !node.isExpandable) return;
|
|
317
|
-
if (isDisabled(key) && disabledBehavior() === 'all') return;
|
|
318
|
-
|
|
319
|
-
const current = expandedKeys();
|
|
320
|
-
if (!current.has(key)) return;
|
|
321
|
-
|
|
322
|
-
const newKeys = new Set(current);
|
|
323
|
-
newKeys.delete(key);
|
|
324
|
-
updateExpandedKeys(newKeys);
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
const setExpandedKeys = (keys: Set<Key>) => {
|
|
328
|
-
updateExpandedKeys(keys);
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
return {
|
|
332
|
-
get collection() {
|
|
333
|
-
return collection();
|
|
334
|
-
},
|
|
335
|
-
get disabledKeys() {
|
|
336
|
-
return disabledKeys();
|
|
337
|
-
},
|
|
338
|
-
get expandedKeys() {
|
|
339
|
-
return expandedKeys();
|
|
340
|
-
},
|
|
341
|
-
get isKeyboardNavigationDisabled() {
|
|
342
|
-
return isKeyboardNavigationDisabled();
|
|
343
|
-
},
|
|
344
|
-
get focusedKey() {
|
|
345
|
-
return focusedKey();
|
|
346
|
-
},
|
|
347
|
-
get childFocusStrategy() {
|
|
348
|
-
return childFocusStrategy();
|
|
349
|
-
},
|
|
350
|
-
get isFocused() {
|
|
351
|
-
return isFocused();
|
|
352
|
-
},
|
|
353
|
-
get selectionMode() {
|
|
354
|
-
return selectionMode();
|
|
355
|
-
},
|
|
356
|
-
get selectedKeys() {
|
|
357
|
-
return selectedKeys();
|
|
358
|
-
},
|
|
359
|
-
isSelected,
|
|
360
|
-
isDisabled,
|
|
361
|
-
isExpanded,
|
|
362
|
-
setFocusedKey,
|
|
363
|
-
setFocused: setIsFocused,
|
|
364
|
-
toggleSelection,
|
|
365
|
-
replaceSelection,
|
|
366
|
-
extendSelection,
|
|
367
|
-
selectAll,
|
|
368
|
-
clearSelection,
|
|
369
|
-
toggleSelectAll,
|
|
370
|
-
toggleKey,
|
|
371
|
-
expandKey,
|
|
372
|
-
collapseKey,
|
|
373
|
-
setExpandedKeys,
|
|
374
|
-
setKeyboardNavigationDisabled,
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// Helper functions
|
|
379
|
-
function getInitialSelection(defaultKeys?: 'all' | Iterable<Key>): 'all' | Set<Key> {
|
|
380
|
-
if (defaultKeys === undefined) return new Set();
|
|
381
|
-
return normalizeSelection(defaultKeys);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function normalizeSelection(keys: 'all' | Iterable<Key>): 'all' | Set<Key> {
|
|
385
|
-
if (keys === 'all') return 'all';
|
|
386
|
-
return new Set(keys);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function getInitialExpandedKeys(defaultKeys?: Iterable<Key>): Set<Key> {
|
|
390
|
-
if (defaultKeys === undefined) return new Set();
|
|
391
|
-
return new Set(defaultKeys);
|
|
392
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tree state management for Tree components.
|
|
3
|
+
* Based on @react-stately/tree/useTreeState.
|
|
4
|
+
*
|
|
5
|
+
* Manages expansion state, selection, and focus for hierarchical tree data.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createSignal, createEffect, createMemo, on, type Accessor } from 'solid-js';
|
|
9
|
+
import type {
|
|
10
|
+
TreeState,
|
|
11
|
+
TreeStateOptions,
|
|
12
|
+
TreeCollection,
|
|
13
|
+
TreeNode,
|
|
14
|
+
} from './types';
|
|
15
|
+
import type { Key, FocusStrategy } from '../collections/types';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Creates state management for a tree component.
|
|
19
|
+
* Handles expansion, selection, focus management, and keyboard navigation state.
|
|
20
|
+
*/
|
|
21
|
+
export function createTreeState<T extends object, C extends TreeCollection<T> = TreeCollection<T>>(
|
|
22
|
+
options: Accessor<TreeStateOptions<T, C>>
|
|
23
|
+
): TreeState<T, C> {
|
|
24
|
+
const getOptions = () => options();
|
|
25
|
+
|
|
26
|
+
// Disabled keys as a Set
|
|
27
|
+
const disabledKeys = createMemo(() => {
|
|
28
|
+
const keys = getOptions().disabledKeys;
|
|
29
|
+
return keys ? new Set(keys) : new Set<Key>();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Expansion state (uncontrolled)
|
|
33
|
+
const [internalExpandedKeys, setInternalExpandedKeys] = createSignal<Set<Key>>(
|
|
34
|
+
getInitialExpandedKeys(getOptions().defaultExpandedKeys)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Computed expanded keys (controlled or uncontrolled)
|
|
38
|
+
const expandedKeys = createMemo(() => {
|
|
39
|
+
const opts = getOptions();
|
|
40
|
+
if (opts.expandedKeys !== undefined) {
|
|
41
|
+
return new Set(opts.expandedKeys);
|
|
42
|
+
}
|
|
43
|
+
return internalExpandedKeys();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Collection - rebuilt when expanded keys change
|
|
47
|
+
const collection = createMemo(() => {
|
|
48
|
+
const opts = getOptions();
|
|
49
|
+
return opts.collectionFactory(expandedKeys());
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Focus state
|
|
53
|
+
const [isFocused, setIsFocused] = createSignal(false);
|
|
54
|
+
const [focusedKey, setFocusedKeyInternal] = createSignal<Key | null>(null);
|
|
55
|
+
const [childFocusStrategy, setChildFocusStrategy] = createSignal<FocusStrategy | null>(null);
|
|
56
|
+
const [isKeyboardNavigationDisabled, setKeyboardNavigationDisabled] = createSignal(false);
|
|
57
|
+
|
|
58
|
+
// Selection state
|
|
59
|
+
const [internalSelectedKeys, setInternalSelectedKeys] = createSignal<'all' | Set<Key>>(
|
|
60
|
+
getInitialSelection(getOptions().defaultSelectedKeys)
|
|
61
|
+
);
|
|
62
|
+
const [anchorKey, setAnchorKey] = createSignal<Key | null>(null);
|
|
63
|
+
|
|
64
|
+
// Computed selection
|
|
65
|
+
const selectedKeys = createMemo(() => {
|
|
66
|
+
const opts = getOptions();
|
|
67
|
+
if (opts.selectedKeys !== undefined) {
|
|
68
|
+
return normalizeSelection(opts.selectedKeys);
|
|
69
|
+
}
|
|
70
|
+
return internalSelectedKeys();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const selectionMode = createMemo(() => getOptions().selectionMode ?? 'none');
|
|
74
|
+
const selectionBehavior = createMemo(() => getOptions().selectionBehavior ?? 'toggle');
|
|
75
|
+
const disallowEmptySelection = createMemo(() => getOptions().disallowEmptySelection ?? false);
|
|
76
|
+
const disabledBehavior = createMemo(() => getOptions().disabledBehavior ?? 'all');
|
|
77
|
+
|
|
78
|
+
// Set focused key
|
|
79
|
+
const setFocusedKey = (key: Key | null, strategy: FocusStrategy = 'first') => {
|
|
80
|
+
setFocusedKeyInternal(key);
|
|
81
|
+
setChildFocusStrategy(strategy);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Reset focused key if the item is removed from visible collection
|
|
85
|
+
let cachedCollection: C | null = null;
|
|
86
|
+
|
|
87
|
+
createEffect(
|
|
88
|
+
on(collection, (coll) => {
|
|
89
|
+
const currentFocusedKey = focusedKey();
|
|
90
|
+
|
|
91
|
+
if (currentFocusedKey != null && cachedCollection) {
|
|
92
|
+
// Check if the focused item is still visible
|
|
93
|
+
const visibleKeys = [...coll.getKeys()];
|
|
94
|
+
if (!visibleKeys.includes(currentFocusedKey)) {
|
|
95
|
+
// The focused item was collapsed or removed, find a new one to focus
|
|
96
|
+
// Try to find the parent and focus it
|
|
97
|
+
const node = cachedCollection.getItem(currentFocusedKey);
|
|
98
|
+
if (node?.parentKey != null) {
|
|
99
|
+
const parent = coll.getItem(node.parentKey);
|
|
100
|
+
if (parent && visibleKeys.includes(parent.key)) {
|
|
101
|
+
setFocusedKeyInternal(parent.key);
|
|
102
|
+
cachedCollection = coll;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Otherwise, find the nearest visible sibling or previous item
|
|
108
|
+
const rows = coll.rows;
|
|
109
|
+
if (rows.length > 0) {
|
|
110
|
+
// Find the closest item to where the focused item was
|
|
111
|
+
const cachedNode = cachedCollection.getItem(currentFocusedKey);
|
|
112
|
+
if (cachedNode?.rowIndex !== undefined) {
|
|
113
|
+
const newIndex = Math.min(cachedNode.rowIndex, rows.length - 1);
|
|
114
|
+
const newNode = rows[newIndex];
|
|
115
|
+
if (newNode && !disabledKeys().has(newNode.key)) {
|
|
116
|
+
setFocusedKeyInternal(newNode.key);
|
|
117
|
+
cachedCollection = coll;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Fall back to first non-disabled item
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
if (!disabledKeys().has(row.key)) {
|
|
124
|
+
setFocusedKeyInternal(row.key);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
setFocusedKeyInternal(null);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
cachedCollection = coll;
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Selection methods
|
|
139
|
+
const isSelected = (key: Key): boolean => {
|
|
140
|
+
const keys = selectedKeys();
|
|
141
|
+
if (keys === 'all') return true;
|
|
142
|
+
return keys.has(key);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const isDisabled = (key: Key): boolean => {
|
|
146
|
+
return disabledKeys().has(key);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const isExpanded = (key: Key): boolean => {
|
|
150
|
+
return expandedKeys().has(key);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const updateSelection = (newSelection: 'all' | Set<Key>) => {
|
|
154
|
+
const opts = getOptions();
|
|
155
|
+
|
|
156
|
+
// Controlled mode
|
|
157
|
+
if (opts.selectedKeys !== undefined) {
|
|
158
|
+
opts.onSelectionChange?.(newSelection);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Uncontrolled mode
|
|
163
|
+
const current = internalSelectedKeys();
|
|
164
|
+
const isDifferent =
|
|
165
|
+
current === 'all' ||
|
|
166
|
+
newSelection === 'all' ||
|
|
167
|
+
current.size !== (newSelection as Set<Key>).size ||
|
|
168
|
+
![...current].every((k) => (newSelection as Set<Key>).has(k));
|
|
169
|
+
|
|
170
|
+
if (isDifferent) {
|
|
171
|
+
setInternalSelectedKeys(newSelection);
|
|
172
|
+
opts.onSelectionChange?.(newSelection);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const toggleSelection = (key: Key) => {
|
|
177
|
+
if (isDisabled(key) && disabledBehavior() === 'all') return;
|
|
178
|
+
if (selectionMode() === 'none') return;
|
|
179
|
+
|
|
180
|
+
const current = selectedKeys();
|
|
181
|
+
|
|
182
|
+
if (selectionMode() === 'single') {
|
|
183
|
+
if (isSelected(key) && !disallowEmptySelection()) {
|
|
184
|
+
updateSelection(new Set());
|
|
185
|
+
} else {
|
|
186
|
+
updateSelection(new Set([key]));
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Multiple selection
|
|
192
|
+
if (current === 'all') {
|
|
193
|
+
// Can't toggle when all selected without knowing all keys
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const newSelection = new Set(current);
|
|
198
|
+
if (newSelection.has(key)) {
|
|
199
|
+
if (newSelection.size > 1 || !disallowEmptySelection()) {
|
|
200
|
+
newSelection.delete(key);
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
newSelection.add(key);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
updateSelection(newSelection);
|
|
207
|
+
setAnchorKey(key);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const replaceSelection = (key: Key) => {
|
|
211
|
+
if (isDisabled(key) && disabledBehavior() === 'all') return;
|
|
212
|
+
if (selectionMode() === 'none') return;
|
|
213
|
+
|
|
214
|
+
updateSelection(new Set([key]));
|
|
215
|
+
setAnchorKey(key);
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const extendSelection = (toKey: Key) => {
|
|
219
|
+
if (isDisabled(toKey) && disabledBehavior() === 'all') return;
|
|
220
|
+
if (selectionMode() !== 'multiple') {
|
|
221
|
+
replaceSelection(toKey);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const anchor = anchorKey();
|
|
226
|
+
if (!anchor) {
|
|
227
|
+
replaceSelection(toKey);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const coll = collection();
|
|
232
|
+
const rows = coll.rows.filter((r) => r.type === 'item');
|
|
233
|
+
const keys = rows.map((r) => r.key);
|
|
234
|
+
const anchorIndex = keys.indexOf(anchor);
|
|
235
|
+
const toIndex = keys.indexOf(toKey);
|
|
236
|
+
|
|
237
|
+
if (anchorIndex === -1 || toIndex === -1) {
|
|
238
|
+
replaceSelection(toKey);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const start = Math.min(anchorIndex, toIndex);
|
|
243
|
+
const end = Math.max(anchorIndex, toIndex);
|
|
244
|
+
const rangeKeys = keys.slice(start, end + 1).filter((k) => !isDisabled(k));
|
|
245
|
+
|
|
246
|
+
updateSelection(new Set(rangeKeys));
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const selectAll = () => {
|
|
250
|
+
if (selectionMode() !== 'multiple') return;
|
|
251
|
+
updateSelection('all');
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const clearSelection = () => {
|
|
255
|
+
if (disallowEmptySelection()) return;
|
|
256
|
+
updateSelection(new Set());
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const toggleSelectAll = () => {
|
|
260
|
+
if (selectionMode() !== 'multiple') return;
|
|
261
|
+
|
|
262
|
+
if (selectedKeys() === 'all') {
|
|
263
|
+
clearSelection();
|
|
264
|
+
} else {
|
|
265
|
+
selectAll();
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// Expansion methods
|
|
270
|
+
const updateExpandedKeys = (newKeys: Set<Key>) => {
|
|
271
|
+
const opts = getOptions();
|
|
272
|
+
|
|
273
|
+
// Controlled mode
|
|
274
|
+
if (opts.expandedKeys !== undefined) {
|
|
275
|
+
opts.onExpandedChange?.(newKeys);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Uncontrolled mode
|
|
280
|
+
setInternalExpandedKeys(newKeys);
|
|
281
|
+
opts.onExpandedChange?.(newKeys);
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const toggleKey = (key: Key) => {
|
|
285
|
+
const node = collection().getItem(key);
|
|
286
|
+
if (!node || !node.isExpandable) return;
|
|
287
|
+
if (isDisabled(key) && disabledBehavior() === 'all') return;
|
|
288
|
+
|
|
289
|
+
const current = expandedKeys();
|
|
290
|
+
const newKeys = new Set(current);
|
|
291
|
+
|
|
292
|
+
if (newKeys.has(key)) {
|
|
293
|
+
newKeys.delete(key);
|
|
294
|
+
} else {
|
|
295
|
+
newKeys.add(key);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
updateExpandedKeys(newKeys);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const expandKey = (key: Key) => {
|
|
302
|
+
const node = collection().getItem(key);
|
|
303
|
+
if (!node || !node.isExpandable) return;
|
|
304
|
+
if (isDisabled(key) && disabledBehavior() === 'all') return;
|
|
305
|
+
|
|
306
|
+
const current = expandedKeys();
|
|
307
|
+
if (current.has(key)) return;
|
|
308
|
+
|
|
309
|
+
const newKeys = new Set(current);
|
|
310
|
+
newKeys.add(key);
|
|
311
|
+
updateExpandedKeys(newKeys);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const collapseKey = (key: Key) => {
|
|
315
|
+
const node = collection().getItem(key);
|
|
316
|
+
if (!node || !node.isExpandable) return;
|
|
317
|
+
if (isDisabled(key) && disabledBehavior() === 'all') return;
|
|
318
|
+
|
|
319
|
+
const current = expandedKeys();
|
|
320
|
+
if (!current.has(key)) return;
|
|
321
|
+
|
|
322
|
+
const newKeys = new Set(current);
|
|
323
|
+
newKeys.delete(key);
|
|
324
|
+
updateExpandedKeys(newKeys);
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const setExpandedKeys = (keys: Set<Key>) => {
|
|
328
|
+
updateExpandedKeys(keys);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
get collection() {
|
|
333
|
+
return collection();
|
|
334
|
+
},
|
|
335
|
+
get disabledKeys() {
|
|
336
|
+
return disabledKeys();
|
|
337
|
+
},
|
|
338
|
+
get expandedKeys() {
|
|
339
|
+
return expandedKeys();
|
|
340
|
+
},
|
|
341
|
+
get isKeyboardNavigationDisabled() {
|
|
342
|
+
return isKeyboardNavigationDisabled();
|
|
343
|
+
},
|
|
344
|
+
get focusedKey() {
|
|
345
|
+
return focusedKey();
|
|
346
|
+
},
|
|
347
|
+
get childFocusStrategy() {
|
|
348
|
+
return childFocusStrategy();
|
|
349
|
+
},
|
|
350
|
+
get isFocused() {
|
|
351
|
+
return isFocused();
|
|
352
|
+
},
|
|
353
|
+
get selectionMode() {
|
|
354
|
+
return selectionMode();
|
|
355
|
+
},
|
|
356
|
+
get selectedKeys() {
|
|
357
|
+
return selectedKeys();
|
|
358
|
+
},
|
|
359
|
+
isSelected,
|
|
360
|
+
isDisabled,
|
|
361
|
+
isExpanded,
|
|
362
|
+
setFocusedKey,
|
|
363
|
+
setFocused: setIsFocused,
|
|
364
|
+
toggleSelection,
|
|
365
|
+
replaceSelection,
|
|
366
|
+
extendSelection,
|
|
367
|
+
selectAll,
|
|
368
|
+
clearSelection,
|
|
369
|
+
toggleSelectAll,
|
|
370
|
+
toggleKey,
|
|
371
|
+
expandKey,
|
|
372
|
+
collapseKey,
|
|
373
|
+
setExpandedKeys,
|
|
374
|
+
setKeyboardNavigationDisabled,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Helper functions
|
|
379
|
+
function getInitialSelection(defaultKeys?: 'all' | Iterable<Key>): 'all' | Set<Key> {
|
|
380
|
+
if (defaultKeys === undefined) return new Set();
|
|
381
|
+
return normalizeSelection(defaultKeys);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function normalizeSelection(keys: 'all' | Iterable<Key>): 'all' | Set<Key> {
|
|
385
|
+
if (keys === 'all') return 'all';
|
|
386
|
+
return new Set(keys);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function getInitialExpandedKeys(defaultKeys?: Iterable<Key>): Set<Key> {
|
|
390
|
+
if (defaultKeys === undefined) return new Set();
|
|
391
|
+
return new Set(defaultKeys);
|
|
392
|
+
}
|