@djangocfg/widget-data 0.1.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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -0
  3. package/package.json +122 -0
  4. package/src/DataGrid/README.md +48 -0
  5. package/src/DataGrid/context/DataGridContext.tsx +442 -0
  6. package/src/DataGrid/context/index.ts +4 -0
  7. package/src/DataGrid/index.ts +34 -0
  8. package/src/DataGrid/lazy.tsx +23 -0
  9. package/src/DataGrid/parts/DataGrid.tsx +125 -0
  10. package/src/DataGrid/parts/DataGridCell.tsx +30 -0
  11. package/src/DataGrid/parts/DataGridHeader.tsx +111 -0
  12. package/src/DataGrid/parts/DataGridPagination.tsx +105 -0
  13. package/src/DataGrid/parts/DataGridRow.tsx +84 -0
  14. package/src/DataGrid/parts/index.ts +7 -0
  15. package/src/DataGrid/types.ts +95 -0
  16. package/src/DataTable/README.md +42 -0
  17. package/src/DataTable/context/DataTableContext.tsx +434 -0
  18. package/src/DataTable/context/index.ts +4 -0
  19. package/src/DataTable/index.ts +33 -0
  20. package/src/DataTable/lazy.tsx +23 -0
  21. package/src/DataTable/parts/DataTable.tsx +142 -0
  22. package/src/DataTable/parts/DataTableHeader.tsx +103 -0
  23. package/src/DataTable/parts/DataTablePagination.tsx +115 -0
  24. package/src/DataTable/parts/DataTableRow.tsx +96 -0
  25. package/src/DataTable/parts/index.ts +6 -0
  26. package/src/DataTable/types.ts +88 -0
  27. package/src/FileIcon/FileIcon.tsx +87 -0
  28. package/src/FileIcon/get-file-icon.ts +73 -0
  29. package/src/FileIcon/icons/icon-data.ts +399 -0
  30. package/src/FileIcon/index.ts +19 -0
  31. package/src/FileIcon/loader.ts +29 -0
  32. package/src/FileIcon/specialFolders.ts +110 -0
  33. package/src/FileIcon/treeAdapter.tsx +49 -0
  34. package/src/JsonTree/JsonViewer.tsx +720 -0
  35. package/src/JsonTree/README.md +142 -0
  36. package/src/JsonTree/index.tsx +11 -0
  37. package/src/JsonTree/lazy.tsx +22 -0
  38. package/src/JsonTree/types.ts +91 -0
  39. package/src/Kanban/context/KanbanContext.tsx +165 -0
  40. package/src/Kanban/context/index.ts +4 -0
  41. package/src/Kanban/index.ts +31 -0
  42. package/src/Kanban/lazy.tsx +23 -0
  43. package/src/Kanban/parts/Kanban.tsx +77 -0
  44. package/src/Kanban/parts/KanbanCard.tsx +76 -0
  45. package/src/Kanban/parts/KanbanColumn.tsx +79 -0
  46. package/src/Kanban/parts/KanbanDragOverlay.tsx +46 -0
  47. package/src/Kanban/parts/index.ts +6 -0
  48. package/src/Kanban/types.ts +45 -0
  49. package/src/Listbox/context/ListboxProvider.tsx +616 -0
  50. package/src/Listbox/context/index.ts +8 -0
  51. package/src/Listbox/index.ts +28 -0
  52. package/src/Listbox/lazy.tsx +31 -0
  53. package/src/Listbox/parts/ListboxGroup.tsx +71 -0
  54. package/src/Listbox/parts/ListboxItem.tsx +151 -0
  55. package/src/Listbox/parts/index.ts +12 -0
  56. package/src/Listbox/types.ts +98 -0
  57. package/src/Masonry/README.md +79 -0
  58. package/src/Masonry/context/MasonryProvider.tsx +1205 -0
  59. package/src/Masonry/context/index.ts +8 -0
  60. package/src/Masonry/index.ts +18 -0
  61. package/src/Masonry/lazy.tsx +20 -0
  62. package/src/Masonry/parts/index.ts +2 -0
  63. package/src/Masonry/types.ts +44 -0
  64. package/src/Timeline/context/TimelineProvider.tsx +474 -0
  65. package/src/Timeline/context/index.ts +16 -0
  66. package/src/Timeline/index.ts +29 -0
  67. package/src/Timeline/lazy.tsx +27 -0
  68. package/src/Timeline/parts/index.ts +2 -0
  69. package/src/Timeline/types.ts +25 -0
  70. package/src/Tree/FinderTree.tsx +42 -0
  71. package/src/Tree/README.md +542 -0
  72. package/src/Tree/TreeDndProvider.tsx +137 -0
  73. package/src/Tree/TreeRoot.tsx +290 -0
  74. package/src/Tree/components/TreeChevron.tsx +48 -0
  75. package/src/Tree/components/TreeContent.tsx +70 -0
  76. package/src/Tree/components/TreeDropIndicator.tsx +65 -0
  77. package/src/Tree/components/TreeEmpty.tsx +22 -0
  78. package/src/Tree/components/TreeEmptyArea.tsx +160 -0
  79. package/src/Tree/components/TreeError.tsx +24 -0
  80. package/src/Tree/components/TreeIcon.tsx +39 -0
  81. package/src/Tree/components/TreeIndentGuides.tsx +33 -0
  82. package/src/Tree/components/TreeLabel.tsx +39 -0
  83. package/src/Tree/components/TreeRenameInput.tsx +114 -0
  84. package/src/Tree/components/TreeRow.tsx +300 -0
  85. package/src/Tree/components/TreeSearchInput.tsx +50 -0
  86. package/src/Tree/components/TreeSkeleton.tsx +22 -0
  87. package/src/Tree/components/index.ts +28 -0
  88. package/src/Tree/context/TreeContext.tsx +409 -0
  89. package/src/Tree/context/TreeContextValue.ts +139 -0
  90. package/src/Tree/context/async-children/collect-ids.ts +27 -0
  91. package/src/Tree/context/async-children/index.ts +8 -0
  92. package/src/Tree/context/async-children/use-async-children.ts +157 -0
  93. package/src/Tree/context/clipboard/index.ts +4 -0
  94. package/src/Tree/context/clipboard/use-clipboard.ts +115 -0
  95. package/src/Tree/context/dnd/index.ts +8 -0
  96. package/src/Tree/context/dnd/use-dnd.ts +194 -0
  97. package/src/Tree/context/expansion/index.ts +4 -0
  98. package/src/Tree/context/expansion/use-expansion.ts +55 -0
  99. package/src/Tree/context/hooks.ts +177 -0
  100. package/src/Tree/context/index.ts +16 -0
  101. package/src/Tree/context/menu/builtin-actions.ts +358 -0
  102. package/src/Tree/context/menu/index.ts +11 -0
  103. package/src/Tree/context/menu/render.tsx +104 -0
  104. package/src/Tree/context/menu/use-resolved-menu.ts +141 -0
  105. package/src/Tree/context/persist/index.ts +4 -0
  106. package/src/Tree/context/persist/use-persist-sync.ts +74 -0
  107. package/src/Tree/context/rename/index.ts +4 -0
  108. package/src/Tree/context/rename/use-rename.ts +113 -0
  109. package/src/Tree/context/selection/index.ts +4 -0
  110. package/src/Tree/context/selection/use-selection.ts +146 -0
  111. package/src/Tree/context/state/index.ts +6 -0
  112. package/src/Tree/context/state/initial.ts +41 -0
  113. package/src/Tree/context/state/reducer.ts +76 -0
  114. package/src/Tree/context/state/types.ts +46 -0
  115. package/src/Tree/data/appearance.ts +237 -0
  116. package/src/Tree/data/childCache.ts +43 -0
  117. package/src/Tree/data/clipboard.ts +33 -0
  118. package/src/Tree/data/createDemoTree.ts +42 -0
  119. package/src/Tree/data/dnd.ts +123 -0
  120. package/src/Tree/data/finderShortcuts.ts +67 -0
  121. package/src/Tree/data/flatten.ts +66 -0
  122. package/src/Tree/data/index.ts +45 -0
  123. package/src/Tree/data/persist.ts +62 -0
  124. package/src/Tree/data/renameUtils.ts +51 -0
  125. package/src/Tree/data/selection.ts +161 -0
  126. package/src/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  127. package/src/Tree/hooks/finder-hotkeys/index.ts +8 -0
  128. package/src/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  129. package/src/Tree/hooks/index.ts +25 -0
  130. package/src/Tree/hooks/keyboard/activation.ts +27 -0
  131. package/src/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  132. package/src/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  133. package/src/Tree/hooks/keyboard/index.ts +10 -0
  134. package/src/Tree/hooks/keyboard/types.ts +39 -0
  135. package/src/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  136. package/src/Tree/hooks/type-ahead/index.ts +5 -0
  137. package/src/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  138. package/src/Tree/hooks/type-ahead/use-tree-type-ahead.ts +89 -0
  139. package/src/Tree/index.tsx +135 -0
  140. package/src/Tree/lazy.tsx +14 -0
  141. package/src/Tree/types/activation.ts +30 -0
  142. package/src/Tree/types/adapter.ts +70 -0
  143. package/src/Tree/types/index.ts +27 -0
  144. package/src/Tree/types/labels.ts +97 -0
  145. package/src/Tree/types/loader.ts +9 -0
  146. package/src/Tree/types/node.ts +38 -0
  147. package/src/Tree/types/root-props.ts +158 -0
  148. package/src/Tree/types/selection.ts +3 -0
  149. package/src/Tree/types/slots.ts +64 -0
  150. package/src/index.ts +21 -0
  151. package/src/stories/_fixtures/categories.ts +51 -0
  152. package/src/stories/_fixtures/finder-store.ts +196 -0
  153. package/src/stories/_fixtures/fs.ts +83 -0
  154. package/src/stories/_fixtures/index.ts +5 -0
  155. package/src/stories/_fixtures/org.ts +46 -0
  156. package/src/stories/_fixtures/status.ts +21 -0
  157. package/src/styles/index.css +10 -0
@@ -0,0 +1,616 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { useComposedRefs } from "@djangocfg/ui-core/lib";
6
+ import { VisuallyHiddenInput } from "@djangocfg/ui-core/components";
7
+ import { composeEventHandlers } from "@djangocfg/ui-core/lib";
8
+ import { cn } from "@djangocfg/ui-core/lib";
9
+ import type {
10
+ ListboxRootProps,
11
+ ListboxState,
12
+ ListboxStore,
13
+ ListboxContextValue,
14
+ CollectionItem,
15
+ CollectionItemMap,
16
+ CollectionGroupMap,
17
+ ListboxValue,
18
+ } from "../types";
19
+
20
+ const ROOT_NAME = "Listbox";
21
+ const ITEM_NAME = "ListboxItem";
22
+ const ITEM_SELECT_EVENT = `${ITEM_NAME}.Select.Event`;
23
+
24
+ function createSelectableStore(defaultValue: string | null = null): ListboxStore {
25
+ const state: ListboxState = {
26
+ selectedValues: defaultValue ? new Set<string>([defaultValue]) : new Set<string>(),
27
+ focusedValue: null,
28
+ highlightedValue: null,
29
+ };
30
+
31
+ const listeners = new Set<() => void>();
32
+
33
+ const store: ListboxStore = {
34
+ onSubscribe(callback) {
35
+ listeners.add(callback);
36
+ return () => {
37
+ listeners.delete(callback);
38
+ };
39
+ },
40
+
41
+ getSnapshot() {
42
+ return state;
43
+ },
44
+
45
+ onStateChange(key, value, silent = false) {
46
+ if (Object.is(state[key], value)) return;
47
+ state[key] = value;
48
+ if (!silent) {
49
+ queueMicrotask(() => store.onEmit());
50
+ }
51
+ },
52
+
53
+ onEmit() {
54
+ for (const listener of listeners) {
55
+ listener();
56
+ }
57
+ },
58
+
59
+ onSelectedStateChange(value, multiple = false) {
60
+ const newSelectedValues = new Set(state.selectedValues);
61
+
62
+ if (multiple) {
63
+ if (newSelectedValues.has(value)) {
64
+ newSelectedValues.delete(value);
65
+ } else {
66
+ newSelectedValues.add(value);
67
+ }
68
+ } else {
69
+ if (newSelectedValues.size === 1 && newSelectedValues.has(value)) {
70
+ newSelectedValues.clear();
71
+ } else {
72
+ newSelectedValues.clear();
73
+ newSelectedValues.add(value);
74
+ }
75
+ }
76
+
77
+ const hasChanged =
78
+ state.selectedValues.size !== newSelectedValues.size ||
79
+ ![...newSelectedValues].every((v) => state.selectedValues.has(v));
80
+
81
+ if (hasChanged) {
82
+ store.onStateChange("selectedValues", newSelectedValues);
83
+ }
84
+ },
85
+
86
+ onClearSelection() {
87
+ if (state.selectedValues.size > 0) {
88
+ store.onStateChange("selectedValues", new Set<string>());
89
+ }
90
+ },
91
+
92
+ onHighlightedValueChange(value) {
93
+ store.onStateChange("highlightedValue", value);
94
+ },
95
+ };
96
+
97
+ return store;
98
+ }
99
+
100
+ function useLazyRef<T>(init: () => T): React.RefObject<T> {
101
+ const ref = React.useRef<T | null>(null);
102
+ if (ref.current === null) {
103
+ ref.current = init();
104
+ }
105
+ return ref as React.RefObject<T>;
106
+ }
107
+
108
+ function useCollection() {
109
+ const collectionRef = React.useRef<HTMLDivElement | null>(null);
110
+ const itemMap = useLazyRef<CollectionItemMap>(() => new Map()).current;
111
+ const groupMap = useLazyRef<CollectionGroupMap>(() => new Map()).current;
112
+
113
+ const getItems = React.useCallback(() => {
114
+ const collectionNode = collectionRef.current;
115
+ if (!collectionNode) return [];
116
+ const items = Array.from(itemMap.values());
117
+ if (items.length === 0) return [];
118
+ return items.sort((a, b) => {
119
+ if (!a?.ref.current || !b?.ref.current) return 0;
120
+ return a.ref.current.compareDocumentPosition(b.ref.current) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
121
+ });
122
+ }, [itemMap]);
123
+
124
+ const onItemRegister = React.useCallback(
125
+ (item: CollectionItem, groupId?: string) => {
126
+ itemMap.set(item.ref, item);
127
+ if (groupId) {
128
+ if (!groupMap.has(groupId)) {
129
+ groupMap.set(groupId, new Set());
130
+ }
131
+ groupMap.get(groupId)?.add(item.ref);
132
+ }
133
+ return () => {
134
+ itemMap.delete(item.ref);
135
+ if (groupId) {
136
+ const group = groupMap.get(groupId);
137
+ group?.delete(item.ref);
138
+ if (group?.size === 0) {
139
+ groupMap.delete(groupId);
140
+ }
141
+ }
142
+ };
143
+ },
144
+ [itemMap, groupMap],
145
+ );
146
+
147
+ return { collectionRef, itemMap, groupMap, getItems, onItemRegister };
148
+ }
149
+
150
+ function findEnabledItem(
151
+ items: CollectionItem[],
152
+ { startingIndex, decrement = false, loop = false }: { startingIndex: number; decrement?: boolean; loop?: boolean },
153
+ ): CollectionItem | null {
154
+ const len = items.length;
155
+ let index = startingIndex;
156
+ do {
157
+ index = decrement ? index - 1 : index + 1;
158
+ if (loop) {
159
+ if (index < 0) index = len - 1;
160
+ else if (index >= len) index = 0;
161
+ } else {
162
+ if (index < 0 || index >= len) {
163
+ return items[decrement ? 0 : len - 1] ?? null;
164
+ }
165
+ }
166
+ const item = items[index];
167
+ if (item && !item.disabled) return item;
168
+ } while (index !== startingIndex);
169
+ return items[startingIndex] ?? null;
170
+ }
171
+
172
+ function getMinItemValue(items: CollectionItem[]): string | null {
173
+ for (const item of items) {
174
+ if (!item.disabled) return item.value;
175
+ }
176
+ return items[0]?.value ?? null;
177
+ }
178
+
179
+ function getMaxItemValue(items: CollectionItem[]): string | null {
180
+ for (let i = items.length - 1; i >= 0; i--) {
181
+ const item = items[i];
182
+ if (item && !item.disabled) return item.value;
183
+ }
184
+ return items[items.length - 1]?.value ?? null;
185
+ }
186
+
187
+ function calculateGridLayout(
188
+ items: CollectionItem[],
189
+ orientation: string,
190
+ ): { columnCount: number; rowCount: number } {
191
+ if (orientation !== "mixed" || items.length <= 1) {
192
+ return { columnCount: 1, rowCount: items.length };
193
+ }
194
+ const itemElements = items.map((item) => item.ref.current).filter(Boolean) as HTMLDivElement[];
195
+ if (itemElements.length <= 1) {
196
+ return { columnCount: 1, rowCount: items.length };
197
+ }
198
+ const rect1 = itemElements[0]?.getBoundingClientRect();
199
+ const rect2 = itemElements[1]?.getBoundingClientRect();
200
+ if (!rect1 || !rect2) {
201
+ return { columnCount: 1, rowCount: items.length };
202
+ }
203
+ const sameRow = Math.abs(rect1.top - rect2.top) < 10;
204
+ if (!sameRow) {
205
+ return { columnCount: 1, rowCount: items.length };
206
+ }
207
+ const firstRowY = rect1.top;
208
+ let colCount = 0;
209
+ for (const itemElement of itemElements) {
210
+ const rect = itemElement.getBoundingClientRect();
211
+ if (Math.abs(rect.top - firstRowY) < 10) {
212
+ colCount++;
213
+ } else {
214
+ break;
215
+ }
216
+ }
217
+ const columnCount = Math.max(1, colCount);
218
+ const rowCount = Math.ceil(items.length / columnCount);
219
+ return { columnCount, rowCount };
220
+ }
221
+
222
+ function dispatchDiscreteCustomEvent<E extends CustomEvent>(target: EventTarget, event: E) {
223
+ if (target && "dispatchEvent" in target && target.dispatchEvent) {
224
+ target.dispatchEvent(event);
225
+ }
226
+ }
227
+
228
+ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
229
+
230
+ const ListboxContext = React.createContext<ListboxContextValue<boolean> | null>(null);
231
+ ListboxContext.displayName = ROOT_NAME;
232
+
233
+ function useListboxContext(name: string) {
234
+ const context = React.useContext(ListboxContext);
235
+ if (!context) {
236
+ throw new Error(`\`${name}\` must be used within \`${ROOT_NAME}\``);
237
+ }
238
+ return context;
239
+ }
240
+
241
+ function useListboxState<T>(selector: (state: ListboxState) => T): T {
242
+ const store = useListboxContext(ROOT_NAME).store;
243
+ const subscribe = React.useCallback(
244
+ (callback: () => void) => store.onSubscribe(callback),
245
+ [store],
246
+ );
247
+ const getSnapshot = React.useCallback(() => selector(store.getSnapshot()), [store, selector]);
248
+ return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
249
+ }
250
+
251
+ function ListboxRootImpl<Multiple extends boolean = false>(
252
+ props: ListboxRootProps<Multiple>,
253
+ forwardedRef: React.ForwardedRef<HTMLDivElement>,
254
+ ) {
255
+ const {
256
+ defaultValue,
257
+ value,
258
+ onValueChange,
259
+ dir = "ltr",
260
+ disabled = false,
261
+ loop = false,
262
+ multiple = false as Multiple,
263
+ orientation = "vertical",
264
+ name,
265
+ virtual = false,
266
+ asChild,
267
+ className,
268
+ ...rootProps
269
+ } = props;
270
+
271
+ const storeRef = useLazyRef<ListboxStore>(() => {
272
+ const store = createSelectableStore(typeof defaultValue === "string" ? defaultValue : null);
273
+ if (multiple && Array.isArray(defaultValue) && defaultValue.length) {
274
+ for (const val of defaultValue) {
275
+ if (val) store.onSelectedStateChange(val, true);
276
+ }
277
+ }
278
+ return store;
279
+ });
280
+
281
+ const store = storeRef.current;
282
+
283
+ useIsomorphicLayoutEffect(() => {
284
+ if (value !== undefined) {
285
+ store.onClearSelection();
286
+ if (multiple && Array.isArray(value)) {
287
+ for (const val of value) {
288
+ if (val) store.onSelectedStateChange(val, true);
289
+ }
290
+ } else if (typeof value === "string" && value) {
291
+ store.onSelectedStateChange(value, false);
292
+ }
293
+ }
294
+ }, [value, multiple, store]);
295
+
296
+ const [focusedValue, setFocusedValue] = React.useState<string | null>(null);
297
+ const { collectionRef, getItems, onItemRegister } = useCollection();
298
+ const isShiftTabRef = useLazyRef(() => false);
299
+ const composedRef = useComposedRefs(collectionRef, forwardedRef);
300
+
301
+ const isFormControl = collectionRef.current ? !!collectionRef.current.closest("form") : true;
302
+
303
+ const onItemSelect = React.useCallback(
304
+ (itemValue: string, isMultipleEvent = false) => {
305
+ const allItems = getItems();
306
+ const item = allItems.find((item) => item.value === itemValue);
307
+
308
+ if (item?.ref.current && item.onSelect) {
309
+ const itemSelectEvent = new CustomEvent(ITEM_SELECT_EVENT, { bubbles: true });
310
+ item.ref.current.addEventListener(
311
+ ITEM_SELECT_EVENT,
312
+ () => item.onSelect?.(itemValue),
313
+ { once: true },
314
+ );
315
+ if (item.ref.current instanceof HTMLElement) {
316
+ dispatchDiscreteCustomEvent(item.ref.current, itemSelectEvent);
317
+ }
318
+ }
319
+
320
+ if (multiple) {
321
+ if (isMultipleEvent) {
322
+ const currentValues = new Set(Array.isArray(value) ? value : value ? [value] : []);
323
+ if (currentValues.has(itemValue)) {
324
+ currentValues.delete(itemValue);
325
+ } else {
326
+ currentValues.add(itemValue);
327
+ setFocusedValue(itemValue);
328
+ }
329
+ onValueChange?.([...currentValues] as ListboxValue<Multiple>);
330
+ } else {
331
+ onValueChange?.([itemValue] as ListboxValue<Multiple>);
332
+ setFocusedValue(itemValue);
333
+ }
334
+ } else {
335
+ if (value === itemValue) {
336
+ onValueChange?.("" as ListboxValue<Multiple>);
337
+ } else {
338
+ onValueChange?.(itemValue as ListboxValue<Multiple>);
339
+ setFocusedValue(itemValue);
340
+ }
341
+ }
342
+
343
+ store.onSelectedStateChange(itemValue, multiple && isMultipleEvent);
344
+ },
345
+ [value, onValueChange, store, multiple, getItems],
346
+ );
347
+
348
+ const onItemFocus = React.useCallback(
349
+ (value: string) => {
350
+ store.onStateChange("focusedValue", value);
351
+ setFocusedValue(value);
352
+ },
353
+ [store],
354
+ );
355
+
356
+ const onItemBlur = React.useCallback(() => {
357
+ store.onStateChange("focusedValue", null);
358
+ }, [store]);
359
+
360
+ const onItemHighlight = React.useCallback(
361
+ (value: string | null) => {
362
+ store.onHighlightedValueChange(value);
363
+ },
364
+ [store],
365
+ );
366
+
367
+ const focusItemByValue = React.useCallback(
368
+ (value: string) => {
369
+ const allItems = getItems();
370
+ const item = allItems.find((item) => item.value === value);
371
+ if (item?.ref.current && !virtual && !item.disabled) {
372
+ item.ref.current?.focus();
373
+ store.onStateChange("focusedValue", value);
374
+ setFocusedValue(value);
375
+ store.onHighlightedValueChange(value);
376
+ }
377
+ },
378
+ [getItems, store, virtual],
379
+ );
380
+
381
+ const onKeyDown = React.useCallback(
382
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
383
+ if (disabled) return;
384
+
385
+ if (event.key === "Tab" && event.shiftKey) {
386
+ isShiftTabRef.current = true;
387
+ if (event.target !== event.currentTarget) {
388
+ store.onStateChange("focusedValue", null);
389
+ if (!(event.currentTarget instanceof HTMLElement)) return;
390
+ event.currentTarget.focus();
391
+ }
392
+ setTimeout(() => {
393
+ isShiftTabRef.current = false;
394
+ }, 0);
395
+ return;
396
+ }
397
+
398
+ if (event.key === "Tab") return;
399
+
400
+ const isRtl = dir === "rtl";
401
+ const isVertical = orientation === "vertical" || orientation === "mixed";
402
+ const isHorizontal = orientation === "horizontal" || orientation === "mixed";
403
+
404
+ const items = getItems().filter((item) => !item.disabled);
405
+ const itemCount = items.length;
406
+ if (itemCount === 0) return;
407
+
408
+ const focusedValue = store.getSnapshot().focusedValue;
409
+ const currentIndex = focusedValue ? items.findIndex((item) => item.value === focusedValue) : -1;
410
+
411
+ let nextItem: CollectionItem | null = null;
412
+ const { columnCount, rowCount } = calculateGridLayout(items, orientation);
413
+
414
+ switch (event.key) {
415
+ case "Home": {
416
+ const minValue = getMinItemValue(items);
417
+ if (minValue) {
418
+ const minItem = items.find((item) => item.value === minValue) ?? null;
419
+ if (minItem?.ref.current && !virtual) {
420
+ minItem.ref.current?.focus();
421
+ store.onStateChange("focusedValue", minValue);
422
+ setFocusedValue(minValue);
423
+ store.onHighlightedValueChange(minValue);
424
+ }
425
+ }
426
+ event.preventDefault();
427
+ break;
428
+ }
429
+ case "End": {
430
+ const maxValue = getMaxItemValue(items);
431
+ if (maxValue) {
432
+ const maxItem = items.find((item) => item.value === maxValue) ?? null;
433
+ if (maxItem?.ref.current && !virtual) {
434
+ maxItem.ref.current?.focus();
435
+ store.onStateChange("focusedValue", maxValue);
436
+ setFocusedValue(maxValue);
437
+ store.onHighlightedValueChange(maxValue);
438
+ }
439
+ }
440
+ event.preventDefault();
441
+ break;
442
+ }
443
+ case "ArrowUp":
444
+ if (isVertical) {
445
+ if (orientation === "mixed" && columnCount > 1 && currentIndex >= 0) {
446
+ const currentCol = currentIndex % columnCount;
447
+ const targetIndex = currentIndex - columnCount;
448
+ if (targetIndex >= 0) {
449
+ nextItem = items[targetIndex] ?? null;
450
+ } else if (loop) {
451
+ const lastRowItemIndex = currentCol + (rowCount - 1) * columnCount;
452
+ const targetIndex = Math.min(lastRowItemIndex, itemCount - 1);
453
+ nextItem = items[targetIndex] ?? null;
454
+ }
455
+ } else if (currentIndex >= 0) {
456
+ nextItem = findEnabledItem(items, { startingIndex: currentIndex, decrement: true, loop });
457
+ } else if (items.length > 0) {
458
+ nextItem = items[0] ?? null;
459
+ }
460
+ event.preventDefault();
461
+ }
462
+ break;
463
+ case "ArrowDown":
464
+ if (isVertical) {
465
+ if (orientation === "mixed" && columnCount > 1 && currentIndex >= 0) {
466
+ const currentCol = currentIndex % columnCount;
467
+ const targetIndex = currentIndex + columnCount;
468
+ if (targetIndex < itemCount) {
469
+ nextItem = items[targetIndex] ?? null;
470
+ } else if (loop) {
471
+ nextItem = items[currentCol] ?? null;
472
+ }
473
+ } else if (currentIndex >= 0) {
474
+ nextItem = findEnabledItem(items, { startingIndex: currentIndex, loop });
475
+ } else if (items.length > 0) {
476
+ nextItem = items[0] ?? null;
477
+ }
478
+ event.preventDefault();
479
+ }
480
+ break;
481
+ case "ArrowLeft":
482
+ if (isHorizontal && currentIndex >= 0) {
483
+ nextItem = findEnabledItem(items, { startingIndex: currentIndex, decrement: !isRtl, loop });
484
+ event.preventDefault();
485
+ } else if (isHorizontal && items.length > 0) {
486
+ nextItem = items[0] ?? null;
487
+ event.preventDefault();
488
+ }
489
+ break;
490
+ case "ArrowRight":
491
+ if (isHorizontal && currentIndex >= 0) {
492
+ nextItem = findEnabledItem(items, { startingIndex: currentIndex, decrement: isRtl, loop });
493
+ event.preventDefault();
494
+ } else if (isHorizontal && items.length > 0) {
495
+ nextItem = items[0] ?? null;
496
+ event.preventDefault();
497
+ }
498
+ break;
499
+ case "Enter":
500
+ case " ":
501
+ if (focusedValue) {
502
+ const isMultipleSelectionKey = multiple && (multiple === true || event.ctrlKey || event.metaKey);
503
+ onItemSelect(focusedValue, isMultipleSelectionKey);
504
+ setFocusedValue(focusedValue);
505
+ const focusedItem = items.find((item) => item.value === focusedValue);
506
+ if (focusedItem?.ref.current instanceof HTMLElement) {
507
+ focusedItem.ref.current.scrollIntoView({ block: "nearest", inline: "nearest" });
508
+ }
509
+ event.preventDefault();
510
+ }
511
+ break;
512
+ case "Escape":
513
+ store.onStateChange("focusedValue", null);
514
+ store.onHighlightedValueChange(null);
515
+ event.preventDefault();
516
+ break;
517
+ default:
518
+ return;
519
+ }
520
+
521
+ if (nextItem) {
522
+ if (!virtual && nextItem.ref.current) {
523
+ nextItem.ref.current?.focus();
524
+ store.onStateChange("focusedValue", nextItem.value);
525
+ setFocusedValue(nextItem.value);
526
+ store.onHighlightedValueChange(nextItem.value);
527
+ }
528
+ }
529
+ },
530
+ [dir, orientation, store, onItemSelect, getItems, isShiftTabRef, disabled, loop, virtual, multiple],
531
+ );
532
+
533
+ const onFocus = React.useCallback(
534
+ (event: React.FocusEvent<HTMLDivElement>) => {
535
+ if (isShiftTabRef.current) return;
536
+ if (event.target === event.currentTarget) {
537
+ const items = getItems().filter((item) => !item.disabled);
538
+ if (items.length === 0) return;
539
+ if (focusedValue) {
540
+ const lastFocusedItem = items.find((item) => item.value === focusedValue);
541
+ if (lastFocusedItem) {
542
+ focusItemByValue(focusedValue);
543
+ return;
544
+ }
545
+ }
546
+ const firstItem = items[0];
547
+ if (firstItem?.ref.current && !virtual) {
548
+ firstItem.ref.current?.focus();
549
+ store.onStateChange("focusedValue", firstItem.value);
550
+ setFocusedValue(firstItem.value);
551
+ }
552
+ }
553
+ },
554
+ [focusItemByValue, getItems, virtual, store, focusedValue, isShiftTabRef],
555
+ );
556
+
557
+ const onBlur = React.useCallback(
558
+ (event: React.FocusEvent<HTMLDivElement>) => {
559
+ if (collectionRef.current && !collectionRef.current.contains(event.relatedTarget as Node)) {
560
+ store.onStateChange("focusedValue", null);
561
+ }
562
+ },
563
+ [store, collectionRef],
564
+ );
565
+
566
+ const contextValue = React.useMemo<ListboxContextValue<typeof multiple>>(
567
+ () => ({
568
+ store,
569
+ onItemRegister,
570
+ onItemSelect,
571
+ onItemFocus,
572
+ onItemBlur,
573
+ onItemHighlight,
574
+ dir,
575
+ disabled,
576
+ multiple,
577
+ }),
578
+ [store, onItemRegister, onItemSelect, onItemFocus, onItemBlur, onItemHighlight, dir, disabled, multiple],
579
+ );
580
+
581
+ const RootPrimitive = asChild ? Slot : "div";
582
+
583
+ return (
584
+ <ListboxContext.Provider value={contextValue}>
585
+ <RootPrimitive
586
+ role="listbox"
587
+ aria-multiselectable={multiple ? "true" : undefined}
588
+ data-orientation={orientation}
589
+ dir={dir}
590
+ tabIndex={disabled ? undefined : 0}
591
+ className={cn("outline-none", className)}
592
+ {...rootProps}
593
+ ref={composedRef}
594
+ onKeyDown={composeEventHandlers(rootProps.onKeyDown, onKeyDown)}
595
+ onFocus={composeEventHandlers(rootProps.onFocus, onFocus)}
596
+ onBlur={composeEventHandlers(rootProps.onBlur, onBlur)}
597
+ />
598
+ {isFormControl && (
599
+ <VisuallyHiddenInput
600
+ type="hidden"
601
+ control={collectionRef.current}
602
+ name={name}
603
+ value={value}
604
+ disabled={disabled}
605
+ />
606
+ )}
607
+ </ListboxContext.Provider>
608
+ );
609
+ }
610
+
611
+ const ListboxRoot = React.forwardRef(ListboxRootImpl) as <Multiple extends boolean = false>(
612
+ props: ListboxRootProps<Multiple> & { ref?: React.ForwardedRef<HTMLDivElement> },
613
+ ) => React.JSX.Element;
614
+ (ListboxRoot as { displayName?: string }).displayName = ROOT_NAME;
615
+
616
+ export { ListboxRoot, useListboxContext, useListboxState, ListboxContext };
@@ -0,0 +1,8 @@
1
+ "use client";
2
+
3
+ export {
4
+ ListboxRoot,
5
+ useListboxContext,
6
+ useListboxState,
7
+ ListboxContext,
8
+ } from "./ListboxProvider";
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ export {
4
+ ListboxRoot as Listbox,
5
+ ListboxRoot,
6
+ } from "./context/ListboxProvider";
7
+ export {
8
+ ListboxGroup,
9
+ ListboxGroupLabel,
10
+ } from "./parts/ListboxGroup";
11
+ export {
12
+ ListboxItem,
13
+ ListboxItemIndicator,
14
+ } from "./parts/ListboxItem";
15
+ export type {
16
+ ListboxRootProps as ListboxProps,
17
+ ListboxRootProps,
18
+ ListboxGroupProps,
19
+ ListboxGroupLabelProps,
20
+ ListboxItemProps,
21
+ ListboxItemIndicatorProps,
22
+ ListboxValue,
23
+ } from "./types";
24
+
25
+ // The dynamic-import wrapper. Exported here because the subpath resolves to
26
+ // this barrel: in the retired ui-tools `lazy.tsx` was reachable from no declared export,
27
+ // so `LazyListbox` was dead code for every tool but `JsonTree`.
28
+ export { LazyListbox } from './lazy';
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+
3
+ import { createLazyComponent, LoadingFallback } from '@djangocfg/widget-kit/lazy';
4
+ import type { ListboxRootProps } from './types';
5
+
6
+ export const LazyListbox = createLazyComponent<ListboxRootProps>(
7
+ () => import('./context/ListboxProvider').then((m) => ({ default: m.ListboxRoot })),
8
+ {
9
+ displayName: 'LazyListbox',
10
+ fallback: <LoadingFallback minHeight={200} text="Loading listbox…" />,
11
+ },
12
+ );
13
+
14
+ // Re-export light surface synchronously
15
+ export * from './types';
16
+ export {
17
+ ListboxGroup,
18
+ ListboxGroupLabel,
19
+ useListboxGroupContext,
20
+ } from './parts/ListboxGroup';
21
+ export {
22
+ ListboxItem,
23
+ ListboxItemIndicator,
24
+ useListboxItemContext,
25
+ } from './parts/ListboxItem';
26
+ export {
27
+ ListboxRoot,
28
+ useListboxContext,
29
+ useListboxState,
30
+ ListboxContext,
31
+ } from './context/ListboxProvider';