@ankhorage/zora 25.4.0 → 25.6.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 +12 -0
- package/README.md +3 -1
- package/dist/features/grid-view/adapters/inbound/GridView.d.ts +2 -7
- package/dist/features/grid-view/adapters/inbound/GridView.d.ts.map +1 -1
- package/dist/features/grid-view/adapters/inbound/GridView.js +309 -51
- package/dist/features/grid-view/adapters/inbound/GridView.js.map +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.d.ts.map +1 -1
- package/dist/features/grid-view/adapters/inbound/TileGrid.js +11 -4
- package/dist/features/grid-view/adapters/inbound/TileGrid.js.map +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/time-grid/adapters/inbound/TimeGrid.d.ts +5 -0
- package/dist/features/time-grid/adapters/inbound/TimeGrid.d.ts.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGrid.js +107 -0
- package/dist/features/time-grid/adapters/inbound/TimeGrid.js.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.d.ts +9 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.d.ts.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.js +5 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.js.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.d.ts +9 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.d.ts.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.js +5 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.js.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.d.ts +9 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.d.ts.map +1 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.js +10 -0
- package/dist/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.js.map +1 -0
- package/dist/features/time-grid/application/resolveTimeGridNextFocusId.d.ts +4 -0
- package/dist/features/time-grid/application/resolveTimeGridNextFocusId.d.ts.map +1 -0
- package/dist/features/time-grid/application/resolveTimeGridNextFocusId.js +31 -0
- package/dist/features/time-grid/application/resolveTimeGridNextFocusId.js.map +1 -0
- package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.d.ts +5 -0
- package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.d.ts.map +1 -0
- package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.js +6 -0
- package/dist/features/time-grid/application/resolveTimeGridVisibleIntervals.js.map +1 -0
- package/dist/features/time-grid/meta.d.ts +11 -0
- package/dist/features/time-grid/meta.d.ts.map +1 -0
- package/dist/features/time-grid/meta.js +11 -0
- package/dist/features/time-grid/meta.js.map +1 -0
- package/dist/features/time-grid/public.d.ts +5 -0
- package/dist/features/time-grid/public.d.ts.map +1 -0
- package/dist/features/time-grid/public.js +4 -0
- package/dist/features/time-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/grid-view.d.ts +18 -1
- package/dist/types/grid-view.d.ts.map +1 -1
- package/dist/types/grid-view.js.map +1 -1
- package/dist/types/time-grid.d.ts +30 -0
- package/dist/types/time-grid.d.ts.map +1 -0
- package/dist/types/time-grid.js +2 -0
- package/dist/types/time-grid.js.map +1 -0
- package/package.json +1 -1
- package/src/features/grid-view/adapters/inbound/GridView.interaction.test.tsx +585 -0
- package/src/features/grid-view/adapters/inbound/GridView.test.tsx +74 -0
- package/src/features/grid-view/adapters/inbound/GridView.tsx +506 -77
- package/src/features/grid-view/adapters/inbound/TileGrid.tsx +26 -3
- package/src/features/registry/ZORA_COMPONENT_REGISTRY.ts +2 -0
- package/src/features/registry/componentMeta.ts +2 -0
- package/src/features/time-grid/adapters/inbound/TimeGrid.test.tsx +158 -0
- package/src/features/time-grid/adapters/inbound/TimeGrid.tsx +197 -0
- package/src/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.native.tsx +11 -0
- package/src/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.tsx +11 -0
- package/src/features/time-grid/adapters/inbound/TimeGridKeyboardProxy.web.tsx +20 -0
- package/src/features/time-grid/application/resolveTimeGridNextFocusId.test.ts +46 -0
- package/src/features/time-grid/application/resolveTimeGridNextFocusId.ts +41 -0
- package/src/features/time-grid/application/resolveTimeGridVisibleIntervals.test.ts +33 -0
- package/src/features/time-grid/application/resolveTimeGridVisibleIntervals.ts +13 -0
- package/src/features/time-grid/meta.ts +12 -0
- package/src/features/time-grid/public.ts +4 -0
- package/src/index.ts +2 -0
- package/src/types/grid-view.ts +27 -1
- package/src/types/time-grid.ts +33 -0
- package/web-dist/chunks/{Accordion-r2w86rwv.js → Accordion-ebghcyzj.js} +125 -1
- package/web-dist/chunks/{Accordion-s3d6g59s.js → Accordion-f43jba23.js} +1 -1
- package/web-dist/chunks/Accordion-q8exgc3h.js +515 -0
- package/web-dist/chunks/{Accordion-60wn9xn9.js → Accordion-szar8dt5.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.js +1 -1
- package/web-dist/components/tile-grid/TileGrid.js +1 -1
- package/web-dist/components/time-grid/TimeGrid.d.ts +3 -0
- package/web-dist/components/time-grid/TimeGrid.js +208 -0
- package/web-dist/components/time-grid/index.d.ts +1 -0
- package/web-dist/components/time-grid/index.js +2 -0
- package/web-dist/components/uploader/Uploader.js +2 -2
- package/web-dist/manifest.json +38 -22
- package/web-dist/chunks/Accordion-nxsda1my.js +0 -194
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import type { GridRectItem, GridViewport } from '@ankhorage/grid-view';
|
|
5
|
+
import { expect, test } from 'bun:test';
|
|
6
|
+
import { Window } from 'happy-dom';
|
|
7
|
+
import React, { act } from 'react';
|
|
8
|
+
import { createRoot } from 'react-dom/client';
|
|
9
|
+
|
|
10
|
+
import type { GridView as GridViewComponent } from './GridView';
|
|
11
|
+
import type { TileGrid as TileGridComponent } from './TileGrid';
|
|
12
|
+
|
|
13
|
+
const webDistRoot = join(import.meta.dir, '../../../../../web-dist');
|
|
14
|
+
const load = () => import(pathToFileURL(join(webDistRoot, 'components/grid-view/index.js')).href);
|
|
15
|
+
const items: readonly GridRectItem[] = [
|
|
16
|
+
{ height: 20, id: 'near', width: 20, x: 10, y: 10 },
|
|
17
|
+
{ height: 20, id: 'far', width: 20, x: 300, y: 300 },
|
|
18
|
+
{ height: 20, id: 'far-other', width: 20, x: 600, y: 600 },
|
|
19
|
+
];
|
|
20
|
+
const constraints = { world: { height: 1000, width: 1000, x: 0, y: 0 } };
|
|
21
|
+
|
|
22
|
+
test('supports accessible pan and zoom controls and native scrolling without feedback proposals', async () => {
|
|
23
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
24
|
+
await withBrowserAsync(async (browserWindow, host) => {
|
|
25
|
+
const changes: GridViewport[] = [];
|
|
26
|
+
const root = createRoot(host);
|
|
27
|
+
try {
|
|
28
|
+
await interactAsync(() =>
|
|
29
|
+
root.render(
|
|
30
|
+
<GridView
|
|
31
|
+
contentHeight={1000}
|
|
32
|
+
contentWidth={1000}
|
|
33
|
+
height={100}
|
|
34
|
+
items={items}
|
|
35
|
+
onViewportChange={(viewport) => changes.push(viewport)}
|
|
36
|
+
renderItem={(item) => <span data-grid-item={item.id}>{item.id}</span>}
|
|
37
|
+
testID="grid"
|
|
38
|
+
viewportConstraints={constraints}
|
|
39
|
+
width={100}
|
|
40
|
+
/>,
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
const horizontal = host.querySelector<HTMLElement>('[data-testid="grid-horizontal-scroll"]');
|
|
44
|
+
const panRight = host.querySelector<HTMLButtonElement>('[aria-label="Pan right"]');
|
|
45
|
+
const zoomIn = host.querySelector<HTMLButtonElement>('[aria-label="Zoom in"]');
|
|
46
|
+
if (!horizontal || !panRight || !zoomIn) throw new Error('Missing interactive grid controls');
|
|
47
|
+
|
|
48
|
+
expect(host.querySelectorAll('[role="button"]')).toHaveLength(6);
|
|
49
|
+
horizontal.scrollLeft = 25;
|
|
50
|
+
await interactAsync(() =>
|
|
51
|
+
horizontal.dispatchEvent(new browserWindow.Event('scroll', { bubbles: true })),
|
|
52
|
+
);
|
|
53
|
+
expect(changes).toHaveLength(1);
|
|
54
|
+
expect(changes.at(-1)?.offsetX).toBe(25);
|
|
55
|
+
|
|
56
|
+
await interactAsync(() => panRight.click());
|
|
57
|
+
await interactAsync(() => zoomIn.click());
|
|
58
|
+
expect(changes).toHaveLength(3);
|
|
59
|
+
} finally {
|
|
60
|
+
await interactAsync(() => root.unmount());
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('deduplicates a controlled focus-reveal veto while allowing accepted movement and focus changes', async () => {
|
|
66
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
67
|
+
await withBrowserAsync(async (_browserWindow, host) => {
|
|
68
|
+
const controlledChanges: GridViewport[] = [];
|
|
69
|
+
const root = createRoot(host);
|
|
70
|
+
const controlledViewport = createViewport();
|
|
71
|
+
try {
|
|
72
|
+
await interactAsync(() =>
|
|
73
|
+
root.render(
|
|
74
|
+
<GridView
|
|
75
|
+
contentHeight={1000}
|
|
76
|
+
contentWidth={1000}
|
|
77
|
+
focusedItemId="far"
|
|
78
|
+
height={100}
|
|
79
|
+
items={[...items]}
|
|
80
|
+
onViewportChange={(viewport) => controlledChanges.push(viewport)}
|
|
81
|
+
renderItem={(item) => <span>{item.id}</span>}
|
|
82
|
+
viewport={controlledViewport}
|
|
83
|
+
viewportConstraints={constraints}
|
|
84
|
+
width={100}
|
|
85
|
+
/>,
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
await interactAsync(() =>
|
|
89
|
+
root.render(
|
|
90
|
+
<GridView
|
|
91
|
+
contentHeight={1000}
|
|
92
|
+
contentWidth={1000}
|
|
93
|
+
focusedItemId="far"
|
|
94
|
+
height={100}
|
|
95
|
+
items={[...items]}
|
|
96
|
+
onViewportChange={(viewport) => controlledChanges.push(viewport)}
|
|
97
|
+
renderItem={(item) => <span>{item.id}</span>}
|
|
98
|
+
viewport={{ ...controlledViewport }}
|
|
99
|
+
viewportConstraints={constraints}
|
|
100
|
+
width={100}
|
|
101
|
+
/>,
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
expect(controlledChanges).toHaveLength(1);
|
|
105
|
+
|
|
106
|
+
await interactAsync(() =>
|
|
107
|
+
root.render(
|
|
108
|
+
<GridView
|
|
109
|
+
contentHeight={1000}
|
|
110
|
+
contentWidth={1000}
|
|
111
|
+
focusedItemId="far"
|
|
112
|
+
height={100}
|
|
113
|
+
items={[...items]}
|
|
114
|
+
onViewportChange={(viewport) => controlledChanges.push(viewport)}
|
|
115
|
+
renderItem={(item) => <span>{item.id}</span>}
|
|
116
|
+
viewport={controlledChanges[0]}
|
|
117
|
+
viewportConstraints={constraints}
|
|
118
|
+
width={100}
|
|
119
|
+
/>,
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
expect(controlledChanges).toHaveLength(1);
|
|
123
|
+
|
|
124
|
+
await interactAsync(() =>
|
|
125
|
+
root.render(
|
|
126
|
+
<GridView
|
|
127
|
+
contentHeight={1000}
|
|
128
|
+
contentWidth={1000}
|
|
129
|
+
focusedItemId="far"
|
|
130
|
+
height={100}
|
|
131
|
+
items={[...items]}
|
|
132
|
+
onViewportChange={(viewport) => controlledChanges.push(viewport)}
|
|
133
|
+
renderItem={(item) => <span>{item.id}</span>}
|
|
134
|
+
viewport={controlledViewport}
|
|
135
|
+
viewportConstraints={constraints}
|
|
136
|
+
width={100}
|
|
137
|
+
/>,
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
expect(controlledChanges).toHaveLength(1);
|
|
141
|
+
|
|
142
|
+
await interactAsync(() =>
|
|
143
|
+
root.render(
|
|
144
|
+
<GridView
|
|
145
|
+
contentHeight={1000}
|
|
146
|
+
contentWidth={1000}
|
|
147
|
+
focusedItemId="far-other"
|
|
148
|
+
height={100}
|
|
149
|
+
items={[...items]}
|
|
150
|
+
onViewportChange={(viewport) => controlledChanges.push(viewport)}
|
|
151
|
+
renderItem={(item) => <span>{item.id}</span>}
|
|
152
|
+
viewport={controlledChanges.at(-1)}
|
|
153
|
+
viewportConstraints={constraints}
|
|
154
|
+
width={100}
|
|
155
|
+
/>,
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
expect(controlledChanges).toHaveLength(2);
|
|
159
|
+
|
|
160
|
+
const uncontrolledChanges: GridViewport[] = [];
|
|
161
|
+
await interactAsync(() =>
|
|
162
|
+
root.render(
|
|
163
|
+
<GridView
|
|
164
|
+
contentHeight={1000}
|
|
165
|
+
contentWidth={1000}
|
|
166
|
+
focusedItemId="far"
|
|
167
|
+
height={100}
|
|
168
|
+
items={items}
|
|
169
|
+
onViewportChange={(viewport) => uncontrolledChanges.push(viewport)}
|
|
170
|
+
renderItem={(item) => <span>{item.id}</span>}
|
|
171
|
+
viewportConstraints={constraints}
|
|
172
|
+
width={100}
|
|
173
|
+
/>,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
expect(uncontrolledChanges).toHaveLength(1);
|
|
177
|
+
} finally {
|
|
178
|
+
await interactAsync(() => root.unmount());
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('maps centered bounds and overscroll to native positions and preserves a local pinch focal world point', async () => {
|
|
184
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
185
|
+
await withBrowserAsync(async (browserWindow, host) => {
|
|
186
|
+
const changes: GridViewport[] = [];
|
|
187
|
+
const root = createRoot(host);
|
|
188
|
+
try {
|
|
189
|
+
await interactAsync(() =>
|
|
190
|
+
root.render(
|
|
191
|
+
<GridView
|
|
192
|
+
contentHeight={50}
|
|
193
|
+
contentWidth={50}
|
|
194
|
+
height={600}
|
|
195
|
+
items={[{ height: 50, id: 'small', width: 50, x: 0, y: 0 }]}
|
|
196
|
+
renderItem={(item) => <span data-grid-item={item.id}>{item.id}</span>}
|
|
197
|
+
testID="grid"
|
|
198
|
+
viewportConstraints={{ world: { height: 50, width: 50, x: 0, y: 0 } }}
|
|
199
|
+
width={800}
|
|
200
|
+
/>,
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
expect(host.querySelector('[data-grid-item="small"]')?.parentElement?.style.left).toBe(
|
|
204
|
+
'375px',
|
|
205
|
+
);
|
|
206
|
+
expect(host.querySelector('[data-grid-item="small"]')?.parentElement?.style.top).toBe(
|
|
207
|
+
'275px',
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
await interactAsync(() =>
|
|
211
|
+
root.render(
|
|
212
|
+
<GridView
|
|
213
|
+
contentHeight={1000}
|
|
214
|
+
contentWidth={1000}
|
|
215
|
+
height={100}
|
|
216
|
+
items={items}
|
|
217
|
+
onViewportChange={(viewport) => changes.push(viewport)}
|
|
218
|
+
renderItem={(item) => <span data-grid-item={item.id}>{item.id}</span>}
|
|
219
|
+
testID="grid"
|
|
220
|
+
viewport={createViewport({ offsetX: -20, offsetY: -20 })}
|
|
221
|
+
viewportConstraints={{
|
|
222
|
+
overscrollX: 20,
|
|
223
|
+
overscrollY: 20,
|
|
224
|
+
world: { height: 1000, width: 1000, x: 0, y: 0 },
|
|
225
|
+
}}
|
|
226
|
+
width={100}
|
|
227
|
+
/>,
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
const horizontal = host.querySelector<HTMLElement>('[data-testid="grid-horizontal-scroll"]');
|
|
231
|
+
if (!horizontal) throw new Error('Missing horizontal scroll view');
|
|
232
|
+
expect(horizontal.firstElementChild?.firstElementChild?.getAttribute('style')).toContain(
|
|
233
|
+
'width: 1040px',
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const grid = host.querySelector<HTMLElement>('[data-testid="grid"]');
|
|
237
|
+
if (!grid) throw new Error('Missing grid viewport');
|
|
238
|
+
Object.defineProperty(grid, 'getBoundingClientRect', {
|
|
239
|
+
value: () => ({ left: 200, top: 200 }),
|
|
240
|
+
});
|
|
241
|
+
Object.defineProperty(grid, 'measureInWindow', {
|
|
242
|
+
value: (callback: (x: number, y: number) => void) => callback(200, 200),
|
|
243
|
+
});
|
|
244
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchstart', [
|
|
245
|
+
{ locationX: 8, locationY: 12, pageX: 220, pageY: 250 },
|
|
246
|
+
{ locationX: 72, locationY: 88, pageX: 280, pageY: 250 },
|
|
247
|
+
]);
|
|
248
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchmove', [
|
|
249
|
+
{ locationX: 1, locationY: 99, pageX: 190, pageY: 250 },
|
|
250
|
+
{ locationX: 99, locationY: 1, pageX: 310, pageY: 250 },
|
|
251
|
+
]);
|
|
252
|
+
expect(changes.at(-1)?.pixelsPerUnitX).toBe(2);
|
|
253
|
+
expect(changes.at(-1)?.offsetX).toBe(5);
|
|
254
|
+
} finally {
|
|
255
|
+
await interactAsync(() => root.unmount());
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('uses default viewport scale when laying out TileGrid columns', async () => {
|
|
261
|
+
const { TileGrid } = (await load()) as { TileGrid: typeof TileGridComponent };
|
|
262
|
+
await withBrowserAsync(async (_browserWindow, host) => {
|
|
263
|
+
const root = createRoot(host);
|
|
264
|
+
try {
|
|
265
|
+
await interactAsync(() =>
|
|
266
|
+
root.render(
|
|
267
|
+
<TileGrid
|
|
268
|
+
defaultViewport={{ pixelsPerUnitX: 2, pixelsPerUnitY: 2 }}
|
|
269
|
+
height={300}
|
|
270
|
+
items={[{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]}
|
|
271
|
+
renderItem={(item) => <span data-tile={item.id}>{item.id}</span>}
|
|
272
|
+
tileSize={100}
|
|
273
|
+
width={500}
|
|
274
|
+
/>,
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
expect(host.querySelector('[data-tile="c"]')?.parentElement?.style.top).toBe('224px');
|
|
278
|
+
} finally {
|
|
279
|
+
await interactAsync(() => root.unmount());
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('keeps TileGrid placement synchronized with uncontrolled and controlled GridView zoom', async () => {
|
|
285
|
+
const { TileGrid } = (await load()) as { TileGrid: typeof TileGridComponent };
|
|
286
|
+
const tileItems = ['a', 'b', 'c', 'd', 'e'].map((id) => ({ id }));
|
|
287
|
+
await withBrowserAsync(async (_browserWindow, host) => {
|
|
288
|
+
const uncontrolledColumns: number[] = [];
|
|
289
|
+
const root = createRoot(host);
|
|
290
|
+
try {
|
|
291
|
+
await interactAsync(() =>
|
|
292
|
+
root.render(
|
|
293
|
+
<TileGrid
|
|
294
|
+
height={300}
|
|
295
|
+
items={tileItems}
|
|
296
|
+
onColumnsChange={(columns) => uncontrolledColumns.push(columns)}
|
|
297
|
+
renderItem={(item) => <span data-tile={item.id}>{item.id}</span>}
|
|
298
|
+
tileSize={100}
|
|
299
|
+
width={500}
|
|
300
|
+
/>,
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
const zoomIn = host.querySelector<HTMLButtonElement>('[aria-label="Zoom in"]');
|
|
304
|
+
if (!zoomIn) throw new Error('Missing uncontrolled TileGrid zoom control');
|
|
305
|
+
await interactAsync(() => zoomIn.click());
|
|
306
|
+
expect(uncontrolledColumns).toEqual([4, 3]);
|
|
307
|
+
expect(host.querySelector('[data-tile="d"]')?.parentElement?.style.top).not.toBe(
|
|
308
|
+
host.querySelector('[data-tile="a"]')?.parentElement?.style.top,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const controlledChanges: GridViewport[] = [];
|
|
312
|
+
const controlledViewport = createViewport({ height: 300, width: 500 });
|
|
313
|
+
await interactAsync(() =>
|
|
314
|
+
root.render(
|
|
315
|
+
<TileGrid
|
|
316
|
+
height={300}
|
|
317
|
+
items={tileItems}
|
|
318
|
+
onViewportChange={(nextViewport) => controlledChanges.push(nextViewport)}
|
|
319
|
+
renderItem={(item) => <span data-tile={item.id}>{item.id}</span>}
|
|
320
|
+
tileSize={100}
|
|
321
|
+
viewport={controlledViewport}
|
|
322
|
+
width={500}
|
|
323
|
+
/>,
|
|
324
|
+
),
|
|
325
|
+
);
|
|
326
|
+
const controlledZoomIn = host.querySelector<HTMLButtonElement>('[aria-label="Zoom in"]');
|
|
327
|
+
if (!controlledZoomIn) throw new Error('Missing controlled TileGrid zoom control');
|
|
328
|
+
await interactAsync(() => controlledZoomIn.click());
|
|
329
|
+
expect(controlledChanges).toHaveLength(1);
|
|
330
|
+
await interactAsync(() =>
|
|
331
|
+
root.render(
|
|
332
|
+
<TileGrid
|
|
333
|
+
height={300}
|
|
334
|
+
items={tileItems}
|
|
335
|
+
onViewportChange={(nextViewport) => controlledChanges.push(nextViewport)}
|
|
336
|
+
renderItem={(item) => <span data-tile={item.id}>{item.id}</span>}
|
|
337
|
+
tileSize={100}
|
|
338
|
+
viewport={controlledChanges[0]}
|
|
339
|
+
width={500}
|
|
340
|
+
/>,
|
|
341
|
+
),
|
|
342
|
+
);
|
|
343
|
+
expect(host.querySelector('[data-tile="d"]')?.parentElement?.style.top).not.toBe(
|
|
344
|
+
host.querySelector('[data-tile="a"]')?.parentElement?.style.top,
|
|
345
|
+
);
|
|
346
|
+
} finally {
|
|
347
|
+
await interactAsync(() => root.unmount());
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('waits for the newest asynchronous viewport measurement before continuous pinch zoom', async () => {
|
|
353
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
354
|
+
await withBrowserAsync(async (browserWindow, host) => {
|
|
355
|
+
const changes: GridViewport[] = [];
|
|
356
|
+
const measurements: ((x: number, y: number) => void)[] = [];
|
|
357
|
+
const root = createRoot(host);
|
|
358
|
+
try {
|
|
359
|
+
await interactAsync(() =>
|
|
360
|
+
root.render(
|
|
361
|
+
<GridView
|
|
362
|
+
contentHeight={1000}
|
|
363
|
+
contentWidth={1000}
|
|
364
|
+
height={100}
|
|
365
|
+
items={items}
|
|
366
|
+
onViewportChange={(viewport) => changes.push(viewport)}
|
|
367
|
+
renderItem={(item) => <span>{item.id}</span>}
|
|
368
|
+
testID="grid"
|
|
369
|
+
viewportConstraints={constraints}
|
|
370
|
+
width={100}
|
|
371
|
+
/>,
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
const grid = host.querySelector<HTMLElement>('[data-testid="grid"]');
|
|
375
|
+
if (!grid) throw new Error('Missing grid viewport');
|
|
376
|
+
Object.defineProperty(grid, 'measureInWindow', {
|
|
377
|
+
value: (callback: (x: number, y: number) => void) => measurements.push(callback),
|
|
378
|
+
});
|
|
379
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchstart', [
|
|
380
|
+
{ pageX: 210, pageY: 170 },
|
|
381
|
+
{ pageX: 290, pageY: 170 },
|
|
382
|
+
]);
|
|
383
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchmove', [
|
|
384
|
+
{ pageX: 200, pageY: 170 },
|
|
385
|
+
{ pageX: 300, pageY: 170 },
|
|
386
|
+
]);
|
|
387
|
+
await interactAsync(() => measurements[0]?.(20, 20));
|
|
388
|
+
await interactAsync(() => measurements[1]?.(200, 150));
|
|
389
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchmove', [
|
|
390
|
+
{ pageX: 190, pageY: 170 },
|
|
391
|
+
{ pageX: 310, pageY: 170 },
|
|
392
|
+
]);
|
|
393
|
+
await interactAsync(() => measurements[2]?.(200, 150));
|
|
394
|
+
expect(changes).toHaveLength(1);
|
|
395
|
+
expect(changes[0]?.pixelsPerUnitX).toBe(1.2);
|
|
396
|
+
expect(changes[0]?.offsetX).toBeCloseTo(8.333333333333343);
|
|
397
|
+
expect(changes[0]?.offsetY).toBeCloseTo(3.333333333333332);
|
|
398
|
+
} finally {
|
|
399
|
+
await interactAsync(() => root.unmount());
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('invalidates delayed pinch measurements after touch lifecycle completion', async () => {
|
|
405
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
406
|
+
await withBrowserAsync(async (browserWindow, host) => {
|
|
407
|
+
const changes: GridViewport[] = [];
|
|
408
|
+
const measurements: ((x: number, y: number) => void)[] = [];
|
|
409
|
+
const root = createRoot(host);
|
|
410
|
+
try {
|
|
411
|
+
await interactAsync(() =>
|
|
412
|
+
root.render(
|
|
413
|
+
<GridView
|
|
414
|
+
contentHeight={1000}
|
|
415
|
+
contentWidth={1000}
|
|
416
|
+
height={100}
|
|
417
|
+
items={items}
|
|
418
|
+
onViewportChange={(viewport) => changes.push(viewport)}
|
|
419
|
+
renderItem={(item) => <span>{item.id}</span>}
|
|
420
|
+
testID="grid"
|
|
421
|
+
viewportConstraints={constraints}
|
|
422
|
+
width={100}
|
|
423
|
+
/>,
|
|
424
|
+
),
|
|
425
|
+
);
|
|
426
|
+
const grid = host.querySelector<HTMLElement>('[data-testid="grid"]');
|
|
427
|
+
const horizontal = host.querySelector<HTMLElement>('[data-testid="grid-horizontal-scroll"]');
|
|
428
|
+
const vertical = host.querySelector<HTMLElement>('[data-testid="grid-vertical-scroll"]');
|
|
429
|
+
if (!grid || !horizontal || !vertical) throw new Error('Missing grid scroll surfaces');
|
|
430
|
+
Object.defineProperty(grid, 'measureInWindow', {
|
|
431
|
+
value: (callback: (x: number, y: number) => void) => measurements.push(callback),
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchstart', [
|
|
435
|
+
{ pageX: 10, pageY: 50 },
|
|
436
|
+
{ pageX: 90, pageY: 50 },
|
|
437
|
+
]);
|
|
438
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchend', []);
|
|
439
|
+
await interactAsync(() => measurements[0]?.(200, 150));
|
|
440
|
+
expect(changes).toHaveLength(0);
|
|
441
|
+
expect(horizontal.style.overflowX).not.toBe('hidden');
|
|
442
|
+
expect(vertical.style.overflowY).not.toBe('hidden');
|
|
443
|
+
|
|
444
|
+
horizontal.scrollLeft = 20;
|
|
445
|
+
await interactAsync(() =>
|
|
446
|
+
horizontal.dispatchEvent(new browserWindow.Event('scroll', { bubbles: true })),
|
|
447
|
+
);
|
|
448
|
+
expect(changes).toHaveLength(1);
|
|
449
|
+
|
|
450
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchstart', [
|
|
451
|
+
{ pageX: 10, pageY: 50 },
|
|
452
|
+
{ pageX: 90, pageY: 50 },
|
|
453
|
+
]);
|
|
454
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchcancel', []);
|
|
455
|
+
await interactAsync(() => measurements[1]?.(200, 150));
|
|
456
|
+
expect(changes).toHaveLength(1);
|
|
457
|
+
expect(horizontal.style.overflowX).not.toBe('hidden');
|
|
458
|
+
expect(vertical.style.overflowY).not.toBe('hidden');
|
|
459
|
+
|
|
460
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchstart', [
|
|
461
|
+
{ pageX: 10, pageY: 50 },
|
|
462
|
+
{ pageX: 90, pageY: 50 },
|
|
463
|
+
]);
|
|
464
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchend', []);
|
|
465
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchstart', [
|
|
466
|
+
{ pageX: 10, pageY: 50 },
|
|
467
|
+
{ pageX: 90, pageY: 50 },
|
|
468
|
+
]);
|
|
469
|
+
await interactAsync(() => measurements[2]?.(200, 150));
|
|
470
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchend', []);
|
|
471
|
+
await interactAsync(() => measurements[3]?.(200, 150));
|
|
472
|
+
expect(changes).toHaveLength(1);
|
|
473
|
+
expect(horizontal.style.overflowX).not.toBe('hidden');
|
|
474
|
+
expect(vertical.style.overflowY).not.toBe('hidden');
|
|
475
|
+
} finally {
|
|
476
|
+
await interactAsync(() => root.unmount());
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('invalidates a pending pinch update when GridView unmounts', async () => {
|
|
482
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
483
|
+
await withBrowserAsync(async (browserWindow, host) => {
|
|
484
|
+
const changes: GridViewport[] = [];
|
|
485
|
+
const measurements: ((x: number, y: number) => void)[] = [];
|
|
486
|
+
const root = createRoot(host);
|
|
487
|
+
await interactAsync(() =>
|
|
488
|
+
root.render(
|
|
489
|
+
<GridView
|
|
490
|
+
contentHeight={1000}
|
|
491
|
+
contentWidth={1000}
|
|
492
|
+
height={100}
|
|
493
|
+
items={items}
|
|
494
|
+
onViewportChange={(viewport) => changes.push(viewport)}
|
|
495
|
+
renderItem={(item) => <span>{item.id}</span>}
|
|
496
|
+
testID="grid"
|
|
497
|
+
viewportConstraints={constraints}
|
|
498
|
+
width={100}
|
|
499
|
+
/>,
|
|
500
|
+
),
|
|
501
|
+
);
|
|
502
|
+
const grid = host.querySelector<HTMLElement>('[data-testid="grid"]');
|
|
503
|
+
if (!grid) throw new Error('Missing grid viewport');
|
|
504
|
+
Object.defineProperty(grid, 'measureInWindow', {
|
|
505
|
+
value: (callback: (x: number, y: number) => void) => measurements.push(callback),
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchstart', [
|
|
509
|
+
{ pageX: 10, pageY: 50 },
|
|
510
|
+
{ pageX: 90, pageY: 50 },
|
|
511
|
+
]);
|
|
512
|
+
await interactAsync(() => measurements[0]?.(0, 0));
|
|
513
|
+
await dispatchTouchesAsync(browserWindow, grid, 'touchmove', [
|
|
514
|
+
{ pageX: 0, pageY: 50 },
|
|
515
|
+
{ pageX: 100, pageY: 50 },
|
|
516
|
+
]);
|
|
517
|
+
await interactAsync(() => root.unmount());
|
|
518
|
+
await interactAsync(() => measurements[1]?.(0, 0));
|
|
519
|
+
expect(changes).toHaveLength(0);
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
/*** Provides a stable controlled viewport for component interaction tests. */
|
|
524
|
+
function createViewport(overrides: Partial<GridViewport> = {}): GridViewport {
|
|
525
|
+
return {
|
|
526
|
+
height: 100,
|
|
527
|
+
offsetX: 0,
|
|
528
|
+
offsetY: 0,
|
|
529
|
+
pixelsPerUnitX: 1,
|
|
530
|
+
pixelsPerUnitY: 1,
|
|
531
|
+
width: 100,
|
|
532
|
+
...overrides,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/*** Executes a React interaction after the renderer has a chance to flush effects. */
|
|
537
|
+
function interactAsync(action: () => void): Promise<void> {
|
|
538
|
+
return act(() => Promise.resolve().then(action));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/*** Mounts an isolated browser surface and restores global DOM bindings after a test. */
|
|
542
|
+
async function withBrowserAsync(
|
|
543
|
+
test: (browserWindow: Window, host: HTMLElement) => Promise<void>,
|
|
544
|
+
): Promise<void> {
|
|
545
|
+
const browserWindow = new Window();
|
|
546
|
+
const keys = ['window', 'document', 'Node', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'] as const;
|
|
547
|
+
const previous = keys.map(
|
|
548
|
+
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
|
|
549
|
+
);
|
|
550
|
+
Object.assign(globalThis, {
|
|
551
|
+
IS_REACT_ACT_ENVIRONMENT: true,
|
|
552
|
+
window: browserWindow,
|
|
553
|
+
document: browserWindow.document,
|
|
554
|
+
Node: browserWindow.Node,
|
|
555
|
+
navigator: browserWindow.navigator,
|
|
556
|
+
});
|
|
557
|
+
const host = document.createElement('div');
|
|
558
|
+
document.body.appendChild(host);
|
|
559
|
+
try {
|
|
560
|
+
await test(browserWindow, host);
|
|
561
|
+
} finally {
|
|
562
|
+
browserWindow.close();
|
|
563
|
+
for (const [key, descriptor] of previous) {
|
|
564
|
+
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
|
565
|
+
else Reflect.deleteProperty(globalThis, key);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/*** Dispatches touches whose target-local values may differ while page coordinates stay global. */
|
|
571
|
+
function dispatchTouchesAsync(
|
|
572
|
+
browserWindow: Window,
|
|
573
|
+
target: HTMLElement,
|
|
574
|
+
type: 'touchcancel' | 'touchend' | 'touchstart' | 'touchmove',
|
|
575
|
+
touches: readonly Readonly<{
|
|
576
|
+
locationX?: number;
|
|
577
|
+
locationY?: number;
|
|
578
|
+
pageX: number;
|
|
579
|
+
pageY: number;
|
|
580
|
+
}>[],
|
|
581
|
+
): Promise<void> {
|
|
582
|
+
const event = new browserWindow.Event(type, { bubbles: true });
|
|
583
|
+
Object.defineProperty(event, 'touches', { value: touches });
|
|
584
|
+
return interactAsync(() => target.dispatchEvent(event));
|
|
585
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import type { GridRectItem, GridViewport } from '@ankhorage/grid-view';
|
|
5
|
+
import { expect, test } from 'bun:test';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
8
|
+
|
|
9
|
+
import type { GridView as GridViewComponent } from './GridView';
|
|
10
|
+
|
|
11
|
+
const webDistRoot = join(import.meta.dir, '../../../../../web-dist');
|
|
12
|
+
const load = (path: string) => import(pathToFileURL(join(webDistRoot, path)).href);
|
|
13
|
+
|
|
14
|
+
const world = Array.from({ length: 10_000 }, (_, index): GridRectItem => ({
|
|
15
|
+
height: 10,
|
|
16
|
+
id: `item-${index}`,
|
|
17
|
+
width: 10,
|
|
18
|
+
x: (index % 100) * 10,
|
|
19
|
+
y: Math.floor(index / 100) * 10,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
test('virtualizes a large bounded world using independent default viewport scales', async () => {
|
|
23
|
+
const { GridView } = (await load('components/grid-view/index.js')) as {
|
|
24
|
+
GridView: typeof GridViewComponent;
|
|
25
|
+
};
|
|
26
|
+
const markup = renderGrid(GridView, {
|
|
27
|
+
height: 100,
|
|
28
|
+
offsetX: 200,
|
|
29
|
+
offsetY: 300,
|
|
30
|
+
pixelsPerUnitX: 2,
|
|
31
|
+
pixelsPerUnitY: 1,
|
|
32
|
+
width: 100,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(markup).toContain('item-3020');
|
|
36
|
+
expect(markup).not.toContain('item-0');
|
|
37
|
+
expect(markup).not.toContain('item-9999');
|
|
38
|
+
expect(markup.match(/data-grid-item=/g)?.length).toBeLessThan(150);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('renders controlled world offsets without expanding the mounted item set', async () => {
|
|
42
|
+
const { GridView } = (await load('components/grid-view/index.js')) as {
|
|
43
|
+
GridView: typeof GridViewComponent;
|
|
44
|
+
};
|
|
45
|
+
const markup = renderGrid(GridView, {
|
|
46
|
+
height: 100,
|
|
47
|
+
offsetX: 800,
|
|
48
|
+
offsetY: 700,
|
|
49
|
+
pixelsPerUnitX: 1,
|
|
50
|
+
pixelsPerUnitY: 2,
|
|
51
|
+
width: 100,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(markup).toContain('item-7080');
|
|
55
|
+
expect(markup).not.toContain('item-3020');
|
|
56
|
+
expect(markup.match(/data-grid-item=/g)?.length).toBeLessThan(150);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/*** Renders the public viewport boundary with caller-owned controlled state. */
|
|
60
|
+
function renderGrid(GridView: typeof GridViewComponent, viewport: GridViewport): string {
|
|
61
|
+
return renderToStaticMarkup(
|
|
62
|
+
<GridView
|
|
63
|
+
contentHeight={1000}
|
|
64
|
+
contentWidth={1000}
|
|
65
|
+
height={viewport.height}
|
|
66
|
+
items={world}
|
|
67
|
+
overscanPixels={0}
|
|
68
|
+
renderItem={(item) => <span data-grid-item={item.id}>{item.id}</span>}
|
|
69
|
+
viewport={viewport}
|
|
70
|
+
viewportConstraints={{ world: { height: 1000, width: 1000, x: 0, y: 0 } }}
|
|
71
|
+
width={viewport.width}
|
|
72
|
+
/>,
|
|
73
|
+
);
|
|
74
|
+
}
|