@ankhorage/zora 25.3.0 → 25.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/dist/features/registry/ZORA_COMPONENT_REGISTRY.d.ts.map +1 -1
  4. package/dist/features/registry/ZORA_COMPONENT_REGISTRY.js +2 -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 +2 -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/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/types/spatial-grid.d.ts +41 -0
  30. package/dist/types/spatial-grid.d.ts.map +1 -0
  31. package/dist/types/spatial-grid.js +2 -0
  32. package/dist/types/spatial-grid.js.map +1 -0
  33. package/package.json +3 -3
  34. package/src/features/registry/ZORA_COMPONENT_REGISTRY.ts +2 -0
  35. package/src/features/registry/componentMeta.ts +2 -0
  36. package/src/features/spatial-grid/adapters/inbound/SpatialGrid.test.tsx +248 -0
  37. package/src/features/spatial-grid/adapters/inbound/SpatialGrid.tsx +112 -0
  38. package/src/features/spatial-grid/application/sortSpatialGridItems.test.ts +42 -0
  39. package/src/features/spatial-grid/application/sortSpatialGridItems.ts +14 -0
  40. package/src/features/spatial-grid/meta.ts +12 -0
  41. package/src/features/spatial-grid/public.ts +6 -0
  42. package/src/index.ts +6 -0
  43. package/src/types/spatial-grid.ts +44 -0
  44. package/web-dist/chunks/{Accordion-e9ewv367.js → Accordion-60wn9xn9.js} +1 -1
  45. package/web-dist/chunks/{Accordion-q2z00p5r.js → Accordion-nxsda1my.js} +1 -1
  46. package/web-dist/chunks/{Accordion-689w7frk.js → Accordion-s3d6g59s.js} +1 -1
  47. package/web-dist/components/explorer/Explorer.js +2 -2
  48. package/web-dist/components/file-explorer/FileExplorer.js +2 -2
  49. package/web-dist/components/grid-line-overlay/GridLineOverlay.js +1 -1
  50. package/web-dist/components/grid-ruler/GridRuler.js +1 -1
  51. package/web-dist/components/grid-view/GridView.js +1 -1
  52. package/web-dist/components/media-explorer/MediaExplorer.js +2 -2
  53. package/web-dist/components/reader/Reader.js +1 -1
  54. package/web-dist/components/spatial-grid/SpatialGrid.d.ts +3 -0
  55. package/web-dist/components/spatial-grid/SpatialGrid.js +102 -0
  56. package/web-dist/components/spatial-grid/index.d.ts +1 -0
  57. package/web-dist/components/spatial-grid/index.js +2 -0
  58. package/web-dist/components/tile-grid/TileGrid.js +1 -1
  59. package/web-dist/components/uploader/Uploader.js +2 -2
  60. package/web-dist/manifest.json +36 -20
  61. /package/web-dist/chunks/{Accordion-e6ddpz5v.js → Accordion-r2w86rwv.js} +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spatial-grid.d.ts","sourceRoot":"","sources":["../../src/types/spatial-grid.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACvE,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AAE5C,iGAAiG;AACjG,MAAM,WAAW,eAAgB,SAAQ,YAAY;IACnD,sGAAsG;IACtG,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,qFAAqF;IACrF,QAAQ,CAAC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IACrC,gFAAgF;IAChF,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,qDAAqD;AACrD,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;CAC5B;AAED,+FAA+F;AAC/F,MAAM,WAAW,gBAAiB,SAAQ,aAAa;IACrD,QAAQ,CAAC,KAAK,EAAE,SAAS,eAAe,EAAE,CAAC;IAC3C,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,uFAAuF;IACvF,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,QAAQ,CAAC,YAAY,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC9C,QAAQ,CAAC,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAC;IAC7D,QAAQ,CAAC,sBAAsB,CAAC,EAAE,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAAC;IACnE,QAAQ,CAAC,UAAU,EAAE,CAAC,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,oBAAoB,KAAK,KAAK,CAAC,SAAS,CAAC;CAC9F"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=spatial-grid.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spatial-grid.js","sourceRoot":"","sources":["../../src/types/spatial-grid.ts"],"names":[],"mappings":"","sourcesContent":["import type { GridRectItem, GridViewport } from '@ankhorage/grid-view';\nimport type React from 'react';\n\nimport type { ZoraBaseProps } from './base';\n\n/** A free-positioned item retains the grid-view world rectangle and adds presentation policy. */\nexport interface SpatialGridItem extends GridRectItem {\n /** Higher values are painted and hit-tested above lower values; equal values preserve input order. */\n readonly zIndex?: number;\n /** Accessible name for the item; defaults to a stable ID-derived board-item name. */\n readonly accessibilityLabel?: string;\n /** Prevents selection and activation for this item while keeping it visible. */\n readonly disabled?: boolean;\n}\n\n/** State supplied to the code-only item renderer. */\nexport interface SpatialGridItemState {\n readonly selected: boolean;\n readonly focused: boolean;\n readonly disabled: boolean;\n}\n\n/** Props for the virtualized, free-placement presentation over canonical GridView geometry. */\nexport interface SpatialGridProps extends ZoraBaseProps {\n readonly items: readonly SpatialGridItem[];\n readonly contentWidth: number;\n readonly contentHeight: number;\n readonly width: number;\n readonly height: number;\n readonly zoom?: number;\n readonly overscanPixels?: number;\n readonly selectedItemId?: string;\n readonly focusedItemId?: string;\n readonly revealPaddingPixels?: number;\n readonly disabled?: boolean;\n /** Prevents selection and activation while retaining focus and viewport navigation. */\n readonly readOnly?: boolean;\n readonly onSelectionChange?: (id: string) => void;\n readonly onActivation?: (id: string) => void;\n readonly onFocusChange?: (id: string) => void;\n readonly onViewportChange?: (viewport: GridViewport) => void;\n readonly onVisibleItemIdsChange?: (ids: readonly string[]) => void;\n readonly renderItem: (item: SpatialGridItem, state: SpatialGridItemState) => React.ReactNode;\n}\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/zora",
3
3
  "type": "module",
4
- "version": "25.3.0",
4
+ "version": "25.4.0",
5
5
  "description": "Opinionated React Native and React Native Web UI kit built on @ankhorage/surface.",
6
6
  "homepage": "https://github.com/ankhorage/zora#readme",
7
7
  "bugs": {
@@ -44,7 +44,7 @@
44
44
  "@ankhorage/color-theory": "^0.3.1",
45
45
  "@ankhorage/contracts": "^24.2.1",
46
46
  "@ankhorage/game": "^0.1.2",
47
- "@ankhorage/grid-view": "^0.3.0",
47
+ "@ankhorage/grid-view": "^0.5.0",
48
48
  "@ankhorage/reader": "^0.1.0",
49
49
  "@ankhorage/runtime": "^4.0.7",
50
50
  "@ankhorage/surface": "^9.4.2",
@@ -132,7 +132,7 @@
132
132
  "version-packages": "ankhorage-changeset version"
133
133
  },
134
134
  "devDependencies": {
135
- "@ankhorage/devtools": "^2.3.9",
135
+ "@ankhorage/devtools": "^2.3.10",
136
136
  "@ankhorage/paradox": "^0.3.1",
137
137
  "@react-native-vector-icons/fontawesome": "^13.1.4",
138
138
  "@react-native-vector-icons/fontawesome5": "^13.1.4",
@@ -66,6 +66,7 @@ import { BarcodeScannerView, CameraPermissionView, ScanOverlay } from '../scanne
66
66
  import { ScreenSection, SectionHeader } from '../section/public';
67
67
  import { SelectableItem } from '../selection/public';
68
68
  import { Skeleton, SkeletonCard, SkeletonList, SkeletonText } from '../skeleton/public';
69
+ import { SpatialGrid } from '../spatial-grid/public';
69
70
  import { Surface } from '../surface/public';
70
71
  import {
71
72
  CardBack,
@@ -215,6 +216,7 @@ const _ZORA_COMPONENT_REGISTRY = {
215
216
  ScanOverlay,
216
217
  SectionHeader,
217
218
  SelectableItem,
219
+ SpatialGrid,
218
220
  PaletteItem,
219
221
  CardBack,
220
222
  CardHand,
@@ -85,6 +85,7 @@ import {
85
85
  skeletonMeta,
86
86
  skeletonTextMeta,
87
87
  } from '../skeleton/skeletonMeta';
88
+ import { spatialGridMeta } from '../spatial-grid/meta';
88
89
  import { surfaceMeta } from '../surface/surfaceMeta';
89
90
  import {
90
91
  cardBackMeta,
@@ -218,6 +219,7 @@ export const ZORA_COMPONENT_META: ZoraComponentMetaRegistry = finalizeFeatureMet
218
219
  ScanOverlay: scanOverlayMeta,
219
220
  SectionHeader: sectionHeaderMeta,
220
221
  SelectableItem: selectableItemMeta,
222
+ SpatialGrid: spatialGridMeta,
221
223
  SelectionProvider: selectionProviderMeta,
222
224
  PaletteItem: paletteItemMeta,
223
225
  Timeline: timelineMeta,
@@ -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';
package/src/index.ts CHANGED
@@ -416,6 +416,12 @@ export {
416
416
  SkeletonText,
417
417
  type SkeletonTextProps,
418
418
  } from './features/skeleton/public';
419
+ export type {
420
+ SpatialGridItem,
421
+ SpatialGridItemState,
422
+ SpatialGridProps,
423
+ } from './features/spatial-grid/public';
424
+ export { SpatialGrid } from './features/spatial-grid/public';
419
425
  export type { SurfaceProps, SurfaceVariant } from './features/surface/public';
420
426
  export { Surface } from './features/surface/public';
421
427
  export type {
@@ -0,0 +1,44 @@
1
+ import type { GridRectItem, GridViewport } from '@ankhorage/grid-view';
2
+ import type React from 'react';
3
+
4
+ import type { ZoraBaseProps } from './base';
5
+
6
+ /** A free-positioned item retains the grid-view world rectangle and adds presentation policy. */
7
+ export interface SpatialGridItem extends GridRectItem {
8
+ /** Higher values are painted and hit-tested above lower values; equal values preserve input order. */
9
+ readonly zIndex?: number;
10
+ /** Accessible name for the item; defaults to a stable ID-derived board-item name. */
11
+ readonly accessibilityLabel?: string;
12
+ /** Prevents selection and activation for this item while keeping it visible. */
13
+ readonly disabled?: boolean;
14
+ }
15
+
16
+ /** State supplied to the code-only item renderer. */
17
+ export interface SpatialGridItemState {
18
+ readonly selected: boolean;
19
+ readonly focused: boolean;
20
+ readonly disabled: boolean;
21
+ }
22
+
23
+ /** Props for the virtualized, free-placement presentation over canonical GridView geometry. */
24
+ export interface SpatialGridProps extends ZoraBaseProps {
25
+ readonly items: readonly SpatialGridItem[];
26
+ readonly contentWidth: number;
27
+ readonly contentHeight: number;
28
+ readonly width: number;
29
+ readonly height: number;
30
+ readonly zoom?: number;
31
+ readonly overscanPixels?: number;
32
+ readonly selectedItemId?: string;
33
+ readonly focusedItemId?: string;
34
+ readonly revealPaddingPixels?: number;
35
+ readonly disabled?: boolean;
36
+ /** Prevents selection and activation while retaining focus and viewport navigation. */
37
+ readonly readOnly?: boolean;
38
+ readonly onSelectionChange?: (id: string) => void;
39
+ readonly onActivation?: (id: string) => void;
40
+ readonly onFocusChange?: (id: string) => void;
41
+ readonly onViewportChange?: (viewport: GridViewport) => void;
42
+ readonly onVisibleItemIdsChange?: (ids: readonly string[]) => void;
43
+ readonly renderItem: (item: SpatialGridItem, state: SpatialGridItemState) => React.ReactNode;
44
+ }
@@ -6,7 +6,7 @@ import {
6
6
  getAxisCategoryTicks,
7
7
  getAxisTicks,
8
8
  worldToViewport
9
- } from "./Accordion-e6ddpz5v.js";
9
+ } from "./Accordion-r2w86rwv.js";
10
10
  import {
11
11
  useZoraTheme2
12
12
  } from "./Accordion-srjya6dz.js";
@@ -3,7 +3,7 @@ import {
3
3
  getVisibleGridItems,
4
4
  revealWorldRect,
5
5
  worldToViewport
6
- } from "./Accordion-e6ddpz5v.js";
6
+ } from "./Accordion-r2w86rwv.js";
7
7
  import {
8
8
  require_cjs
9
9
  } from "./Accordion-xgpzh4wq.js";
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import {
3
3
  TileGrid2
4
- } from "./Accordion-q2z00p5r.js";
4
+ } from "./Accordion-nxsda1my.js";
5
5
  import {
6
6
  withZoraThemeScope
7
7
  } from "./Accordion-42jfy1a1.js";
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import"../../chunks/Accordion-q2z00p5r.js";
2
+ import"../../chunks/Accordion-nxsda1my.js";
3
3
  import"../../chunks/Accordion-bfzeab9x.js";
4
4
  import"../../chunks/Accordion-6wrr910w.js";
5
5
  import"../../chunks/Accordion-8f0nekwe.js";
@@ -14,7 +14,7 @@ import {
14
14
  Explorer2,
15
15
  FileExplorer2,
16
16
  MediaExplorer2
17
- } from "../../chunks/Accordion-689w7frk.js";
17
+ } from "../../chunks/Accordion-s3d6g59s.js";
18
18
  import"../../chunks/Accordion-bff0p3x4.js";
19
19
  import"../../chunks/Accordion-srjya6dz.js";
20
20
  import"../../chunks/Accordion-184r2r82.js";
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import"../../chunks/Accordion-q2z00p5r.js";
2
+ import"../../chunks/Accordion-nxsda1my.js";
3
3
  import"../../chunks/Accordion-bfzeab9x.js";
4
4
  import"../../chunks/Accordion-6wrr910w.js";
5
5
  import"../../chunks/Accordion-8f0nekwe.js";
@@ -14,7 +14,7 @@ import {
14
14
  Explorer2,
15
15
  FileExplorer2,
16
16
  MediaExplorer2
17
- } from "../../chunks/Accordion-689w7frk.js";
17
+ } from "../../chunks/Accordion-s3d6g59s.js";
18
18
  import"../../chunks/Accordion-bff0p3x4.js";
19
19
  import"../../chunks/Accordion-srjya6dz.js";
20
20
  import"../../chunks/Accordion-184r2r82.js";
@@ -4,7 +4,7 @@ import"../../chunks/Accordion-42jfy1a1.js";
4
4
  import {
5
5
  GridLineOverlay2,
6
6
  GridRuler2
7
- } from "../../chunks/Accordion-e9ewv367.js";
7
+ } from "../../chunks/Accordion-60wn9xn9.js";
8
8
  import"../../chunks/Accordion-srjya6dz.js";
9
9
  import"../../chunks/Accordion-xgpzh4wq.js";
10
10
  export {
@@ -4,7 +4,7 @@ import"../../chunks/Accordion-42jfy1a1.js";
4
4
  import {
5
5
  GridLineOverlay2,
6
6
  GridRuler2
7
- } from "../../chunks/Accordion-e9ewv367.js";
7
+ } from "../../chunks/Accordion-60wn9xn9.js";
8
8
  import"../../chunks/Accordion-srjya6dz.js";
9
9
  import"../../chunks/Accordion-xgpzh4wq.js";
10
10
  export {
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  GridView2,
4
4
  TileGrid2
5
- } from "../../chunks/Accordion-q2z00p5r.js";
5
+ } from "../../chunks/Accordion-nxsda1my.js";
6
6
  import"../../chunks/Accordion-xgpzh4wq.js";
7
7
  export {
8
8
  GridView2 as GridView,