@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.
- package/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/dist/features/registry/ZORA_COMPONENT_REGISTRY.d.ts.map +1 -1
- package/dist/features/registry/ZORA_COMPONENT_REGISTRY.js +2 -0
- package/dist/features/registry/ZORA_COMPONENT_REGISTRY.js.map +1 -1
- package/dist/features/registry/componentMeta.d.ts.map +1 -1
- package/dist/features/registry/componentMeta.js +2 -0
- package/dist/features/registry/componentMeta.js.map +1 -1
- package/dist/features/spatial-grid/adapters/inbound/SpatialGrid.d.ts +5 -0
- package/dist/features/spatial-grid/adapters/inbound/SpatialGrid.d.ts.map +1 -0
- package/dist/features/spatial-grid/adapters/inbound/SpatialGrid.js +38 -0
- package/dist/features/spatial-grid/adapters/inbound/SpatialGrid.js.map +1 -0
- package/dist/features/spatial-grid/application/sortSpatialGridItems.d.ts +4 -0
- package/dist/features/spatial-grid/application/sortSpatialGridItems.d.ts.map +1 -0
- package/dist/features/spatial-grid/application/sortSpatialGridItems.js +8 -0
- package/dist/features/spatial-grid/application/sortSpatialGridItems.js.map +1 -0
- package/dist/features/spatial-grid/meta.d.ts +11 -0
- package/dist/features/spatial-grid/meta.d.ts.map +1 -0
- package/dist/features/spatial-grid/meta.js +11 -0
- package/dist/features/spatial-grid/meta.js.map +1 -0
- package/dist/features/spatial-grid/public.d.ts +3 -0
- package/dist/features/spatial-grid/public.d.ts.map +1 -0
- package/dist/features/spatial-grid/public.js +2 -0
- package/dist/features/spatial-grid/public.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/types/spatial-grid.d.ts +41 -0
- package/dist/types/spatial-grid.d.ts.map +1 -0
- package/dist/types/spatial-grid.js +2 -0
- package/dist/types/spatial-grid.js.map +1 -0
- package/package.json +3 -3
- package/src/features/registry/ZORA_COMPONENT_REGISTRY.ts +2 -0
- package/src/features/registry/componentMeta.ts +2 -0
- package/src/features/spatial-grid/adapters/inbound/SpatialGrid.test.tsx +248 -0
- package/src/features/spatial-grid/adapters/inbound/SpatialGrid.tsx +112 -0
- package/src/features/spatial-grid/application/sortSpatialGridItems.test.ts +42 -0
- package/src/features/spatial-grid/application/sortSpatialGridItems.ts +14 -0
- package/src/features/spatial-grid/meta.ts +12 -0
- package/src/features/spatial-grid/public.ts +6 -0
- package/src/index.ts +6 -0
- package/src/types/spatial-grid.ts +44 -0
- package/web-dist/chunks/{Accordion-e9ewv367.js → Accordion-60wn9xn9.js} +1 -1
- package/web-dist/chunks/{Accordion-q2z00p5r.js → Accordion-nxsda1my.js} +1 -1
- package/web-dist/chunks/{Accordion-689w7frk.js → Accordion-s3d6g59s.js} +1 -1
- package/web-dist/components/explorer/Explorer.js +2 -2
- package/web-dist/components/file-explorer/FileExplorer.js +2 -2
- package/web-dist/components/grid-line-overlay/GridLineOverlay.js +1 -1
- package/web-dist/components/grid-ruler/GridRuler.js +1 -1
- package/web-dist/components/grid-view/GridView.js +1 -1
- package/web-dist/components/media-explorer/MediaExplorer.js +2 -2
- package/web-dist/components/reader/Reader.js +1 -1
- package/web-dist/components/spatial-grid/SpatialGrid.d.ts +3 -0
- package/web-dist/components/spatial-grid/SpatialGrid.js +102 -0
- package/web-dist/components/spatial-grid/index.d.ts +1 -0
- package/web-dist/components/spatial-grid/index.js +2 -0
- package/web-dist/components/tile-grid/TileGrid.js +1 -1
- package/web-dist/components/uploader/Uploader.js +2 -2
- package/web-dist/manifest.json +36 -20
- /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 @@
|
|
|
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.
|
|
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.
|
|
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.
|
|
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;
|
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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import"../../chunks/Accordion-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
7
|
+
} from "../../chunks/Accordion-60wn9xn9.js";
|
|
8
8
|
import"../../chunks/Accordion-srjya6dz.js";
|
|
9
9
|
import"../../chunks/Accordion-xgpzh4wq.js";
|
|
10
10
|
export {
|