@ankhorage/zora 25.3.0 → 25.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -1
  3. package/dist/features/registry/ZORA_COMPONENT_REGISTRY.d.ts.map +1 -1
  4. package/dist/features/registry/ZORA_COMPONENT_REGISTRY.js +4 -0
  5. package/dist/features/registry/ZORA_COMPONENT_REGISTRY.js.map +1 -1
  6. package/dist/features/registry/componentMeta.d.ts.map +1 -1
  7. package/dist/features/registry/componentMeta.js +4 -0
  8. package/dist/features/registry/componentMeta.js.map +1 -1
  9. package/dist/features/spatial-grid/adapters/inbound/SpatialGrid.d.ts +5 -0
  10. package/dist/features/spatial-grid/adapters/inbound/SpatialGrid.d.ts.map +1 -0
  11. package/dist/features/spatial-grid/adapters/inbound/SpatialGrid.js +38 -0
  12. package/dist/features/spatial-grid/adapters/inbound/SpatialGrid.js.map +1 -0
  13. package/dist/features/spatial-grid/application/sortSpatialGridItems.d.ts +4 -0
  14. package/dist/features/spatial-grid/application/sortSpatialGridItems.d.ts.map +1 -0
  15. package/dist/features/spatial-grid/application/sortSpatialGridItems.js +8 -0
  16. package/dist/features/spatial-grid/application/sortSpatialGridItems.js.map +1 -0
  17. package/dist/features/spatial-grid/meta.d.ts +11 -0
  18. package/dist/features/spatial-grid/meta.d.ts.map +1 -0
  19. package/dist/features/spatial-grid/meta.js +11 -0
  20. package/dist/features/spatial-grid/meta.js.map +1 -0
  21. package/dist/features/spatial-grid/public.d.ts +3 -0
  22. package/dist/features/spatial-grid/public.d.ts.map +1 -0
  23. package/dist/features/spatial-grid/public.js +2 -0
  24. package/dist/features/spatial-grid/public.js.map +1 -0
  25. package/dist/features/time-grid/adapters/inbound/TimeGrid.d.ts +5 -0
  26. package/dist/features/time-grid/adapters/inbound/TimeGrid.d.ts.map +1 -0
  27. package/dist/features/time-grid/adapters/inbound/TimeGrid.js +107 -0
  28. package/dist/features/time-grid/adapters/inbound/TimeGrid.js.map +1 -0
  29. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.d.ts +9 -0
  30. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.d.ts.map +1 -0
  31. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.js +5 -0
  32. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.js.map +1 -0
  33. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.d.ts +9 -0
  34. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.d.ts.map +1 -0
  35. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.js +5 -0
  36. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.js.map +1 -0
  37. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.d.ts +9 -0
  38. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.d.ts.map +1 -0
  39. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.js +10 -0
  40. package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.js.map +1 -0
  41. package/dist/features/time-grid/application/resolveTimeGridNextFocusId.d.ts +4 -0
  42. package/dist/features/time-grid/application/resolveTimeGridNextFocusId.d.ts.map +1 -0
  43. package/dist/features/time-grid/application/resolveTimeGridNextFocusId.js +31 -0
  44. package/dist/features/time-grid/application/resolveTimeGridNextFocusId.js.map +1 -0
  45. package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.d.ts +5 -0
  46. package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.d.ts.map +1 -0
  47. package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.js +6 -0
  48. package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.js.map +1 -0
  49. package/dist/features/time-grid/meta.d.ts +11 -0
  50. package/dist/features/time-grid/meta.d.ts.map +1 -0
  51. package/dist/features/time-grid/meta.js +11 -0
  52. package/dist/features/time-grid/meta.js.map +1 -0
  53. package/dist/features/time-grid/public.d.ts +5 -0
  54. package/dist/features/time-grid/public.d.ts.map +1 -0
  55. package/dist/features/time-grid/public.js +4 -0
  56. package/dist/features/time-grid/public.js.map +1 -0
  57. package/dist/index.d.ts +4 -0
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +2 -0
  60. package/dist/index.js.map +1 -1
  61. package/dist/types/spatial-grid.d.ts +41 -0
  62. package/dist/types/spatial-grid.d.ts.map +1 -0
  63. package/dist/types/spatial-grid.js +2 -0
  64. package/dist/types/spatial-grid.js.map +1 -0
  65. package/dist/types/time-grid.d.ts +30 -0
  66. package/dist/types/time-grid.d.ts.map +1 -0
  67. package/dist/types/time-grid.js +2 -0
  68. package/dist/types/time-grid.js.map +1 -0
  69. package/package.json +3 -3
  70. package/src/features/registry/ZORA_COMPONENT_REGISTRY.ts +4 -0
  71. package/src/features/registry/componentMeta.ts +4 -0
  72. package/src/features/spatial-grid/adapters/inbound/SpatialGrid.test.tsx +248 -0
  73. package/src/features/spatial-grid/adapters/inbound/SpatialGrid.tsx +112 -0
  74. package/src/features/spatial-grid/application/sortSpatialGridItems.test.ts +42 -0
  75. package/src/features/spatial-grid/application/sortSpatialGridItems.ts +14 -0
  76. package/src/features/spatial-grid/meta.ts +12 -0
  77. package/src/features/spatial-grid/public.ts +6 -0
  78. package/src/features/time-grid/adapters/inbound/TimeGrid.test.tsx +131 -0
  79. package/src/features/time-grid/adapters/inbound/TimeGrid.tsx +197 -0
  80. package/src/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.tsx +11 -0
  81. package/src/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.tsx +11 -0
  82. package/src/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.tsx +20 -0
  83. package/src/features/time-grid/application/resolveTimeGridNextFocusId.test.ts +46 -0
  84. package/src/features/time-grid/application/resolveTimeGridNextFocusId.ts +41 -0
  85. package/src/features/time-grid/application/resolveTimeGridVisibleIntervals.test.ts +33 -0
  86. package/src/features/time-grid/application/resolveTimeGridVisibleIntervals.ts +13 -0
  87. package/src/features/time-grid/meta.ts +12 -0
  88. package/src/features/time-grid/public.ts +4 -0
  89. package/src/index.ts +8 -0
  90. package/src/types/spatial-grid.ts +44 -0
  91. package/src/types/time-grid.ts +33 -0
  92. package/web-dist/chunks/{Accordion-e6ddpz5v.js → Accordion-0my2z6qe.js} +85 -1
  93. package/web-dist/chunks/{Accordion-e9ewv367.js → Accordion-7qfd4cb4.js} +1 -1
  94. package/web-dist/chunks/{Accordion-689w7frk.js → Accordion-d6azgz09.js} +1 -1
  95. package/web-dist/chunks/{Accordion-q2z00p5r.js → Accordion-gxj5qbpr.js} +1 -1
  96. package/web-dist/components/explorer/Explorer.js +2 -2
  97. package/web-dist/components/file-explorer/FileExplorer.js +2 -2
  98. package/web-dist/components/grid-line-overlay/GridLineOverlay.js +1 -1
  99. package/web-dist/components/grid-ruler/GridRuler.js +1 -1
  100. package/web-dist/components/grid-view/GridView.js +1 -1
  101. package/web-dist/components/media-explorer/MediaExplorer.js +2 -2
  102. package/web-dist/components/reader/Reader.js +1 -1
  103. package/web-dist/components/spatial-grid/SpatialGrid.d.ts +3 -0
  104. package/web-dist/components/spatial-grid/SpatialGrid.js +102 -0
  105. package/web-dist/components/spatial-grid/index.d.ts +1 -0
  106. package/web-dist/components/spatial-grid/index.js +2 -0
  107. package/web-dist/components/tile-grid/TileGrid.js +1 -1
  108. package/web-dist/components/time-grid/TimeGrid.d.ts +3 -0
  109. package/web-dist/components/time-grid/TimeGrid.js +208 -0
  110. package/web-dist/components/time-grid/index.d.ts +1 -0
  111. package/web-dist/components/time-grid/index.js +2 -0
  112. package/web-dist/components/uploader/Uploader.js +2 -2
  113. package/web-dist/manifest.json +52 -20
@@ -0,0 +1,248 @@
1
+ import { join } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ import { expect, test } from 'bun:test';
5
+ import { Window } from 'happy-dom';
6
+ import React, { act } from 'react';
7
+ import { createRoot } from 'react-dom/client';
8
+ import { renderToStaticMarkup } from 'react-dom/server';
9
+
10
+ import type { SpatialGrid as SpatialGridComponent } from './SpatialGrid';
11
+
12
+ const webDistRoot = join(import.meta.dir, '../../../../../web-dist');
13
+
14
+ /** Loads the independently bundled React Native Web component through its public artifact path. */
15
+ function loadSpatialGrid() {
16
+ return import(
17
+ pathToFileURL(join(webDistRoot, 'components/spatial-grid/index.js')).href
18
+ ) as Promise<{
19
+ SpatialGrid: typeof SpatialGridComponent;
20
+ }>;
21
+ }
22
+
23
+ test('renders a bounded, accessible 10k free-placement catalog in deterministic overlap order', async () => {
24
+ const items = [
25
+ { height: 30, id: 'back', width: 30, x: 0, y: 0, zIndex: -1 },
26
+ { height: 30, id: 'front', width: 30, x: 0, y: 0, zIndex: 1 },
27
+ ...Array.from({ length: 9998 }, (_, index) => ({
28
+ height: 20,
29
+ id: `item-${index}`,
30
+ width: 20,
31
+ x: 1000 + (index % 100) * 100,
32
+ y: 1000 + Math.floor(index / 100) * 100,
33
+ })),
34
+ ];
35
+ await withBrowserRoot(async (browserWindow) => {
36
+ const { SpatialGrid } = await loadSpatialGrid();
37
+ browserWindow.document.body.innerHTML = renderToStaticMarkup(
38
+ <SpatialGrid
39
+ contentHeight={11000}
40
+ contentWidth={11000}
41
+ height={100}
42
+ items={items}
43
+ selectedItemId="front"
44
+ testID="board"
45
+ width={100}
46
+ zoom={2}
47
+ renderItem={(item) => <span>{item.id}</span>}
48
+ />,
49
+ );
50
+ const mounted = [...browserWindow.document.querySelectorAll('[data-testid^="board-item-"]')];
51
+
52
+ expect(mounted).toHaveLength(2);
53
+ expect(mounted.map((element) => element.getAttribute('data-testid'))).toEqual([
54
+ 'board-item-back',
55
+ 'board-item-front',
56
+ ]);
57
+ expect(
58
+ browserWindow.document
59
+ .querySelector('[data-testid="board-item-front"]')
60
+ ?.getAttribute('role'),
61
+ ).toBe('button');
62
+ expect(
63
+ browserWindow.document
64
+ .querySelector('[data-testid="board-item-front"]')
65
+ ?.getAttribute('tabindex'),
66
+ ).toBe('0');
67
+ expect(
68
+ browserWindow.document
69
+ .querySelector('[data-testid="board-item-front"]')
70
+ ?.getAttribute('aria-label'),
71
+ ).toBe('Spatial grid item front');
72
+ });
73
+ });
74
+
75
+ test('activates the topmost overlapping item by pointer and keyboard', async () => {
76
+ const { SpatialGrid } = await loadSpatialGrid();
77
+ const selection: string[] = [];
78
+ const activation: string[] = [];
79
+ await withBrowserRoot(async (browserWindow, host, root) => {
80
+ await renderSpatialGrid(
81
+ root,
82
+ <SpatialGrid
83
+ contentHeight={100}
84
+ contentWidth={100}
85
+ height={100}
86
+ items={[
87
+ { height: 50, id: 'back', width: 50, x: 10, y: 20, zIndex: -1 },
88
+ { height: 50, id: 'front', width: 50, x: 10, y: 20, zIndex: 1 },
89
+ ]}
90
+ testID="interactive-board"
91
+ width={100}
92
+ onActivation={(id) => activation.push(id)}
93
+ onSelectionChange={(id) => selection.push(id)}
94
+ renderItem={(item) => <span>{item.id}</span>}
95
+ />,
96
+ );
97
+ const front = requireItem(host, 'interactive-board-item-front');
98
+ await dispatch(front, new browserWindow.MouseEvent('click', { bubbles: true }));
99
+ await dispatch(
100
+ front,
101
+ new browserWindow.KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }),
102
+ );
103
+ await dispatch(
104
+ front,
105
+ new browserWindow.KeyboardEvent('keyup', { bubbles: true, key: 'Enter' }),
106
+ );
107
+ // happy-dom does not synthesize the browser click dispatched for an activated button.
108
+ await dispatch(front, new browserWindow.MouseEvent('click', { bubbles: true }));
109
+ expect(selection).toEqual(['front', 'front']);
110
+ expect(activation).toEqual(['front', 'front']);
111
+ });
112
+ });
113
+
114
+ test('separates disabled, read-only, and passive interaction behavior', async () => {
115
+ const { SpatialGrid } = await loadSpatialGrid();
116
+ const selection: string[] = [];
117
+ const activation: string[] = [];
118
+ const focus: string[] = [];
119
+ await withBrowserRoot(async (browserWindow, host, root) => {
120
+ const render = (
121
+ props: Pick<
122
+ React.ComponentProps<typeof SpatialGrid>,
123
+ 'disabled' | 'readOnly' | 'interactionPolicy'
124
+ >,
125
+ ) => (
126
+ <SpatialGrid
127
+ contentHeight={100}
128
+ contentWidth={100}
129
+ height={100}
130
+ items={[{ height: 50, id: 'node', width: 50, x: 10, y: 20 }]}
131
+ testID="interactive-board"
132
+ width={100}
133
+ onActivation={(id) => activation.push(id)}
134
+ onFocusChange={(id) => focus.push(id)}
135
+ onSelectionChange={(id) => selection.push(id)}
136
+ renderItem={(item) => <span>{item.id}</span>}
137
+ {...props}
138
+ />
139
+ );
140
+ for (const props of [
141
+ { disabled: true },
142
+ { readOnly: true },
143
+ { interactionPolicy: 'passive' as const },
144
+ ]) {
145
+ await renderSpatialGrid(root, render(props));
146
+ const item = requireItem(host, 'interactive-board-item-node');
147
+ await dispatch(item, new browserWindow.FocusEvent('focusin', { bubbles: true }));
148
+ await dispatch(item, new browserWindow.MouseEvent('click', { bubbles: true }));
149
+ }
150
+
151
+ expect(selection).toEqual([]);
152
+ expect(activation).toEqual([]);
153
+ expect(focus).toEqual(['node']);
154
+ });
155
+ });
156
+
157
+ test('reveals a focused offscreen item through the composed GridView viewport', async () => {
158
+ const { SpatialGrid } = await loadSpatialGrid();
159
+ await withBrowserRoot(async (_browserWindow, host, root) => {
160
+ const scrollCalls: unknown[] = [];
161
+ const render = (focusedItemId?: string) => (
162
+ <SpatialGrid
163
+ contentHeight={1000}
164
+ contentWidth={1000}
165
+ focusedItemId={focusedItemId}
166
+ height={100}
167
+ items={[{ height: 20, id: 'distant', width: 20, x: 400, y: 500 }]}
168
+ revealPaddingPixels={10}
169
+ width={100}
170
+ renderItem={(item) => <span>{item.id}</span>}
171
+ />
172
+ );
173
+ await renderSpatialGrid(root, render());
174
+ const scrollableNodes = [...host.querySelectorAll('div')] as HTMLElement[];
175
+ for (const node of scrollableNodes) {
176
+ node.scrollTo = (options: unknown) => scrollCalls.push(options);
177
+ }
178
+ await renderSpatialGrid(root, render('distant'));
179
+
180
+ expect(scrollCalls).toContainEqual({ animated: true, x: 330 });
181
+ expect(scrollCalls).toContainEqual({ animated: true, y: 430 });
182
+ });
183
+ });
184
+
185
+ /** Runs a mounted RNW test with its DOM globals restored after the assertion. */
186
+ async function withBrowserRoot(
187
+ callback: (
188
+ browserWindow: Window,
189
+ host: HTMLDivElement,
190
+ root: ReturnType<typeof createRoot>,
191
+ ) => Promise<void>,
192
+ ) {
193
+ const browserWindow = new Window();
194
+ const keys = [
195
+ 'window',
196
+ 'document',
197
+ 'Node',
198
+ 'navigator',
199
+ 'ShadowRoot',
200
+ 'IS_REACT_ACT_ENVIRONMENT',
201
+ ] as const;
202
+ const previous = keys.map(
203
+ (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
204
+ );
205
+ Object.assign(globalThis, {
206
+ IS_REACT_ACT_ENVIRONMENT: true,
207
+ window: browserWindow,
208
+ document: browserWindow.document,
209
+ Node: browserWindow.Node,
210
+ navigator: browserWindow.navigator,
211
+ ShadowRoot: browserWindow.ShadowRoot,
212
+ });
213
+ const host = document.createElement('div');
214
+ document.body.appendChild(host);
215
+ const root = createRoot(host);
216
+
217
+ try {
218
+ await callback(browserWindow, host, root);
219
+ } finally {
220
+ await act(() => Promise.resolve().then(() => root.unmount()));
221
+ browserWindow.close();
222
+ for (const [key, descriptor] of previous) {
223
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
224
+ else Reflect.deleteProperty(globalThis, key);
225
+ }
226
+ }
227
+ }
228
+
229
+ /** Renders a component through React's mounted interaction boundary. */
230
+ function renderSpatialGrid(root: ReturnType<typeof createRoot>, component: React.ReactNode) {
231
+ return act(() => Promise.resolve().then(() => root.render(component)));
232
+ }
233
+
234
+ /** Retrieves one mounted item by its stable test identifier. */
235
+ function requireItem(host: HTMLDivElement, testID: string): HTMLElement {
236
+ const item = host.querySelector(`[data-testid="${testID}"]`);
237
+ if (!item) throw new Error(`SpatialGrid did not render ${testID}.`);
238
+ return item as HTMLElement;
239
+ }
240
+
241
+ /** Dispatches one mounted native-web event through React's synthetic event boundary. */
242
+ function dispatch(target: EventTarget, event: Event) {
243
+ return act(() =>
244
+ Promise.resolve().then(() => {
245
+ target.dispatchEvent(event);
246
+ }),
247
+ );
248
+ }
@@ -0,0 +1,112 @@
1
+ import React from 'react';
2
+ import { Pressable as NativePressable, View as NativeView } from 'react-native';
3
+
4
+ import type { SpatialGridItem, SpatialGridProps } from '../../../../types/spatial-grid';
5
+ import { GridView } from '../../../grid-view/public';
6
+ import { sortSpatialGridItems } from '../../application/sortSpatialGridItems';
7
+
8
+ /** Renders virtualized free-placement items without owning geometry, pan, zoom, or drag orchestration. */
9
+ export function SpatialGrid({
10
+ items,
11
+ contentWidth,
12
+ contentHeight,
13
+ width,
14
+ height,
15
+ zoom,
16
+ overscanPixels,
17
+ selectedItemId,
18
+ focusedItemId,
19
+ revealPaddingPixels,
20
+ disabled = false,
21
+ readOnly = false,
22
+ interactionPolicy,
23
+ onSelectionChange,
24
+ onActivation,
25
+ onFocusChange,
26
+ onViewportChange,
27
+ onVisibleItemIdsChange,
28
+ renderItem,
29
+ testID,
30
+ }: SpatialGridProps) {
31
+ const orderedItems = React.useMemo(() => sortSpatialGridItems(items), [items]);
32
+ const itemLookup = React.useMemo(
33
+ () => new Map(orderedItems.map((item) => [item.id, item])),
34
+ [orderedItems],
35
+ );
36
+ const interactionBlocked = disabled || interactionPolicy === 'passive';
37
+
38
+ return (
39
+ <NativeView style={{ height, overflow: 'hidden', width }} testID={testID}>
40
+ <GridView
41
+ contentHeight={contentHeight}
42
+ contentWidth={contentWidth}
43
+ focusedItemId={focusedItemId ?? selectedItemId}
44
+ height={height}
45
+ interactionPolicy={interactionPolicy}
46
+ items={orderedItems}
47
+ onViewportChange={onViewportChange}
48
+ onVisibleItemIdsChange={onVisibleItemIdsChange}
49
+ overscanPixels={overscanPixels}
50
+ renderItem={(gridItem) => {
51
+ const item = itemLookup.get(gridItem.id);
52
+ if (!item) return null;
53
+ return renderSpatialGridItem(
54
+ item,
55
+ item.id === selectedItemId,
56
+ item.id === focusedItemId,
57
+ interactionBlocked,
58
+ readOnly,
59
+ onSelectionChange,
60
+ onActivation,
61
+ onFocusChange,
62
+ renderItem,
63
+ testID,
64
+ );
65
+ }}
66
+ revealPaddingPixels={revealPaddingPixels}
67
+ width={width}
68
+ zoom={zoom}
69
+ />
70
+ </NativeView>
71
+ );
72
+ }
73
+
74
+ /** Adds serializable selection, activation, and focus boundaries around a caller-rendered item. */
75
+ function renderSpatialGridItem(
76
+ item: SpatialGridItem,
77
+ selected: boolean,
78
+ focused: boolean,
79
+ interactionBlocked: boolean,
80
+ readOnly: boolean,
81
+ onSelectionChange: SpatialGridProps['onSelectionChange'],
82
+ onActivation: SpatialGridProps['onActivation'],
83
+ onFocusChange: SpatialGridProps['onFocusChange'],
84
+ renderItem: SpatialGridProps['renderItem'],
85
+ testID: string | undefined,
86
+ ) {
87
+ const interactionDisabled = interactionBlocked || item.disabled === true;
88
+ const editingDisabled = interactionDisabled || readOnly;
89
+ return (
90
+ <NativePressable
91
+ accessibilityLabel={item.accessibilityLabel ?? `Spatial grid item ${item.id}`}
92
+ accessibilityRole="button"
93
+ accessibilityState={{ disabled: editingDisabled, selected }}
94
+ disabled={interactionDisabled}
95
+ style={{ height: '100%', width: '100%' }}
96
+ testID={testID === undefined ? undefined : `${testID}-item-${item.id}`}
97
+ onFocus={() => {
98
+ if (!interactionDisabled) onFocusChange?.(item.id);
99
+ }}
100
+ onLongPress={() => {
101
+ if (!editingDisabled) onActivation?.(item.id);
102
+ }}
103
+ onPress={() => {
104
+ if (editingDisabled) return;
105
+ onSelectionChange?.(item.id);
106
+ onActivation?.(item.id);
107
+ }}
108
+ >
109
+ {renderItem(item, { disabled: editingDisabled, focused, selected })}
110
+ </NativePressable>
111
+ );
112
+ }
@@ -0,0 +1,42 @@
1
+ import { getVisibleGridItems } from '@ankhorage/grid-view';
2
+ import { describe, expect, test } from 'bun:test';
3
+
4
+ import { sortSpatialGridItems } from './sortSpatialGridItems';
5
+
6
+ describe('SpatialGrid presentation', () => {
7
+ test('preserves free world rectangles and establishes deterministic overlap hit order', () => {
8
+ const ordered = sortSpatialGridItems([
9
+ { height: 40, id: 'middle', width: 40, x: 24, y: 18 },
10
+ { height: 30, id: 'back', width: 30, x: 16, y: 12, zIndex: -1 },
11
+ { height: 20, id: 'front', width: 20, x: 24, y: 18, zIndex: 2 },
12
+ { height: 10, id: 'front-later', width: 10, x: 24, y: 18, zIndex: 2 },
13
+ ]);
14
+
15
+ expect(ordered.map((item) => item.id)).toEqual(['back', 'middle', 'front', 'front-later']);
16
+ expect(ordered[1]).toMatchObject({ height: 40, width: 40, x: 24, y: 18 });
17
+ });
18
+
19
+ test('keeps a 10k free-placement catalogue bounded to the viewport subset', () => {
20
+ const items = sortSpatialGridItems(
21
+ Array.from({ length: 10000 }, (_, index) => ({
22
+ height: 20,
23
+ id: `item-${index}`,
24
+ width: 20,
25
+ x: (index % 100) * 100,
26
+ y: Math.floor(index / 100) * 100,
27
+ })),
28
+ );
29
+
30
+ const visible = getVisibleGridItems(items, {
31
+ height: 160,
32
+ offsetX: 0,
33
+ offsetY: 0,
34
+ pixelsPerUnitX: 1,
35
+ pixelsPerUnitY: 1,
36
+ width: 160,
37
+ });
38
+
39
+ expect(visible).toHaveLength(4);
40
+ expect(visible.map((item) => item.id)).toEqual(['item-0', 'item-1', 'item-100', 'item-101']);
41
+ });
42
+ });
@@ -0,0 +1,14 @@
1
+ import type { SpatialGridItem } from '../../../types/spatial-grid';
2
+
3
+ /** Orders free-placement items for deterministic paint and overlap hit-testing. */
4
+ export function sortSpatialGridItems(
5
+ items: readonly SpatialGridItem[],
6
+ ): readonly SpatialGridItem[] {
7
+ return [...items]
8
+ .map((item, index) => ({ index, item }))
9
+ .sort(
10
+ (left, right) =>
11
+ (left.item.zIndex ?? 0) - (right.item.zIndex ?? 0) || left.index - right.index,
12
+ )
13
+ .map(({ item }) => item);
14
+ }
@@ -0,0 +1,12 @@
1
+ import type { ZoraComponentMeta } from '../../types/authoring';
2
+
3
+ /** Free-placement rendering requires a caller-owned React renderer and is therefore code-only. */
4
+ export const spatialGridMeta = {
5
+ name: 'SpatialGrid',
6
+ category: 'foundation',
7
+ description: 'Virtualized free-placement board. Item rendering belongs to the code API.',
8
+ directManifestNode: false,
9
+ note: 'SpatialGrid requires a caller-owned React item renderer and cannot be serialized as a manifest node.',
10
+ allowedChildren: [],
11
+ props: {},
12
+ } as const satisfies ZoraComponentMeta;
@@ -0,0 +1,6 @@
1
+ export type {
2
+ SpatialGridItem,
3
+ SpatialGridItemState,
4
+ SpatialGridProps,
5
+ } from '../../types/spatial-grid';
6
+ export { SpatialGrid } from './adapters/inbound/SpatialGrid';
@@ -0,0 +1,131 @@
1
+ import { join } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ import { expect, test } from 'bun:test';
5
+ import { Window } from 'happy-dom';
6
+ import React, { act } from 'react';
7
+ import { createRoot } from 'react-dom/client';
8
+
9
+ import type { TimeGridProps } from '../../../../types/time-grid';
10
+
11
+ const webDistRoot = join(import.meta.dir, '../../../../../web-dist');
12
+
13
+ function load(path: string) {
14
+ return import(pathToFileURL(join(webDistRoot, path)).href);
15
+ }
16
+
17
+ function installBrowserGlobals(browser: Window): () => void {
18
+ const keys = [
19
+ 'window',
20
+ 'document',
21
+ 'Node',
22
+ 'navigator',
23
+ 'ShadowRoot',
24
+ 'IS_REACT_ACT_ENVIRONMENT',
25
+ ] as const;
26
+ const previous = keys.map(
27
+ (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
28
+ );
29
+ Object.assign(globalThis, {
30
+ IS_REACT_ACT_ENVIRONMENT: true,
31
+ Node: browser.Node,
32
+ ShadowRoot: browser.ShadowRoot,
33
+ document: browser.document,
34
+ navigator: browser.navigator,
35
+ window: browser,
36
+ });
37
+ return () => {
38
+ for (const [key, descriptor] of previous) {
39
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
40
+ else Reflect.deleteProperty(globalThis, key);
41
+ }
42
+ };
43
+ }
44
+
45
+ test('keeps mounted interval and virtualized variable-height lane labels synchronized with GridView scrolling', async () => {
46
+ const browser = new Window();
47
+ const restore = installBrowserGlobals(browser);
48
+ const host = document.createElement('div');
49
+ document.body.append(host);
50
+ const root = createRoot(host);
51
+ const { TimeGrid } = (await load('components/time-grid/TimeGrid.js')) as {
52
+ TimeGrid: React.ComponentType<TimeGridProps>;
53
+ };
54
+ const focusedIds: string[] = [];
55
+ const viewportOffsets: number[] = [];
56
+
57
+ try {
58
+ act(() => {
59
+ root.render(
60
+ <TimeGrid
61
+ contentWidth={100}
62
+ focusedIntervalId="drums"
63
+ height={30}
64
+ intervals={[
65
+ { extent: 10, id: 'drums', laneId: 'drums', start: 0 },
66
+ { extent: 10, id: 'strings', laneId: 'strings', start: 0 },
67
+ ]}
68
+ lanes={[
69
+ { height: 20, id: 'drums' },
70
+ { height: 40, id: 'bass' },
71
+ { height: 30, id: 'strings' },
72
+ ]}
73
+ onFocusedIntervalIdChange={(id) => focusedIds.push(id)}
74
+ onViewportChange={(viewport) => viewportOffsets.push(viewport.offsetY)}
75
+ overscanPixels={0}
76
+ renderInterval={(interval, selected) => (
77
+ <span data-selected={selected}>{interval.id}</span>
78
+ )}
79
+ renderLaneLabel={(lane) => <span>{lane.id}</span>}
80
+ selectedIntervalIds={['drums']}
81
+ testID="time-grid"
82
+ width={100}
83
+ zoom={2}
84
+ />,
85
+ );
86
+ });
87
+
88
+ expect(host.querySelector('[data-testid="time-grid-interval-drums"]')?.textContent).toBe(
89
+ 'drums',
90
+ );
91
+ expect(host.querySelector('[data-testid="time-grid-interval-strings"]')).toBeNull();
92
+ expect(
93
+ host.querySelector('[data-testid="time-grid-lane-label-drums"]')?.getAttribute('style'),
94
+ ).toContain('height: 40px');
95
+ expect(host.querySelector('[data-testid="time-grid-lane-label-bass"]')).toBeNull();
96
+
97
+ const interval = host.querySelector<HTMLElement>('[data-testid="time-grid-interval-drums"]');
98
+ if (!interval) throw new Error('Missing mounted interval');
99
+ const arrowDown = new browser.KeyboardEvent('keydown', {
100
+ bubbles: true,
101
+ cancelable: true,
102
+ key: 'ArrowDown',
103
+ });
104
+ void act(() => interval.dispatchEvent(arrowDown));
105
+ expect(arrowDown.defaultPrevented).toBe(true);
106
+ expect(focusedIds).toEqual(['strings']);
107
+
108
+ const verticalScroller = Array.from(host.querySelectorAll<HTMLElement>('div')).find(
109
+ (element) =>
110
+ element.getAttribute('style') === 'width: 200px; height: 30px;' &&
111
+ Array.from(element.querySelectorAll('div')).some(
112
+ (child) => child.getAttribute('style') === 'width: 200px; height: 180px;',
113
+ ),
114
+ );
115
+ if (!verticalScroller) throw new Error('Missing GridView vertical scroller');
116
+ Object.defineProperty(verticalScroller, 'scrollTop', { configurable: true, value: 60 });
117
+ void act(() => verticalScroller.dispatchEvent(new browser.Event('scroll', { bubbles: true })));
118
+
119
+ expect(viewportOffsets).toContain(30);
120
+ expect(host.querySelector('[data-testid="time-grid-lane-label-drums"]')).toBeNull();
121
+ expect(
122
+ host.querySelector('[data-testid="time-grid-lane-label-bass"]')?.getAttribute('style'),
123
+ ).toContain('top: -20px');
124
+ await new Promise((resolve) => setTimeout(resolve, 100));
125
+ } finally {
126
+ act(() => root.unmount());
127
+ host.remove();
128
+ browser.close();
129
+ restore();
130
+ }
131
+ });