@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,1205 @@
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 { cn } from "@djangocfg/ui-core/lib";
7
+ import type { MasonryProps, MasonryItemProps, Positioner, PositionerItem } from "../types";
8
+
9
+ // ============================================================================
10
+ // Interval Tree (red-black tree for efficient range queries)
11
+ // ============================================================================
12
+
13
+ const NODE_COLOR = { RED: 0, BLACK: 1, SENTINEL: 2 } as const;
14
+ type NodeColor = (typeof NODE_COLOR)[keyof typeof NODE_COLOR];
15
+
16
+ const NODE_OPERATION = { REMOVE: 0, PRESERVE: 1 } as const;
17
+ type NodeOperation = (typeof NODE_OPERATION)[keyof typeof NODE_OPERATION];
18
+
19
+ interface ListNode { index: number; high: number; next: ListNode | null; }
20
+ interface TreeNode {
21
+ max: number; low: number; high: number; color: NodeColor;
22
+ parent: TreeNode; right: TreeNode; left: TreeNode; list: ListNode;
23
+ }
24
+ interface Tree { root: TreeNode; size: number; }
25
+
26
+ function addInterval(treeNode: TreeNode, high: number, index: number): boolean {
27
+ let node: ListNode | null = treeNode.list;
28
+ let prevNode: ListNode | undefined;
29
+ while (node) {
30
+ if (node.index === index) return false;
31
+ if (high > node.high) break;
32
+ prevNode = node;
33
+ node = node.next;
34
+ }
35
+ if (!prevNode) treeNode.list = { index, high, next: node };
36
+ if (prevNode) prevNode.next = { index, high, next: prevNode.next };
37
+ return true;
38
+ }
39
+
40
+ function removeInterval(treeNode: TreeNode, index: number): NodeOperation | undefined {
41
+ let node: ListNode | null = treeNode.list;
42
+ if (node.index === index) {
43
+ if (node.next === null) return NODE_OPERATION.REMOVE;
44
+ treeNode.list = node.next;
45
+ return NODE_OPERATION.PRESERVE;
46
+ }
47
+ let prevNode: ListNode | undefined = node;
48
+ node = node.next;
49
+ while (node !== null) {
50
+ if (node.index === index) {
51
+ prevNode.next = node.next;
52
+ return NODE_OPERATION.PRESERVE;
53
+ }
54
+ prevNode = node;
55
+ node = node.next;
56
+ }
57
+ }
58
+
59
+ const SENTINEL_NODE: TreeNode = {
60
+ low: 0, max: 0, high: 0, color: NODE_COLOR.SENTINEL,
61
+ parent: undefined as unknown as TreeNode,
62
+ right: undefined as unknown as TreeNode,
63
+ left: undefined as unknown as TreeNode,
64
+ list: undefined as unknown as ListNode,
65
+ };
66
+ SENTINEL_NODE.parent = SENTINEL_NODE;
67
+ SENTINEL_NODE.left = SENTINEL_NODE;
68
+ SENTINEL_NODE.right = SENTINEL_NODE;
69
+
70
+ function updateMax(node: TreeNode) {
71
+ const max = node.high;
72
+ if (node.left === SENTINEL_NODE && node.right === SENTINEL_NODE) node.max = max;
73
+ else if (node.left === SENTINEL_NODE) node.max = Math.max(node.right.max, max);
74
+ else if (node.right === SENTINEL_NODE) node.max = Math.max(node.left.max, max);
75
+ else node.max = Math.max(Math.max(node.left.max, node.right.max), max);
76
+ }
77
+
78
+ function updateMaxUp(node: TreeNode) {
79
+ let x = node;
80
+ while (x.parent !== SENTINEL_NODE) {
81
+ updateMax(x.parent);
82
+ x = x.parent;
83
+ }
84
+ }
85
+
86
+ function rotateLeft(tree: Tree, x: TreeNode) {
87
+ if (x.right === SENTINEL_NODE) return;
88
+ const y = x.right;
89
+ x.right = y.left;
90
+ if (y.left !== SENTINEL_NODE) y.left.parent = x;
91
+ y.parent = x.parent;
92
+ if (x.parent === SENTINEL_NODE) tree.root = y;
93
+ else if (x === x.parent.left) x.parent.left = y;
94
+ else x.parent.right = y;
95
+ y.left = x;
96
+ x.parent = y;
97
+ updateMax(x);
98
+ updateMax(y);
99
+ }
100
+
101
+ function rotateRight(tree: Tree, x: TreeNode) {
102
+ if (x.left === SENTINEL_NODE) return;
103
+ const y = x.left;
104
+ x.left = y.right;
105
+ if (y.right !== SENTINEL_NODE) y.right.parent = x;
106
+ y.parent = x.parent;
107
+ if (x.parent === SENTINEL_NODE) tree.root = y;
108
+ else if (x === x.parent.right) x.parent.right = y;
109
+ else x.parent.left = y;
110
+ y.right = x;
111
+ x.parent = y;
112
+ updateMax(x);
113
+ updateMax(y);
114
+ }
115
+
116
+ function replaceNode(tree: Tree, x: TreeNode, y: TreeNode) {
117
+ if (x.parent === SENTINEL_NODE) tree.root = y;
118
+ else if (x === x.parent.left) x.parent.left = y;
119
+ else x.parent.right = y;
120
+ y.parent = x.parent;
121
+ }
122
+
123
+ function fixRemove(tree: Tree, node: TreeNode) {
124
+ let x = node;
125
+ let w: TreeNode;
126
+ while (x !== SENTINEL_NODE && x.color === NODE_COLOR.BLACK) {
127
+ if (x === x.parent.left) {
128
+ w = x.parent.right;
129
+ if (w.color === NODE_COLOR.RED) {
130
+ w.color = NODE_COLOR.BLACK;
131
+ x.parent.color = NODE_COLOR.RED;
132
+ rotateLeft(tree, x.parent);
133
+ w = x.parent.right;
134
+ }
135
+ if (w.left.color === NODE_COLOR.BLACK && w.right.color === NODE_COLOR.BLACK) {
136
+ w.color = NODE_COLOR.RED;
137
+ x = x.parent;
138
+ } else {
139
+ if (w.right.color === NODE_COLOR.BLACK) {
140
+ w.left.color = NODE_COLOR.BLACK;
141
+ w.color = NODE_COLOR.RED;
142
+ rotateRight(tree, w);
143
+ w = x.parent.right;
144
+ }
145
+ w.color = x.parent.color;
146
+ x.parent.color = NODE_COLOR.BLACK;
147
+ w.right.color = NODE_COLOR.BLACK;
148
+ rotateLeft(tree, x.parent);
149
+ x = tree.root;
150
+ }
151
+ } else {
152
+ w = x.parent.left;
153
+ if (w.color === NODE_COLOR.RED) {
154
+ w.color = NODE_COLOR.BLACK;
155
+ x.parent.color = NODE_COLOR.RED;
156
+ rotateRight(tree, x.parent);
157
+ w = x.parent.left;
158
+ }
159
+ if (w.right.color === NODE_COLOR.BLACK && w.left.color === NODE_COLOR.BLACK) {
160
+ w.color = NODE_COLOR.RED;
161
+ x = x.parent;
162
+ } else {
163
+ if (w.left.color === NODE_COLOR.BLACK) {
164
+ w.right.color = NODE_COLOR.BLACK;
165
+ w.color = NODE_COLOR.RED;
166
+ rotateLeft(tree, w);
167
+ w = x.parent.left;
168
+ }
169
+ w.color = x.parent.color;
170
+ x.parent.color = NODE_COLOR.BLACK;
171
+ w.left.color = NODE_COLOR.BLACK;
172
+ rotateRight(tree, x.parent);
173
+ x = tree.root;
174
+ }
175
+ }
176
+ }
177
+ x.color = NODE_COLOR.BLACK;
178
+ }
179
+
180
+ function minimumTree(node: TreeNode) {
181
+ let current = node;
182
+ while (current.left !== SENTINEL_NODE) current = current.left;
183
+ return current;
184
+ }
185
+
186
+ function fixInsert(tree: Tree, node: TreeNode) {
187
+ let current = node;
188
+ let y: TreeNode;
189
+ while (current.parent.color === NODE_COLOR.RED) {
190
+ if (current.parent === current.parent.parent.left) {
191
+ y = current.parent.parent.right;
192
+ if (y.color === NODE_COLOR.RED) {
193
+ current.parent.color = NODE_COLOR.BLACK;
194
+ y.color = NODE_COLOR.BLACK;
195
+ current.parent.parent.color = NODE_COLOR.RED;
196
+ current = current.parent.parent;
197
+ } else {
198
+ if (current === current.parent.right) {
199
+ current = current.parent;
200
+ rotateLeft(tree, current);
201
+ }
202
+ current.parent.color = NODE_COLOR.BLACK;
203
+ current.parent.parent.color = NODE_COLOR.RED;
204
+ rotateRight(tree, current.parent.parent);
205
+ }
206
+ } else {
207
+ y = current.parent.parent.left;
208
+ if (y.color === NODE_COLOR.RED) {
209
+ current.parent.color = NODE_COLOR.BLACK;
210
+ y.color = NODE_COLOR.BLACK;
211
+ current.parent.parent.color = NODE_COLOR.RED;
212
+ current = current.parent.parent;
213
+ } else {
214
+ if (current === current.parent.left) {
215
+ current = current.parent;
216
+ rotateRight(tree, current);
217
+ }
218
+ current.parent.color = NODE_COLOR.BLACK;
219
+ current.parent.parent.color = NODE_COLOR.RED;
220
+ rotateLeft(tree, current.parent.parent);
221
+ }
222
+ }
223
+ }
224
+ tree.root.color = NODE_COLOR.BLACK;
225
+ }
226
+
227
+ interface IntervalTree {
228
+ insert(low: number, high: number, index: number): void;
229
+ remove(index: number): void;
230
+ search(low: number, high: number, onCallback: (index: number, low: number) => void): void;
231
+ size: number;
232
+ }
233
+
234
+ function createIntervalTree(): IntervalTree {
235
+ const tree: Tree = { root: SENTINEL_NODE, size: 0 };
236
+ const indexMap: Record<number, TreeNode> = {};
237
+
238
+ return {
239
+ insert(low, high, index) {
240
+ let x: TreeNode = tree.root;
241
+ let y: TreeNode = SENTINEL_NODE;
242
+ while (x !== SENTINEL_NODE) {
243
+ y = x;
244
+ if (low === y.low) break;
245
+ if (low < x.low) x = x.left;
246
+ else x = x.right;
247
+ }
248
+ if (low === y.low && y !== SENTINEL_NODE) {
249
+ if (!addInterval(y, high, index)) return;
250
+ y.high = Math.max(y.high, high);
251
+ updateMax(y);
252
+ updateMaxUp(y);
253
+ indexMap[index] = y;
254
+ tree.size++;
255
+ return;
256
+ }
257
+ const z: TreeNode = {
258
+ low, high, max: high, color: NODE_COLOR.RED, parent: y,
259
+ left: SENTINEL_NODE, right: SENTINEL_NODE,
260
+ list: { index, high, next: null },
261
+ };
262
+ if (y === SENTINEL_NODE) {
263
+ tree.root = z;
264
+ } else {
265
+ if (z.low < y.low) y.left = z;
266
+ else y.right = z;
267
+ updateMaxUp(z);
268
+ }
269
+ fixInsert(tree, z);
270
+ indexMap[index] = z;
271
+ tree.size++;
272
+ },
273
+
274
+ remove(index) {
275
+ const z = indexMap[index];
276
+ if (z === void 0) return;
277
+ delete indexMap[index];
278
+ const intervalResult = removeInterval(z, index);
279
+ if (intervalResult === void 0) return;
280
+ if (intervalResult === NODE_OPERATION.PRESERVE) {
281
+ z.high = z.list.high;
282
+ updateMax(z);
283
+ updateMaxUp(z);
284
+ tree.size--;
285
+ return;
286
+ }
287
+ let y = z;
288
+ let originalYColor = y.color;
289
+ let x: TreeNode;
290
+ if (z.left === SENTINEL_NODE) {
291
+ x = z.right;
292
+ replaceNode(tree, z, z.right);
293
+ } else if (z.right === SENTINEL_NODE) {
294
+ x = z.left;
295
+ replaceNode(tree, z, z.left);
296
+ } else {
297
+ y = minimumTree(z.right);
298
+ originalYColor = y.color;
299
+ x = y.right;
300
+ if (y.parent === z) {
301
+ x.parent = y;
302
+ } else {
303
+ replaceNode(tree, y, y.right);
304
+ y.right = z.right;
305
+ y.right.parent = y;
306
+ }
307
+ replaceNode(tree, z, y);
308
+ y.left = z.left;
309
+ y.left.parent = y;
310
+ y.color = z.color;
311
+ }
312
+ updateMax(x);
313
+ updateMaxUp(x);
314
+ if (originalYColor === NODE_COLOR.BLACK) fixRemove(tree, x);
315
+ tree.size--;
316
+ },
317
+
318
+ search(low, high, onCallback) {
319
+ const stack = [tree.root];
320
+ while (stack.length !== 0) {
321
+ const node = stack.pop();
322
+ if (!node) continue;
323
+ if (node === SENTINEL_NODE || low > node.max) continue;
324
+ if (node.left !== SENTINEL_NODE) stack.push(node.left);
325
+ if (node.right !== SENTINEL_NODE) stack.push(node.right);
326
+ if (node.low <= high && node.high >= low) {
327
+ let curr: ListNode | null = node.list;
328
+ while (curr !== null) {
329
+ if (curr.high >= low) onCallback(curr.index, node.low);
330
+ curr = curr.next;
331
+ }
332
+ }
333
+ }
334
+ },
335
+
336
+ get size() {
337
+ return tree.size;
338
+ },
339
+ };
340
+ }
341
+
342
+ // ============================================================================
343
+ // Positioner
344
+ // ============================================================================
345
+
346
+ const COLUMN_WIDTH = 200;
347
+ const GAP = 0;
348
+ const ITEM_HEIGHT = 300;
349
+
350
+ interface UsePositionerOptions {
351
+ width: number;
352
+ columnWidth?: number;
353
+ columnGap?: number;
354
+ rowGap?: number;
355
+ columnCount?: number;
356
+ maxColumnCount?: number;
357
+ linear?: boolean;
358
+ }
359
+
360
+ function usePositioner(
361
+ { width, columnWidth = COLUMN_WIDTH, columnGap = GAP, rowGap, columnCount, maxColumnCount, linear = false }: UsePositionerOptions,
362
+ deps: React.DependencyList = [],
363
+ ): Positioner {
364
+ const initPositioner = React.useCallback((): Positioner => {
365
+ function binarySearch(a: number[], y: number): number {
366
+ let l = 0;
367
+ let h = a.length - 1;
368
+ while (l <= h) {
369
+ const m = (l + h) >>> 1;
370
+ const x = a[m];
371
+ if (x === y) return m;
372
+ if (x === undefined || x <= y) l = m + 1;
373
+ else h = m - 1;
374
+ }
375
+ return -1;
376
+ }
377
+
378
+ const computedColumnCount =
379
+ columnCount ||
380
+ Math.min(
381
+ Math.floor((width + columnGap) / (columnWidth + columnGap)),
382
+ maxColumnCount || Number.POSITIVE_INFINITY,
383
+ ) ||
384
+ 1;
385
+ const computedColumnWidth = Math.floor(
386
+ (width - columnGap * (computedColumnCount - 1)) / computedColumnCount,
387
+ );
388
+
389
+ const intervalTree = createIntervalTree();
390
+ const columnHeights: number[] = new Array(computedColumnCount).fill(0);
391
+ const items: (PositionerItem | undefined)[] = [];
392
+ const columnItems: number[][] = new Array(computedColumnCount).fill(0).map(() => []);
393
+
394
+ for (let i = 0; i < computedColumnCount; i++) {
395
+ columnHeights[i] = 0;
396
+ columnItems[i] = [];
397
+ }
398
+
399
+ return {
400
+ columnCount: computedColumnCount,
401
+ columnWidth: computedColumnWidth,
402
+ set: (index: number, height = 0) => {
403
+ let columnIndex = 0;
404
+ if (linear) {
405
+ const preferredColumn = index % computedColumnCount;
406
+ let shortestHeight = columnHeights[0] ?? 0;
407
+ let tallestHeight = shortestHeight;
408
+ let shortestIndex = 0;
409
+ for (let i = 0; i < columnHeights.length; i++) {
410
+ const currentHeight = columnHeights[i] ?? 0;
411
+ if (currentHeight < shortestHeight) {
412
+ shortestHeight = currentHeight;
413
+ shortestIndex = i;
414
+ }
415
+ if (currentHeight > tallestHeight) tallestHeight = currentHeight;
416
+ }
417
+ const preferredHeight = (columnHeights[preferredColumn] ?? 0) + height;
418
+ const maxAllowedHeight = shortestHeight + height * 2.5;
419
+ columnIndex = preferredHeight <= maxAllowedHeight ? preferredColumn : shortestIndex;
420
+ } else {
421
+ for (let i = 1; i < columnHeights.length; i++) {
422
+ const currentHeight = columnHeights[i];
423
+ const shortestHeight = columnHeights[columnIndex];
424
+ if (currentHeight !== undefined && shortestHeight !== undefined && currentHeight < shortestHeight) {
425
+ columnIndex = i;
426
+ }
427
+ }
428
+ }
429
+
430
+ const columnHeight = columnHeights[columnIndex];
431
+ if (columnHeight === undefined) return;
432
+
433
+ const top = columnHeight;
434
+ columnHeights[columnIndex] = top + height + (rowGap ?? columnGap);
435
+
436
+ const columnItemsList = columnItems[columnIndex];
437
+ if (!columnItemsList) return;
438
+ columnItemsList.push(index);
439
+
440
+ items[index] = {
441
+ left: columnIndex * (computedColumnWidth + columnGap),
442
+ top,
443
+ height,
444
+ columnIndex,
445
+ };
446
+ intervalTree.insert(top, top + height, index);
447
+ },
448
+ get: (index: number) => items[index],
449
+ update: (updates: number[]) => {
450
+ const columns: (number | undefined)[] = new Array(computedColumnCount);
451
+ let i = 0;
452
+ let j = 0;
453
+
454
+ for (; i < updates.length - 1; i++) {
455
+ const currentIndex = updates[i];
456
+ if (typeof currentIndex !== "number") continue;
457
+ const item = items[currentIndex];
458
+ if (!item) continue;
459
+ const nextHeight = updates[++i];
460
+ if (typeof nextHeight !== "number") continue;
461
+ item.height = nextHeight;
462
+ intervalTree.remove(currentIndex);
463
+ intervalTree.insert(item.top, item.top + item.height, currentIndex);
464
+ columns[item.columnIndex] =
465
+ columns[item.columnIndex] === void 0
466
+ ? currentIndex
467
+ : Math.min(currentIndex, columns[item.columnIndex] ?? currentIndex);
468
+ }
469
+
470
+ for (i = 0; i < columns.length; i++) {
471
+ const currentColumn = columns[i];
472
+ if (currentColumn === void 0) continue;
473
+ const itemsInColumn = columnItems[i];
474
+ if (!itemsInColumn) continue;
475
+ const startIndex = binarySearch(itemsInColumn, currentColumn);
476
+ if (startIndex === -1) continue;
477
+ const currentItemIndex = itemsInColumn[startIndex];
478
+ if (typeof currentItemIndex !== "number") continue;
479
+ const startItem = items[currentItemIndex];
480
+ if (!startItem) continue;
481
+ const currentHeight = columnHeights[i];
482
+ if (typeof currentHeight !== "number") continue;
483
+ columnHeights[i] = startItem.top + startItem.height + (rowGap ?? columnGap);
484
+
485
+ for (j = startIndex + 1; j < itemsInColumn.length; j++) {
486
+ const currentIndex = itemsInColumn[j];
487
+ if (typeof currentIndex !== "number") continue;
488
+ const item = items[currentIndex];
489
+ if (!item) continue;
490
+ const columnHeight = columnHeights[i];
491
+ if (typeof columnHeight !== "number") continue;
492
+ item.top = columnHeight;
493
+ columnHeights[i] = item.top + item.height + (rowGap ?? columnGap);
494
+ intervalTree.remove(currentIndex);
495
+ intervalTree.insert(item.top, item.top + item.height, currentIndex);
496
+ }
497
+ }
498
+ },
499
+ range: (low, high, onItemRender) =>
500
+ intervalTree.search(low, high, (index: number, top: number) => {
501
+ const item = items[index];
502
+ if (!item) return;
503
+ onItemRender(index, item.left, top);
504
+ }),
505
+ estimateHeight: (itemCount, defaultItemHeight): number => {
506
+ const tallestColumn = Math.max(0, Math.max.apply(null, columnHeights));
507
+ return itemCount === intervalTree.size
508
+ ? tallestColumn
509
+ : tallestColumn +
510
+ Math.ceil((itemCount - intervalTree.size) / computedColumnCount) * defaultItemHeight;
511
+ },
512
+ shortestColumn: () => {
513
+ if (columnHeights.length > 1) return Math.min.apply(null, columnHeights);
514
+ return columnHeights[0] ?? 0;
515
+ },
516
+ size(): number {
517
+ return intervalTree.size;
518
+ },
519
+ all(): PositionerItem[] {
520
+ return items.filter(Boolean) as PositionerItem[];
521
+ },
522
+ };
523
+ }, [width, columnWidth, columnGap, rowGap, columnCount, maxColumnCount, linear]);
524
+
525
+ const positionerRef = React.useRef<Positioner | null>(null);
526
+ if (positionerRef.current === null) positionerRef.current = initPositioner();
527
+
528
+ const prevDepsRef = React.useRef(deps);
529
+ const opts = [width, columnWidth, columnGap, rowGap, columnCount, maxColumnCount, linear];
530
+ const prevOptsRef = React.useRef(opts);
531
+ const optsChanged = !opts.every((item, i) => prevOptsRef.current[i] === item);
532
+
533
+ if (optsChanged || !deps.every((item, i) => prevDepsRef.current[i] === item)) {
534
+ const prevPositioner = positionerRef.current;
535
+ const positioner = initPositioner();
536
+ prevDepsRef.current = deps;
537
+ prevOptsRef.current = opts;
538
+
539
+ if (optsChanged) {
540
+ const cacheSize = prevPositioner.size();
541
+ for (let index = 0; index < cacheSize; index++) {
542
+ const pos = prevPositioner.get(index);
543
+ positioner.set(index, pos !== void 0 ? pos.height : 0);
544
+ }
545
+ }
546
+
547
+ positionerRef.current = positioner;
548
+ }
549
+
550
+ return positionerRef.current;
551
+ }
552
+
553
+ // ============================================================================
554
+ // Hooks
555
+ // ============================================================================
556
+
557
+ const DEBOUNCE_DELAY = 300;
558
+ const SCROLL_FPS = 12;
559
+
560
+ interface DebouncedWindowSizeOptions {
561
+ containerRef: React.RefObject<HTMLDivElement | null>;
562
+ defaultWidth?: number;
563
+ defaultHeight?: number;
564
+ delayMs?: number;
565
+ }
566
+
567
+ function useDebouncedWindowSize(options: DebouncedWindowSizeOptions) {
568
+ const { containerRef, defaultWidth = 0, defaultHeight = 0, delayMs = DEBOUNCE_DELAY } = options;
569
+
570
+ const getDocumentSize = React.useCallback(() => {
571
+ if (typeof document === "undefined") {
572
+ return { width: defaultWidth, height: defaultHeight };
573
+ }
574
+ return {
575
+ width: document.documentElement.clientWidth,
576
+ height: document.documentElement.clientHeight,
577
+ };
578
+ }, [defaultWidth, defaultHeight]);
579
+
580
+ const [size, setSize] = React.useState(getDocumentSize());
581
+ const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
582
+
583
+ const setDebouncedSize = React.useCallback(
584
+ (value: { width: number; height: number }) => {
585
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
586
+ timeoutRef.current = setTimeout(() => {
587
+ setSize(value);
588
+ }, delayMs);
589
+ },
590
+ [delayMs],
591
+ );
592
+
593
+ React.useEffect(() => {
594
+ function onResize() {
595
+ if (containerRef.current) {
596
+ setDebouncedSize({
597
+ width: containerRef.current.offsetWidth,
598
+ height: document.documentElement.clientHeight,
599
+ });
600
+ } else {
601
+ setDebouncedSize(getDocumentSize());
602
+ }
603
+ }
604
+ window?.addEventListener("resize", onResize, { passive: true });
605
+ window?.addEventListener("orientationchange", onResize);
606
+ window.visualViewport?.addEventListener("resize", onResize);
607
+ return () => {
608
+ window?.removeEventListener("resize", onResize);
609
+ window?.removeEventListener("orientationchange", onResize);
610
+ window.visualViewport?.removeEventListener("resize", onResize);
611
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
612
+ };
613
+ }, [setDebouncedSize, containerRef, getDocumentSize]);
614
+
615
+ return size;
616
+ }
617
+
618
+ function useLazyRef<T>(init: () => T): React.RefObject<T> {
619
+ const ref = React.useRef<T | null>(null);
620
+ if (ref.current === null) {
621
+ ref.current = init();
622
+ }
623
+ return ref as React.RefObject<T>;
624
+ }
625
+
626
+ type OnRafScheduleReturn<T extends unknown[]> = { (...args: T): void; cancel: () => void };
627
+
628
+ function onRafSchedule<T extends unknown[]>(callback: (...args: T) => void): OnRafScheduleReturn<T> {
629
+ let lastArgs: T = [] as unknown as T;
630
+ let frameId: number | null = null;
631
+ function onCallback(...args: T) {
632
+ lastArgs = args;
633
+ if (frameId === null) {
634
+ frameId = requestAnimationFrame(() => {
635
+ frameId = null;
636
+ callback(...lastArgs);
637
+ });
638
+ }
639
+ }
640
+ onCallback.cancel = () => {
641
+ if (frameId === null) return;
642
+ cancelAnimationFrame(frameId);
643
+ frameId = null;
644
+ };
645
+ return onCallback;
646
+ }
647
+
648
+ function useResizeObserver(positioner: Positioner) {
649
+ const [, setLayoutVersion] = React.useState(0);
650
+
651
+ const createResizeObserver = React.useMemo(() => {
652
+ if (typeof window === "undefined") {
653
+ return () => ({ disconnect: () => {}, observe: () => {}, unobserve: () => {} });
654
+ }
655
+
656
+ const cache = new WeakMap<object, ResizeObserver>();
657
+
658
+ return (positioner: Positioner, onUpdate: () => void) => {
659
+ const key = { positioner, onUpdate };
660
+ if (cache.has(key)) return cache.get(key)!;
661
+
662
+ const updates: number[] = [];
663
+ const itemMap = new WeakMap<Element, number>();
664
+
665
+ const update = onRafSchedule(() => {
666
+ if (updates.length > 0) {
667
+ positioner.update(updates);
668
+ onUpdate();
669
+ }
670
+ updates.length = 0;
671
+ });
672
+
673
+ function onItemResize(target: HTMLDivElement) {
674
+ const height = target.offsetHeight;
675
+ if (height > 0) {
676
+ const index = itemMap.get(target);
677
+ if (index !== void 0) {
678
+ const pos = positioner.get(index);
679
+ if (pos !== void 0 && height !== pos.height) {
680
+ updates.push(index, height);
681
+ }
682
+ }
683
+ }
684
+ update();
685
+ }
686
+
687
+ const scheduledItemMap = new Map<number, OnRafScheduleReturn<[HTMLDivElement]>>();
688
+ function onResizeObserver(entries: ResizeObserverEntry[]) {
689
+ for (const entry of entries) {
690
+ if (!entry) continue;
691
+ const index = itemMap.get(entry.target);
692
+ if (index === void 0) continue;
693
+ let handler = scheduledItemMap.get(index);
694
+ if (!handler) {
695
+ handler = onRafSchedule(onItemResize);
696
+ scheduledItemMap.set(index, handler);
697
+ }
698
+ handler(entry.target as HTMLDivElement);
699
+ }
700
+ }
701
+
702
+ const observer = new ResizeObserver(onResizeObserver);
703
+ const disconnect = observer.disconnect.bind(observer);
704
+ observer.disconnect = () => {
705
+ disconnect();
706
+ for (const [, scheduleItem] of scheduledItemMap) {
707
+ scheduleItem.cancel();
708
+ }
709
+ };
710
+
711
+ cache.set(key, observer);
712
+ return observer;
713
+ };
714
+ }, []);
715
+
716
+ const resizeObserver = createResizeObserver(positioner, () =>
717
+ setLayoutVersion((prev) => prev + 1),
718
+ );
719
+
720
+ React.useEffect(() => () => resizeObserver.disconnect(), [resizeObserver]);
721
+
722
+ return resizeObserver;
723
+ }
724
+
725
+ function useThrottle<State>(
726
+ initialState: State | (() => State),
727
+ options: { fps?: number; leading?: boolean } = {},
728
+ ): [State, React.Dispatch<React.SetStateAction<State>>] {
729
+ const { fps = 30, leading = false } = options;
730
+ const [state, setState] = React.useState(initialState);
731
+ const latestSetState = React.useRef(setState);
732
+ latestSetState.current = setState;
733
+
734
+ const ms = 1000 / fps;
735
+ const prevCountRef = React.useRef(0);
736
+ const trailingTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(null);
737
+
738
+ const clearTrailing = React.useCallback(() => {
739
+ if (trailingTimeout.current) clearTimeout(trailingTimeout.current);
740
+ }, []);
741
+
742
+ React.useEffect(() => {
743
+ return () => {
744
+ prevCountRef.current = 0;
745
+ clearTrailing();
746
+ };
747
+ }, [clearTrailing]);
748
+
749
+ const throttledSetState = React.useCallback(
750
+ (action: React.SetStateAction<State>) => {
751
+ const perf = typeof performance !== "undefined" ? performance : Date;
752
+ const now = () => perf.now();
753
+ const rightNow = now();
754
+ const call = () => {
755
+ prevCountRef.current = rightNow;
756
+ clearTrailing();
757
+ latestSetState.current(action);
758
+ };
759
+ const current = prevCountRef.current;
760
+
761
+ if (leading && current === 0) return call();
762
+ if (rightNow - current > ms) {
763
+ if (current > 0) return call();
764
+ prevCountRef.current = rightNow;
765
+ }
766
+
767
+ clearTrailing();
768
+ trailingTimeout.current = setTimeout(() => {
769
+ call();
770
+ prevCountRef.current = 0;
771
+ }, ms);
772
+ },
773
+ [leading, ms, clearTrailing],
774
+ );
775
+
776
+ return [state, throttledSetState];
777
+ }
778
+
779
+ function useScroller({
780
+ offset = 0,
781
+ fps = SCROLL_FPS,
782
+ }: { offset?: number; fps?: number } = {}): { scrollTop: number; isScrolling: boolean } {
783
+ const [scrollY, setScrollY] = useThrottle(
784
+ typeof globalThis.window === "undefined"
785
+ ? 0
786
+ : (globalThis.window.scrollY ?? document.documentElement.scrollTop ?? 0),
787
+ { fps, leading: true },
788
+ );
789
+
790
+ const onScroll = React.useCallback(() => {
791
+ setScrollY(globalThis.window.scrollY ?? document.documentElement.scrollTop ?? 0);
792
+ }, [setScrollY]);
793
+
794
+ React.useEffect(() => {
795
+ if (typeof globalThis.window === "undefined") return;
796
+ globalThis.window.addEventListener("scroll", onScroll, { passive: true });
797
+ return () => globalThis.window.removeEventListener("scroll", onScroll);
798
+ }, [onScroll]);
799
+
800
+ const [isScrolling, setIsScrolling] = React.useState(false);
801
+ const hasMountedRef = React.useRef(0);
802
+
803
+ React.useEffect(() => {
804
+ if (hasMountedRef.current === 1) setIsScrolling(true);
805
+ let didUnsubscribe = false;
806
+
807
+ function requestTimeout(fn: () => void, delay: number) {
808
+ const start = performance.now();
809
+ const handle = {
810
+ id: requestAnimationFrame(function tick(timestamp) {
811
+ if (timestamp - start >= delay) fn();
812
+ else handle.id = requestAnimationFrame(tick);
813
+ }),
814
+ };
815
+ return handle;
816
+ }
817
+
818
+ const timeout = requestTimeout(() => {
819
+ if (didUnsubscribe) return;
820
+ setIsScrolling(false);
821
+ }, 40 + 1000 / fps);
822
+ hasMountedRef.current = 1;
823
+ return () => {
824
+ didUnsubscribe = true;
825
+ cancelAnimationFrame(timeout.id);
826
+ };
827
+ }, [fps, scrollY]);
828
+
829
+ return { scrollTop: Math.max(0, scrollY - offset), isScrolling };
830
+ }
831
+
832
+ // ============================================================================
833
+ // Masonry Components
834
+ // ============================================================================
835
+
836
+ const ROOT_NAME = "MasonryRoot";
837
+ const VIEWPORT_NAME = "MasonryViewport";
838
+ const ITEM_NAME = "MasonryItem";
839
+
840
+ const MASONRY_ERROR = {
841
+ [ROOT_NAME]: `\`${ROOT_NAME}\` components must be within \`${ROOT_NAME}\``,
842
+ [VIEWPORT_NAME]: `\`${VIEWPORT_NAME}\` components must be within \`${ROOT_NAME}\``,
843
+ [ITEM_NAME]: `\`${ITEM_NAME}\` must be within \`${VIEWPORT_NAME}\``,
844
+ } as const;
845
+
846
+ type RootElement = React.ComponentRef<typeof Masonry>;
847
+ type ItemElement = React.ComponentRef<typeof MasonryItem>;
848
+
849
+ interface MasonryContextValue {
850
+ positioner: Positioner;
851
+ resizeObserver?: ResizeObserver;
852
+ columnWidth: number;
853
+ onItemRegister: (index: number) => (node: ItemElement | null) => void;
854
+ scrollTop: number;
855
+ windowHeight: number;
856
+ itemHeight: number;
857
+ overscan: number;
858
+ isScrolling?: boolean;
859
+ fallback?: React.ReactNode;
860
+ }
861
+
862
+ const MasonryContext = React.createContext<MasonryContextValue | null>(null);
863
+
864
+ function useMasonryContext(name: keyof typeof MASONRY_ERROR) {
865
+ const context = React.useContext(MasonryContext);
866
+ if (!context) {
867
+ throw new Error(MASONRY_ERROR[name]);
868
+ }
869
+ return context;
870
+ }
871
+
872
+ const OVERSCAN = 2;
873
+
874
+ function Masonry(props: MasonryProps) {
875
+ const {
876
+ columnWidth = COLUMN_WIDTH,
877
+ columnCount,
878
+ maxColumnCount,
879
+ gap = GAP,
880
+ itemHeight = ITEM_HEIGHT,
881
+ defaultWidth,
882
+ defaultHeight,
883
+ overscan = OVERSCAN,
884
+ scrollFps = SCROLL_FPS,
885
+ fallback,
886
+ linear = false,
887
+ asChild,
888
+ children,
889
+ style,
890
+ ref,
891
+ ...rootProps
892
+ } = props;
893
+
894
+ const gapValue = typeof gap === "object" ? gap : { column: gap, row: gap };
895
+ const columnGap = gapValue.column;
896
+ const rowGap = gapValue.row;
897
+
898
+ const containerRef = React.useRef<RootElement | null>(null);
899
+ const composedRef = useComposedRefs(ref, containerRef);
900
+
901
+ const size = useDebouncedWindowSize({
902
+ containerRef,
903
+ defaultWidth,
904
+ defaultHeight,
905
+ delayMs: DEBOUNCE_DELAY,
906
+ });
907
+
908
+ const [containerPosition, setContainerPosition] = React.useState<{ offset: number; width: number }>({
909
+ offset: 0,
910
+ width: 0,
911
+ });
912
+
913
+ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
914
+
915
+ useIsomorphicLayoutEffect(() => {
916
+ if (!containerRef.current) return;
917
+ let offset = 0;
918
+ let container = containerRef.current;
919
+ do {
920
+ offset += container.offsetTop ?? 0;
921
+ container = container.offsetParent as RootElement;
922
+ } while (container);
923
+
924
+ if (
925
+ offset !== containerPosition.offset ||
926
+ containerRef.current.offsetWidth !== containerPosition.width
927
+ ) {
928
+ setContainerPosition({
929
+ offset,
930
+ width: containerRef.current.offsetWidth,
931
+ });
932
+ }
933
+ }, [containerPosition, size]);
934
+
935
+ const positioner = usePositioner({
936
+ width: containerPosition.width ?? size.width,
937
+ columnWidth,
938
+ columnGap,
939
+ rowGap,
940
+ columnCount,
941
+ maxColumnCount,
942
+ linear,
943
+ });
944
+ const resizeObserver = useResizeObserver(positioner);
945
+ const { scrollTop, isScrolling } = useScroller({
946
+ offset: containerPosition.offset,
947
+ fps: scrollFps,
948
+ });
949
+
950
+ const itemMap = React.useRef(new WeakMap<ItemElement, number>()).current;
951
+
952
+ const onItemRegister = React.useCallback(
953
+ (index: number) => (node: ItemElement | null) => {
954
+ if (!node) return;
955
+ itemMap.set(node, index);
956
+ if (resizeObserver) {
957
+ resizeObserver.observe(node);
958
+ }
959
+ if (positioner.get(index) === void 0) {
960
+ positioner.set(index, node.offsetHeight);
961
+ }
962
+ },
963
+ [itemMap, positioner, resizeObserver],
964
+ );
965
+
966
+ const contextValue = React.useMemo<MasonryContextValue>(
967
+ () => ({
968
+ positioner,
969
+ resizeObserver,
970
+ columnWidth: positioner.columnWidth,
971
+ onItemRegister,
972
+ scrollTop,
973
+ windowHeight: size.height,
974
+ itemHeight,
975
+ overscan,
976
+ fallback,
977
+ isScrolling,
978
+ }),
979
+ [positioner, resizeObserver, onItemRegister, scrollTop, size.height, itemHeight, overscan, fallback, isScrolling],
980
+ );
981
+
982
+ const RootPrimitive = asChild ? Slot : "div";
983
+
984
+ return (
985
+ <MasonryContext.Provider value={contextValue}>
986
+ <RootPrimitive
987
+ {...rootProps}
988
+ data-slot="masonry"
989
+ ref={composedRef}
990
+ style={{
991
+ position: "relative",
992
+ width: "100%",
993
+ height: "100%",
994
+ ...style,
995
+ }}
996
+ >
997
+ <MasonryViewport>{children}</MasonryViewport>
998
+ </RootPrimitive>
999
+ </MasonryContext.Provider>
1000
+ );
1001
+ }
1002
+
1003
+ interface MasonryItemPropsWithRef extends MasonryItemProps {
1004
+ ref: React.Ref<ItemElement | null>;
1005
+ }
1006
+
1007
+ function MasonryViewport(props: React.ComponentProps<"div">) {
1008
+ const { children, style, ref, ...viewportProps } = props;
1009
+ const context = useMasonryContext(VIEWPORT_NAME);
1010
+ const [layoutVersion, setLayoutVersion] = React.useState(0);
1011
+ const rafId = React.useRef<number | null>(null);
1012
+ const [mounted, setMounted] = React.useState(false);
1013
+
1014
+ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
1015
+
1016
+ useIsomorphicLayoutEffect(() => {
1017
+ setMounted(true);
1018
+ }, []);
1019
+
1020
+ let startIndex = 0;
1021
+ let stopIndex: number | undefined;
1022
+
1023
+ const validChildren = React.Children.toArray(children).filter(
1024
+ (child): child is React.ReactElement<MasonryItemPropsWithRef> =>
1025
+ React.isValidElement(child) && (child.type === MasonryItem || child.type === MasonryItem),
1026
+ );
1027
+ const itemCount = validChildren.length;
1028
+
1029
+ const shortestColumnSize = context.positioner.shortestColumn();
1030
+ const measuredCount = context.positioner.size();
1031
+ const overscanPixels = context.windowHeight * context.overscan;
1032
+ const rangeStart = Math.max(0, context.scrollTop - overscanPixels / 2);
1033
+ const rangeEnd = context.scrollTop + overscanPixels;
1034
+ const layoutOutdated = shortestColumnSize < rangeEnd && measuredCount < itemCount;
1035
+
1036
+ const positionedChildren: React.ReactElement[] = [];
1037
+
1038
+ const visibleItemStyle = React.useMemo(
1039
+ (): React.CSSProperties => ({
1040
+ position: "absolute",
1041
+ writingMode: "horizontal-tb",
1042
+ visibility: "visible",
1043
+ width: context.columnWidth,
1044
+ transform: context.isScrolling ? "translateZ(0)" : undefined,
1045
+ willChange: context.isScrolling ? "transform" : undefined,
1046
+ }),
1047
+ [context.columnWidth, context.isScrolling],
1048
+ );
1049
+
1050
+ const hiddenItemStyle = React.useMemo(
1051
+ (): React.CSSProperties => ({
1052
+ position: "absolute",
1053
+ writingMode: "horizontal-tb",
1054
+ visibility: "hidden",
1055
+ width: context.columnWidth,
1056
+ zIndex: -1000,
1057
+ }),
1058
+ [context.columnWidth],
1059
+ );
1060
+
1061
+ context.positioner.range(rangeStart, rangeEnd, (index, left, top) => {
1062
+ const child = validChildren[index];
1063
+ if (!child) return;
1064
+
1065
+ const itemStyle = {
1066
+ ...visibleItemStyle,
1067
+ top,
1068
+ left,
1069
+ ...child.props.style,
1070
+ };
1071
+
1072
+ positionedChildren.push(
1073
+ React.cloneElement(child, {
1074
+ key: child.key ?? index,
1075
+ ref: context.onItemRegister(index),
1076
+ style: itemStyle,
1077
+ }),
1078
+ );
1079
+
1080
+ if (stopIndex === undefined) {
1081
+ startIndex = index;
1082
+ stopIndex = index;
1083
+ } else {
1084
+ startIndex = Math.min(startIndex, index);
1085
+ stopIndex = Math.max(stopIndex, index);
1086
+ }
1087
+ });
1088
+
1089
+ if (layoutOutdated && mounted) {
1090
+ const batchSize = Math.min(
1091
+ itemCount - measuredCount,
1092
+ Math.ceil(
1093
+ ((context.scrollTop + overscanPixels - shortestColumnSize) / context.itemHeight) *
1094
+ context.positioner.columnCount,
1095
+ ),
1096
+ );
1097
+
1098
+ for (let index = measuredCount; index < measuredCount + batchSize; index++) {
1099
+ const child = validChildren[index];
1100
+ if (!child) continue;
1101
+
1102
+ const itemStyle = {
1103
+ ...hiddenItemStyle,
1104
+ ...child.props.style,
1105
+ };
1106
+
1107
+ positionedChildren.push(
1108
+ React.cloneElement(child, {
1109
+ key: child.key ?? index,
1110
+ ref: context.onItemRegister(index),
1111
+ style: itemStyle,
1112
+ }),
1113
+ );
1114
+ }
1115
+ }
1116
+
1117
+ React.useEffect(() => {
1118
+ if (layoutOutdated && mounted) {
1119
+ if (rafId.current) cancelAnimationFrame(rafId.current);
1120
+ rafId.current = requestAnimationFrame(() => {
1121
+ setLayoutVersion((v) => v + 1);
1122
+ });
1123
+ }
1124
+ return () => {
1125
+ if (rafId.current) cancelAnimationFrame(rafId.current);
1126
+ };
1127
+ }, [layoutOutdated, mounted]);
1128
+
1129
+ const estimatedHeight = React.useMemo(() => {
1130
+ const measuredHeight = context.positioner.estimateHeight(measuredCount, context.itemHeight);
1131
+ if (measuredCount === itemCount) return measuredHeight;
1132
+ const remainingItems = itemCount - measuredCount;
1133
+ const estimatedRemainingHeight = Math.ceil(
1134
+ (remainingItems / context.positioner.columnCount) * context.itemHeight,
1135
+ );
1136
+ return measuredHeight + estimatedRemainingHeight;
1137
+ }, [context.positioner, context.itemHeight, measuredCount, itemCount]);
1138
+
1139
+ const containerStyle = React.useMemo(
1140
+ () => ({
1141
+ position: "relative" as const,
1142
+ width: "100%",
1143
+ maxWidth: "100%",
1144
+ height: Math.ceil(estimatedHeight),
1145
+ maxHeight: Math.ceil(estimatedHeight),
1146
+ willChange: context.isScrolling ? "contents" : undefined,
1147
+ pointerEvents: context.isScrolling ? ("none" as const) : undefined,
1148
+ ...style,
1149
+ }),
1150
+ [context.isScrolling, estimatedHeight, style],
1151
+ );
1152
+
1153
+ // First-paint placeholder: positioned children are still empty until the
1154
+ // ResizeObserver has measured at least one item. Without this branch the
1155
+ // viewport renders `null` on the first frame and feels broken to first-
1156
+ // time users. Consumer-supplied `fallback` wins; otherwise we render a
1157
+ // minimal muted skeleton so something is on screen immediately.
1158
+ if (!mounted) {
1159
+ if (context.fallback) {
1160
+ return context.fallback as React.ReactElement;
1161
+ }
1162
+ return <MasonryFallback itemCount={itemCount} />;
1163
+ }
1164
+
1165
+ return (
1166
+ <div {...viewportProps} ref={ref} style={containerStyle} data-version={mounted ? layoutVersion : undefined}>
1167
+ {positionedChildren}
1168
+ </div>
1169
+ );
1170
+ }
1171
+
1172
+ function MasonryFallback({ itemCount }: { itemCount: number }) {
1173
+ const placeholders = Math.min(Math.max(itemCount, 1), 6);
1174
+ return (
1175
+ <div
1176
+ data-slot="masonry-fallback"
1177
+ aria-hidden="true"
1178
+ style={{
1179
+ display: "grid",
1180
+ gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
1181
+ gap: "var(--masonry-fallback-gap, 12px)",
1182
+ width: "100%",
1183
+ }}
1184
+ >
1185
+ {Array.from({ length: placeholders }).map((_, i) => (
1186
+ <div
1187
+ key={i}
1188
+ style={{
1189
+ height: 12 * (8 + (i % 4) * 3) /* 96 / 132 / 168 / 204 */,
1190
+ borderRadius: "var(--radius-sm)",
1191
+ background: "color-mix(in oklab, var(--muted, #e5e7eb) 70%, transparent)",
1192
+ }}
1193
+ />
1194
+ ))}
1195
+ </div>
1196
+ );
1197
+ }
1198
+
1199
+ function MasonryItem(props: MasonryItemProps) {
1200
+ const { asChild, ref, ...itemProps } = props;
1201
+ const ItemPrimitive = asChild ? Slot : "div";
1202
+ return <ItemPrimitive data-slot="masonry-item" {...itemProps} ref={ref} />;
1203
+ }
1204
+
1205
+ export { Masonry, MasonryItem, MasonryViewport, useMasonryContext };