@ankhorage/zora 25.5.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 +6 -0
- package/README.md +2 -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/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/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/time-grid/adapters/inbound/TimeGrid.test.tsx +35 -8
- package/src/types/grid-view.ts +27 -1
- package/web-dist/chunks/{Accordion-0my2z6qe.js → Accordion-ebghcyzj.js} +41 -1
- package/web-dist/chunks/{Accordion-d6azgz09.js → Accordion-f43jba23.js} +1 -1
- package/web-dist/chunks/Accordion-q8exgc3h.js +515 -0
- package/web-dist/chunks/{Accordion-7qfd4cb4.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/spatial-grid/SpatialGrid.js +1 -1
- package/web-dist/components/tile-grid/TileGrid.js +1 -1
- package/web-dist/components/time-grid/TimeGrid.js +2 -2
- package/web-dist/components/uploader/Uploader.js +2 -2
- package/web-dist/manifest.json +24 -24
- package/web-dist/chunks/Accordion-gxj5qbpr.js +0 -194
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { GridRectItem, GridViewport } from '@ankhorage/grid-view';
|
|
1
|
+
import type { GridRectItem, GridViewport, GridViewportConstraints, GridZoomLimits } from '@ankhorage/grid-view';
|
|
2
2
|
import type React from 'react';
|
|
3
3
|
import type { ZoraBaseProps } from './base';
|
|
4
4
|
/** The renderer owns presentation only; world geometry comes from grid-view. */
|
|
@@ -8,6 +8,18 @@ export interface GridViewProps extends ZoraBaseProps {
|
|
|
8
8
|
contentHeight: number;
|
|
9
9
|
width: number;
|
|
10
10
|
height: number;
|
|
11
|
+
/**
|
|
12
|
+
* Canonical controlled world viewport. Supplying this value makes `onViewportChange`
|
|
13
|
+
* report interaction proposals without mutating renderer-owned state.
|
|
14
|
+
*/
|
|
15
|
+
viewport?: GridViewport;
|
|
16
|
+
/** Initial world position and independent axis scales for uncontrolled rendering. */
|
|
17
|
+
defaultViewport?: Partial<Pick<GridViewport, 'offsetX' | 'offsetY' | 'pixelsPerUnitX' | 'pixelsPerUnitY'>>;
|
|
18
|
+
/** Finite world bounds and optional overscroll for panning and focused-item reveal. */
|
|
19
|
+
viewportConstraints?: GridViewportConstraints;
|
|
20
|
+
/** Independent X/Y scale limits applied to focal-point zoom interactions. */
|
|
21
|
+
zoomLimits?: GridZoomLimits;
|
|
22
|
+
/** @deprecated Use `defaultViewport` for new code. */
|
|
11
23
|
zoom?: number;
|
|
12
24
|
overscanPixels?: number;
|
|
13
25
|
/** Stable item identity that must be scrolled into view without duplicating viewport geometry. */
|
|
@@ -30,11 +42,16 @@ export interface TileGridProps extends ZoraBaseProps {
|
|
|
30
42
|
tileSize?: number;
|
|
31
43
|
gap?: number;
|
|
32
44
|
zoom?: number;
|
|
45
|
+
viewport?: GridViewport;
|
|
46
|
+
defaultViewport?: Partial<Pick<GridViewport, 'offsetX' | 'offsetY' | 'pixelsPerUnitX' | 'pixelsPerUnitY'>>;
|
|
47
|
+
viewportConstraints?: GridViewportConstraints;
|
|
48
|
+
zoomLimits?: GridZoomLimits;
|
|
33
49
|
overscanPixels?: number;
|
|
34
50
|
focusedItemId?: string;
|
|
35
51
|
revealPaddingPixels?: number;
|
|
36
52
|
/** Reports the same measured column count used to position tiles. */
|
|
37
53
|
onColumnsChange?: (columns: number) => void;
|
|
54
|
+
onViewportChange?: (viewport: GridViewport) => void;
|
|
38
55
|
onVisibleItemIdsChange?: (ids: readonly string[]) => void;
|
|
39
56
|
renderItem: (item: TileGridItem) => React.ReactNode;
|
|
40
57
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"grid-view.d.ts","sourceRoot":"","sources":["../../src/types/grid-view.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"grid-view.d.ts","sourceRoot":"","sources":["../../src/types/grid-view.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EACZ,uBAAuB,EACvB,cAAc,EACf,MAAM,sBAAsB,CAAC;AAC9B,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,QAAQ,CAAC;AAE5C,gFAAgF;AAChF,MAAM,WAAW,aAAc,SAAQ,aAAa;IAClD,KAAK,EAAE,SAAS,YAAY,EAAE,CAAC;IAC/B,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,qFAAqF;IACrF,eAAe,CAAC,EAAE,OAAO,CACvB,IAAI,CAAC,YAAY,EAAE,SAAS,GAAG,SAAS,GAAG,gBAAgB,GAAG,gBAAgB,CAAC,CAChF,CAAC;IACF,uFAAuF;IACvF,mBAAmB,CAAC,EAAE,uBAAuB,CAAC;IAC9C,6EAA6E;IAC7E,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,sDAAsD;IACtD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,kGAAkG;IAClG,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,2DAA2D;IAC3D,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAC;IACpD,sBAAsB,CAAC,EAAE,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAAC;IAC1D,UAAU,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,KAAK,CAAC,SAAS,CAAC;CACrD;AAED,yFAAyF;AACzF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,6EAA6E;AAC7E,MAAM,WAAW,aAAc,SAAQ,aAAa;IAClD,KAAK,EAAE,SAAS,YAAY,EAAE,CAAC;IAC/B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,eAAe,CAAC,EAAE,OAAO,CACvB,IAAI,CAAC,YAAY,EAAE,SAAS,GAAG,SAAS,GAAG,gBAAgB,GAAG,gBAAgB,CAAC,CAChF,CAAC;IACF,mBAAmB,CAAC,EAAE,uBAAuB,CAAC;IAC9C,UAAU,CAAC,EAAE,cAAc,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,qEAAqE;IACrE,eAAe,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C,gBAAgB,CAAC,EAAE,CAAC,QAAQ,EAAE,YAAY,KAAK,IAAI,CAAC;IACpD,sBAAsB,CAAC,EAAE,CAAC,GAAG,EAAE,SAAS,MAAM,EAAE,KAAK,IAAI,CAAC;IAC1D,UAAU,EAAE,CAAC,IAAI,EAAE,YAAY,KAAK,KAAK,CAAC,SAAS,CAAC;CACrD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"grid-view.js","sourceRoot":"","sources":["../../src/types/grid-view.ts"],"names":[],"mappings":"","sourcesContent":["import type {
|
|
1
|
+
{"version":3,"file":"grid-view.js","sourceRoot":"","sources":["../../src/types/grid-view.ts"],"names":[],"mappings":"","sourcesContent":["import type {\n GridRectItem,\n GridViewport,\n GridViewportConstraints,\n GridZoomLimits,\n} from '@ankhorage/grid-view';\nimport type React from 'react';\n\nimport type { ZoraBaseProps } from './base';\n\n/** The renderer owns presentation only; world geometry comes from grid-view. */\nexport interface GridViewProps extends ZoraBaseProps {\n items: readonly GridRectItem[];\n contentWidth: number;\n contentHeight: number;\n width: number;\n height: number;\n /**\n * Canonical controlled world viewport. Supplying this value makes `onViewportChange`\n * report interaction proposals without mutating renderer-owned state.\n */\n viewport?: GridViewport;\n /** Initial world position and independent axis scales for uncontrolled rendering. */\n defaultViewport?: Partial<\n Pick<GridViewport, 'offsetX' | 'offsetY' | 'pixelsPerUnitX' | 'pixelsPerUnitY'>\n >;\n /** Finite world bounds and optional overscroll for panning and focused-item reveal. */\n viewportConstraints?: GridViewportConstraints;\n /** Independent X/Y scale limits applied to focal-point zoom interactions. */\n zoomLimits?: GridZoomLimits;\n /** @deprecated Use `defaultViewport` for new code. */\n zoom?: number;\n overscanPixels?: number;\n /** Stable item identity that must be scrolled into view without duplicating viewport geometry. */\n focusedItemId?: string;\n /** Pixel inset retained around a revealed focused item. */\n revealPaddingPixels?: number;\n onViewportChange?: (viewport: GridViewport) => void;\n onVisibleItemIdsChange?: (ids: readonly string[]) => void;\n renderItem: (item: GridRectItem) => React.ReactNode;\n}\n\n/** An item needs a stable ID; visual positioning belongs to the TileGrid composition. */\nexport interface TileGridItem {\n readonly id: string;\n}\n\n/** Reusable tile presentation based on the generic world/viewport engine. */\nexport interface TileGridProps extends ZoraBaseProps {\n items: readonly TileGridItem[];\n width?: number;\n height?: number;\n tileSize?: number;\n gap?: number;\n zoom?: number;\n viewport?: GridViewport;\n defaultViewport?: Partial<\n Pick<GridViewport, 'offsetX' | 'offsetY' | 'pixelsPerUnitX' | 'pixelsPerUnitY'>\n >;\n viewportConstraints?: GridViewportConstraints;\n zoomLimits?: GridZoomLimits;\n overscanPixels?: number;\n focusedItemId?: string;\n revealPaddingPixels?: number;\n /** Reports the same measured column count used to position tiles. */\n onColumnsChange?: (columns: number) => void;\n onViewportChange?: (viewport: GridViewport) => void;\n onVisibleItemIdsChange?: (ids: readonly string[]) => void;\n renderItem: (item: TileGridItem) => 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.6.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": {
|
|
@@ -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
|
+
}
|