@ankhorage/zora 25.8.11 → 25.8.12
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-interactions/adapters/inbound/GridInteractions.d.ts.map +1 -1
- package/dist/features/grid-interactions/adapters/inbound/GridInteractions.js +11 -2
- package/dist/features/grid-interactions/adapters/inbound/GridInteractions.js.map +1 -1
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.d.ts +2 -0
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.d.ts.map +1 -0
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.js +2 -0
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.js.map +1 -0
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.native.d.ts +4 -0
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.native.d.ts.map +1 -0
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.native.js +11 -0
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.native.js.map +1 -0
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.web.d.ts +4 -0
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.web.d.ts.map +1 -0
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.web.js +9 -0
- package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.web.js.map +1 -0
- package/dist/features/grid-interactions/application/createGridInteractionScrollLock.d.ts +5 -0
- package/dist/features/grid-interactions/application/createGridInteractionScrollLock.d.ts.map +1 -0
- package/dist/features/grid-interactions/application/createGridInteractionScrollLock.js +14 -0
- package/dist/features/grid-interactions/application/createGridInteractionScrollLock.js.map +1 -0
- package/dist/features/grid-view/adapters/inbound/GridView.d.ts.map +1 -1
- package/dist/features/grid-view/adapters/inbound/GridView.js +34 -30
- package/dist/features/grid-view/adapters/inbound/GridView.js.map +1 -1
- package/dist/features/grid-view/application/createGridScrollSync.d.ts +7 -0
- package/dist/features/grid-view/application/createGridScrollSync.d.ts.map +1 -0
- package/dist/features/grid-view/application/createGridScrollSync.js +141 -0
- package/dist/features/grid-view/application/createGridScrollSync.js.map +1 -0
- package/dist/features/grid-view/application/resolveScrollEventSource.d.ts +4 -0
- package/dist/features/grid-view/application/resolveScrollEventSource.d.ts.map +1 -0
- package/dist/features/grid-view/application/resolveScrollEventSource.js +8 -0
- package/dist/features/grid-view/application/resolveScrollEventSource.js.map +1 -0
- package/dist/features/grid-view/constants/scroll.d.ts +7 -0
- package/dist/features/grid-view/constants/scroll.d.ts.map +1 -0
- package/dist/features/grid-view/constants/scroll.js +7 -0
- package/dist/features/grid-view/constants/scroll.js.map +1 -0
- package/dist/features/layout/adapters/inbound/ScrollGestureLockContext.d.ts +5 -0
- package/dist/features/layout/adapters/inbound/ScrollGestureLockContext.d.ts.map +1 -0
- package/dist/features/layout/adapters/inbound/ScrollGestureLockContext.js +4 -0
- package/dist/features/layout/adapters/inbound/ScrollGestureLockContext.js.map +1 -0
- package/dist/features/layout/adapters/inbound/ScrollView.d.ts.map +1 -1
- package/dist/features/layout/adapters/inbound/ScrollView.js +11 -3
- package/dist/features/layout/adapters/inbound/ScrollView.js.map +1 -1
- package/dist/features/layout/adapters/inbound/useScrollGestureLock.d.ts +4 -0
- package/dist/features/layout/adapters/inbound/useScrollGestureLock.d.ts.map +1 -0
- package/dist/features/layout/adapters/inbound/useScrollGestureLock.js +8 -0
- package/dist/features/layout/adapters/inbound/useScrollGestureLock.js.map +1 -0
- package/dist/features/layout/public.d.ts +2 -1
- package/dist/features/layout/public.d.ts.map +1 -1
- package/dist/features/layout/public.js +1 -0
- package/dist/features/layout/public.js.map +1 -1
- package/dist/features/layout/utils/createScrollGestureLock.d.ts +4 -0
- package/dist/features/layout/utils/createScrollGestureLock.d.ts.map +1 -0
- package/dist/features/layout/utils/createScrollGestureLock.js +18 -0
- package/dist/features/layout/utils/createScrollGestureLock.js.map +1 -0
- package/dist/types/grid-interactions.d.ts +5 -0
- package/dist/types/grid-interactions.d.ts.map +1 -1
- package/dist/types/grid-interactions.js.map +1 -1
- package/dist/types/grid-view.d.ts +40 -0
- package/dist/types/grid-view.d.ts.map +1 -1
- package/dist/types/grid-view.js.map +1 -1
- package/dist/types/layout.d.ts +5 -0
- package/dist/types/layout.d.ts.map +1 -1
- package/dist/types/layout.js.map +1 -1
- package/package.json +2 -2
- package/src/features/grid-interactions/adapters/inbound/GridInteractions.scrollLock.test.tsx +265 -0
- package/src/features/grid-interactions/adapters/inbound/GridInteractions.test.tsx +64 -0
- package/src/features/grid-interactions/adapters/inbound/GridInteractions.tsx +12 -4
- package/src/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.native.ts +17 -0
- package/src/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.ts +1 -0
- package/src/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.web.ts +11 -0
- package/src/features/grid-interactions/application/createGridInteractionScrollLock.test.ts +41 -0
- package/src/features/grid-interactions/application/createGridInteractionScrollLock.ts +18 -0
- package/src/features/grid-view/adapters/inbound/GridView.nativeScrollSync.test.tsx +186 -0
- package/src/features/grid-view/adapters/inbound/GridView.scrollSync.interaction.test.tsx +304 -0
- package/src/features/grid-view/adapters/inbound/GridView.tsx +49 -29
- package/src/features/grid-view/application/createGridScrollSync.test.ts +292 -0
- package/src/features/grid-view/application/createGridScrollSync.ts +169 -0
- package/src/features/grid-view/application/resolveScrollEventSource.test.ts +19 -0
- package/src/features/grid-view/application/resolveScrollEventSource.ts +12 -0
- package/src/features/grid-view/constants/scroll.ts +8 -0
- package/src/features/layout/adapters/inbound/ScrollGestureLockContext.ts +8 -0
- package/src/features/layout/adapters/inbound/ScrollView.test.tsx +234 -0
- package/src/features/layout/adapters/inbound/ScrollView.tsx +18 -2
- package/src/features/layout/adapters/inbound/useScrollGestureLock.ts +11 -0
- package/src/features/layout/public.ts +2 -0
- package/src/features/layout/utils/createScrollGestureLock.test.ts +34 -0
- package/src/features/layout/utils/createScrollGestureLock.ts +21 -0
- package/src/types/grid-interactions.ts +6 -0
- package/src/types/grid-view.ts +45 -0
- package/src/types/layout.ts +6 -0
- package/web-dist/chunks/{Accordion-v3rzy273.js → Accordion-11ej93t5.js} +3 -3
- package/web-dist/chunks/{Accordion-ddrn94v3.js → Accordion-1hbt9k69.js} +1 -1
- package/web-dist/chunks/{Accordion-wpk8dxqp.js → Accordion-26prtnm7.js} +1 -1
- package/web-dist/chunks/{Accordion-0q2wzjdd.js → Accordion-2jy53kzk.js} +2 -2
- package/web-dist/chunks/{Accordion-geah5s4f.js → Accordion-4yq92mxp.js} +1 -1
- package/web-dist/chunks/{Accordion-8pkw32q9.js → Accordion-5pr1wqe3.js} +1 -1
- package/web-dist/chunks/{Accordion-y3083394.js → Accordion-799z2zf6.js} +1 -1
- package/web-dist/chunks/{Accordion-hecpczme.js → Accordion-9aczb0k2.js} +2 -2
- package/web-dist/chunks/{Accordion-rctfgyfv.js → Accordion-a4pz3vjj.js} +1 -1
- package/web-dist/chunks/{Accordion-9jrxktne.js → Accordion-b337t2df.js} +1 -1
- package/web-dist/chunks/{Accordion-h4ekm5cp.js → Accordion-czcaf5x2.js} +1 -1
- package/web-dist/chunks/{Accordion-vbzjq3z8.js → Accordion-ep35792s.js} +44 -2
- package/web-dist/chunks/{Accordion-ts788t7b.js → Accordion-g08740h4.js} +1 -1
- package/web-dist/chunks/{Accordion-9ygakvwk.js → Accordion-g2aqhct0.js} +4 -4
- package/web-dist/chunks/{Accordion-p1ebrt17.js → Accordion-gn8ptzwb.js} +1 -1
- package/web-dist/chunks/{Accordion-sq7ya2ye.js → Accordion-h9brgsk8.js} +1 -1
- package/web-dist/chunks/{Accordion-ztw7zp6v.js → Accordion-m2hcc8g4.js} +2 -2
- package/web-dist/chunks/{Accordion-sdnzqktc.js → Accordion-m6mp8aea.js} +3 -3
- package/web-dist/chunks/{Accordion-vxg5sw01.js → Accordion-p7d5svfq.js} +1 -1
- package/web-dist/chunks/{Accordion-k9cxpg08.js → Accordion-rqhh2m5b.js} +3 -3
- package/web-dist/chunks/{Accordion-wcmrasqx.js → Accordion-rqr10gn4.js} +4 -4
- package/web-dist/chunks/{Accordion-hn43wt45.js → Accordion-t0k1cjca.js} +181 -29
- package/web-dist/chunks/{Accordion-w59y0jdf.js → Accordion-tyh8jj9d.js} +1 -1
- package/web-dist/chunks/{Accordion-dk0dtyc0.js → Accordion-vjcd5kxc.js} +1 -1
- package/web-dist/chunks/{Accordion-t5scffdr.js → Accordion-vkd002gw.js} +5 -5
- package/web-dist/chunks/{Accordion-r87xx7mg.js → Accordion-w87gvpbg.js} +1 -1
- package/web-dist/chunks/{Accordion-yexfbrzg.js → Accordion-wqyrrptg.js} +2 -2
- package/web-dist/chunks/{Accordion-c50pywj3.js → Accordion-y2pss5p0.js} +1 -1
- package/web-dist/components/accordion/Accordion.js +2 -2
- package/web-dist/components/accordion-item/AccordionItem.js +2 -2
- package/web-dist/components/app-bar/AppBar.js +4 -4
- package/web-dist/components/app-header/AppHeader.js +4 -4
- package/web-dist/components/app-shell/AppShell.js +1 -1
- package/web-dist/components/auth-screen/AuthScreen.js +6 -6
- package/web-dist/components/avatar/Avatar.js +2 -2
- package/web-dist/components/avatar-group/AvatarGroup.js +2 -2
- package/web-dist/components/barcode-scanner-view/BarcodeScannerView.js +6 -6
- package/web-dist/components/bottom-sheet/BottomSheet.js +2 -2
- package/web-dist/components/bottom-sheet-flat-list/BottomSheetFlatList.js +2 -2
- package/web-dist/components/bottom-sheet-scroll-view/BottomSheetScrollView.js +2 -2
- package/web-dist/components/bottom-sheet-section-list/BottomSheetSectionList.js +2 -2
- package/web-dist/components/bottom-sheet-view/BottomSheetView.js +2 -2
- package/web-dist/components/bottom-sheet-virtualized-list/BottomSheetVirtualizedList.js +2 -2
- package/web-dist/components/breadcrumbs/Breadcrumbs.js +2 -2
- package/web-dist/components/button/Button.js +2 -2
- package/web-dist/components/button-group/ButtonGroup.js +2 -2
- package/web-dist/components/camera-permission-view/CameraPermissionView.js +6 -6
- package/web-dist/components/card/Card.js +5 -5
- package/web-dist/components/chat-list-item/ChatListItem.js +3 -3
- package/web-dist/components/checkbox/Checkbox.js +2 -2
- package/web-dist/components/checkbox-group/CheckboxGroup.js +2 -2
- package/web-dist/components/chip/Chip.js +2 -2
- package/web-dist/components/chip-group/ChipGroup.js +2 -2
- package/web-dist/components/collection-editor/CollectionEditor.js +5 -5
- package/web-dist/components/content-rail/ContentRail.js +2 -2
- package/web-dist/components/data-table/DataTable.js +10 -10
- package/web-dist/components/date-picker/DatePicker.js +2 -2
- package/web-dist/components/dialog/Dialog.js +2 -2
- package/web-dist/components/divider/Divider.js +1 -1
- package/web-dist/components/empty-state/EmptyState.js +6 -6
- package/web-dist/components/explorer/Explorer.js +4 -4
- package/web-dist/components/field/Field.js +2 -2
- package/web-dist/components/file-explorer/FileExplorer.js +4 -4
- package/web-dist/components/flat-list/FlatList.js +3 -3
- package/web-dist/components/forgot-password-form/ForgotPasswordForm.js +6 -6
- package/web-dist/components/form/Form.js +4 -4
- package/web-dist/components/form-actions/FormActions.js +4 -4
- package/web-dist/components/form-error/FormError.js +4 -4
- package/web-dist/components/game/Game.js +2 -2
- package/web-dist/components/game-entity/GameEntity.js +2 -2
- package/web-dist/components/game-field/GameField.js +2 -2
- package/web-dist/components/game-input-zone/GameInputZone.js +2 -2
- package/web-dist/components/game-measurement-probe/GameMeasurementProbe.js +2 -2
- package/web-dist/components/game-overlay/GameOverlay.js +2 -2
- package/web-dist/components/gradient/Gradient.js +2 -2
- package/web-dist/components/gradient-renderer-provider/GradientRendererProvider.js +2 -2
- package/web-dist/components/grid/Grid.js +1 -1
- package/web-dist/components/grid-interactions/GridInteractions.js +24 -2
- package/web-dist/components/grid-view/GridView.js +1 -1
- package/web-dist/components/hero/Hero.js +5 -5
- package/web-dist/components/icon-button/IconButton.js +2 -2
- package/web-dist/components/image/Image.js +2 -2
- package/web-dist/components/list/List.js +3 -3
- package/web-dist/components/list-item/ListItem.js +3 -3
- package/web-dist/components/list-section/ListSection.js +3 -3
- package/web-dist/components/matrix-grid/MatrixGrid.js +1 -1
- package/web-dist/components/media-card/MediaCard.js +5 -5
- package/web-dist/components/media-explorer/MediaExplorer.js +4 -4
- package/web-dist/components/message-bubble/MessageBubble.js +3 -3
- package/web-dist/components/metric-card/MetricCard.js +5 -5
- package/web-dist/components/missing-element/MissingElement.js +1 -1
- package/web-dist/components/o-auth-provider-button/OAuthProviderButton.js +6 -6
- package/web-dist/components/o-auth-provider-list/OAuthProviderList.js +6 -6
- package/web-dist/components/otp-form/OtpForm.js +6 -6
- package/web-dist/components/pagination/Pagination.js +2 -2
- package/web-dist/components/palette-item/PaletteItem.js +5 -5
- package/web-dist/components/popover-menu/PopoverMenu.js +2 -2
- package/web-dist/components/post-card/PostCard.js +5 -5
- package/web-dist/components/product-card/ProductCard.js +5 -5
- package/web-dist/components/progress/Progress.js +2 -2
- package/web-dist/components/progress-ring/ProgressRing.js +2 -2
- package/web-dist/components/radio/Radio.js +2 -2
- package/web-dist/components/radio-group/RadioGroup.js +2 -2
- package/web-dist/components/rating/Rating.js +1 -1
- package/web-dist/components/reader/Reader.js +6 -6
- package/web-dist/components/scan-overlay/ScanOverlay.js +6 -6
- package/web-dist/components/screen/Screen.js +1 -1
- package/web-dist/components/screen-section/ScreenSection.js +2 -2
- package/web-dist/components/scroll-view/ScrollView.js +1 -1
- package/web-dist/components/search-input/SearchInput.js +3 -3
- package/web-dist/components/section-header/SectionHeader.js +2 -2
- package/web-dist/components/section-list/SectionList.js +3 -3
- package/web-dist/components/select/Select.js +2 -2
- package/web-dist/components/sign-in-form/SignInForm.js +6 -6
- package/web-dist/components/sign-up-form/SignUpForm.js +6 -6
- package/web-dist/components/skeleton/Skeleton.js +8 -8
- package/web-dist/components/skeleton-card/SkeletonCard.js +8 -8
- package/web-dist/components/skeleton-list/SkeletonList.js +8 -8
- package/web-dist/components/skeleton-text/SkeletonText.js +8 -8
- package/web-dist/components/spatial-grid/SpatialGrid.js +1 -1
- package/web-dist/components/text-input/TextInput.js +3 -3
- package/web-dist/components/tile-grid/TileGrid.js +1 -1
- package/web-dist/components/time-grid/TimeGrid.js +1 -1
- package/web-dist/components/time-picker/TimePicker.js +2 -2
- package/web-dist/components/timeline/Timeline.js +1 -1
- package/web-dist/components/toolbar/Toolbar.js +5 -5
- package/web-dist/components/tree-item/TreeItem.js +4 -4
- package/web-dist/components/uploader/Uploader.js +9 -9
- package/web-dist/components/view/View.js +1 -1
- package/web-dist/manifest.json +326 -326
|
@@ -0,0 +1,304 @@
|
|
|
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
|
+
|
|
12
|
+
const webDistRoot = join(import.meta.dir, '../../../../../web-dist');
|
|
13
|
+
const load = () => import(pathToFileURL(join(webDistRoot, 'components/grid-view/index.js')).href);
|
|
14
|
+
const items: readonly GridRectItem[] = [
|
|
15
|
+
{ height: 20, id: 'near', width: 20, x: 10, y: 10 },
|
|
16
|
+
{ height: 20, id: 'far', width: 20, x: 300, y: 300 },
|
|
17
|
+
{ height: 20, id: 'far-other', width: 20, x: 600, y: 600 },
|
|
18
|
+
];
|
|
19
|
+
const constraints = { world: { height: 1000, width: 1000, x: 0, y: 0 } };
|
|
20
|
+
|
|
21
|
+
test('ignores native echoes of an animated focus reveal and still follows real user scrolling', async () => {
|
|
22
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
23
|
+
await withBrowserAsync(async (browserWindow, host) => {
|
|
24
|
+
const root = createRoot(host);
|
|
25
|
+
const harness = createRevealHarness(GridView, host, browserWindow);
|
|
26
|
+
try {
|
|
27
|
+
await harness.renderAsync(root, undefined);
|
|
28
|
+
await harness.renderAsync(root, 'far');
|
|
29
|
+
const target = harness.lastRequestedTop();
|
|
30
|
+
expect(harness.changes).toHaveLength(1);
|
|
31
|
+
expect(target).toBeGreaterThan(100);
|
|
32
|
+
|
|
33
|
+
await harness.scrollToAsync(target / 4);
|
|
34
|
+
await harness.scrollToAsync(target / 2);
|
|
35
|
+
await harness.scrollToAsync(target + 0.4);
|
|
36
|
+
expect(harness.changes).toHaveLength(1);
|
|
37
|
+
|
|
38
|
+
await harness.scrollToAsync(target + 30);
|
|
39
|
+
expect(harness.changes).toHaveLength(2);
|
|
40
|
+
expect(harness.changes.at(-1)?.offsetY).toBeCloseTo(target + 30, 5);
|
|
41
|
+
} finally {
|
|
42
|
+
await interactAsync(() => root.unmount());
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('adopts the native offset when an animated reveal is interrupted before reaching its target', async () => {
|
|
48
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
49
|
+
await withBrowserAsync(async (browserWindow, host) => {
|
|
50
|
+
const root = createRoot(host);
|
|
51
|
+
const harness = createRevealHarness(GridView, host, browserWindow);
|
|
52
|
+
try {
|
|
53
|
+
await harness.renderAsync(root, undefined);
|
|
54
|
+
await harness.renderAsync(root, 'far');
|
|
55
|
+
await harness.scrollToAsync(40);
|
|
56
|
+
expect(harness.changes).toHaveLength(1);
|
|
57
|
+
|
|
58
|
+
await waitUntilAsync(() => harness.changes.length === 2);
|
|
59
|
+
expect(harness.changes).toHaveLength(2);
|
|
60
|
+
expect(harness.changes.at(-1)?.offsetY).toBe(40);
|
|
61
|
+
} finally {
|
|
62
|
+
await interactAsync(() => root.unmount());
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('retargets an in-flight reveal without feeding stale animation frames back as pans', async () => {
|
|
68
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
69
|
+
await withBrowserAsync(async (browserWindow, host) => {
|
|
70
|
+
const root = createRoot(host);
|
|
71
|
+
const harness = createRevealHarness(GridView, host, browserWindow);
|
|
72
|
+
try {
|
|
73
|
+
await harness.renderAsync(root, undefined);
|
|
74
|
+
await harness.renderAsync(root, 'far');
|
|
75
|
+
const firstTarget = harness.lastRequestedTop();
|
|
76
|
+
await harness.scrollToAsync(firstTarget / 2);
|
|
77
|
+
await harness.renderAsync(root, 'far-other');
|
|
78
|
+
const secondTarget = harness.lastRequestedTop();
|
|
79
|
+
expect(secondTarget).toBeGreaterThan(firstTarget);
|
|
80
|
+
|
|
81
|
+
await harness.scrollToAsync(firstTarget / 2 + 10);
|
|
82
|
+
await harness.scrollToAsync(firstTarget);
|
|
83
|
+
await harness.scrollToAsync(secondTarget);
|
|
84
|
+
|
|
85
|
+
expect(harness.changes).toHaveLength(2);
|
|
86
|
+
expect(harness.changes.at(-1)?.offsetY).toBe(secondTarget);
|
|
87
|
+
} finally {
|
|
88
|
+
await interactAsync(() => root.unmount());
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('settles an interrupted vertical reveal while the horizontal reveal echo stays pending', async () => {
|
|
94
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
95
|
+
const wideItems: readonly GridRectItem[] = [
|
|
96
|
+
{ height: 20, id: 'wide', width: 20, x: 500, y: 300 },
|
|
97
|
+
];
|
|
98
|
+
await withBrowserAsync(async (browserWindow, host) => {
|
|
99
|
+
const root = createRoot(host);
|
|
100
|
+
const harness = createRevealHarness(GridView, host, browserWindow, wideItems);
|
|
101
|
+
try {
|
|
102
|
+
await harness.renderAsync(root, undefined);
|
|
103
|
+
await harness.renderAsync(root, 'wide');
|
|
104
|
+
|
|
105
|
+
// Both axes receive their own programmatic target before any echo is simulated.
|
|
106
|
+
expect(harness.lastRequestedLeft()).toBe(428);
|
|
107
|
+
expect(harness.lastRequestedTop()).toBe(228);
|
|
108
|
+
expect(harness.changes).toHaveLength(1);
|
|
109
|
+
expect(harness.changes[0]).toMatchObject({ offsetX: 428, offsetY: 228 });
|
|
110
|
+
|
|
111
|
+
// The vertical animation is interrupted at its real native offset and then goes quiet.
|
|
112
|
+
await harness.scrollToAsync(40);
|
|
113
|
+
expect(harness.changes).toHaveLength(1);
|
|
114
|
+
await waitUntilAsync(() => harness.changes.length === 2);
|
|
115
|
+
expect(harness.changes[1]).toMatchObject({ offsetX: 428, offsetY: 40 });
|
|
116
|
+
|
|
117
|
+
// Horizontal animation frames and their settled echo are still not user pans.
|
|
118
|
+
await harness.scrollLeftToAsync(200);
|
|
119
|
+
await harness.scrollLeftToAsync(428.4);
|
|
120
|
+
expect(harness.changes).toHaveLength(2);
|
|
121
|
+
|
|
122
|
+
// A genuine horizontal drag afterwards is adopted from the pre-echo baseline.
|
|
123
|
+
await harness.scrollLeftToAsync(458);
|
|
124
|
+
expect(harness.changes).toHaveLength(3);
|
|
125
|
+
expect(harness.changes[2]).toMatchObject({ offsetX: 458, offsetY: 40 });
|
|
126
|
+
} finally {
|
|
127
|
+
await interactAsync(() => root.unmount());
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('keeps the scroll arbiter working after React Strict Mode double-invokes its mount effects', async () => {
|
|
133
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
134
|
+
await withBrowserAsync(async (browserWindow, host) => {
|
|
135
|
+
const root = createRoot(host);
|
|
136
|
+
const harness = createRevealHarness(GridView, host, browserWindow, items, true);
|
|
137
|
+
try {
|
|
138
|
+
await harness.renderAsync(root, undefined);
|
|
139
|
+
await harness.renderAsync(root, 'far');
|
|
140
|
+
const target = harness.lastRequestedTop();
|
|
141
|
+
expect(target).toBe(228);
|
|
142
|
+
expect(harness.changes).toHaveLength(1);
|
|
143
|
+
|
|
144
|
+
await harness.scrollToAsync(target / 4);
|
|
145
|
+
await harness.scrollToAsync(target / 2);
|
|
146
|
+
await harness.scrollToAsync(target + 0.4);
|
|
147
|
+
expect(harness.changes).toHaveLength(1);
|
|
148
|
+
|
|
149
|
+
await harness.scrollToAsync(target + 30);
|
|
150
|
+
expect(harness.changes).toHaveLength(2);
|
|
151
|
+
expect(harness.changes[1]?.offsetY).toBeCloseTo(target + 30, 5);
|
|
152
|
+
} finally {
|
|
153
|
+
await interactAsync(() => root.unmount());
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('scrolls the native views to a non-zero default viewport on a Strict Mode mount', async () => {
|
|
159
|
+
const { GridView } = (await load()) as { GridView: typeof GridViewComponent };
|
|
160
|
+
await withBrowserAsync(async (_browserWindow, host) => {
|
|
161
|
+
const root = createRoot(host);
|
|
162
|
+
try {
|
|
163
|
+
await interactAsync(() =>
|
|
164
|
+
root.render(
|
|
165
|
+
<React.StrictMode>
|
|
166
|
+
<GridView
|
|
167
|
+
contentHeight={1000}
|
|
168
|
+
contentWidth={1000}
|
|
169
|
+
defaultViewport={{ offsetX: 70, offsetY: 50 }}
|
|
170
|
+
height={100}
|
|
171
|
+
items={items}
|
|
172
|
+
renderItem={(item) => <span data-grid-item={item.id}>{item.id}</span>}
|
|
173
|
+
testID="grid"
|
|
174
|
+
viewportConstraints={constraints}
|
|
175
|
+
width={100}
|
|
176
|
+
/>
|
|
177
|
+
</React.StrictMode>,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
const vertical = host.querySelector<HTMLElement>('[data-testid="grid-vertical-scroll"]');
|
|
181
|
+
const horizontal = host.querySelector<HTMLElement>('[data-testid="grid-horizontal-scroll"]');
|
|
182
|
+
expect(vertical?.scrollTop).toBe(50);
|
|
183
|
+
expect(horizontal?.scrollLeft).toBe(70);
|
|
184
|
+
} finally {
|
|
185
|
+
await interactAsync(() => root.unmount());
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/*** Records vertical native scroll requests and drives scroll events for focus-reveal tests. */
|
|
191
|
+
function createRevealHarness(
|
|
192
|
+
GridView: typeof GridViewComponent,
|
|
193
|
+
host: HTMLElement,
|
|
194
|
+
browserWindow: Window,
|
|
195
|
+
gridItems: readonly GridRectItem[] = items,
|
|
196
|
+
strict = false,
|
|
197
|
+
) {
|
|
198
|
+
const changes: GridViewport[] = [];
|
|
199
|
+
const requestedTops: number[] = [];
|
|
200
|
+
const requestedLefts: number[] = [];
|
|
201
|
+
const Wrapper = strict ? React.StrictMode : React.Fragment;
|
|
202
|
+
return {
|
|
203
|
+
changes,
|
|
204
|
+
lastRequestedLeft: () => requestedLefts.at(-1) ?? Number.NaN,
|
|
205
|
+
lastRequestedTop: () => requestedTops.at(-1) ?? Number.NaN,
|
|
206
|
+
renderAsync: async (root: ReturnType<typeof createRoot>, focusedItemId: string | undefined) => {
|
|
207
|
+
await interactAsync(() =>
|
|
208
|
+
root.render(
|
|
209
|
+
<Wrapper>
|
|
210
|
+
<GridView
|
|
211
|
+
contentHeight={1000}
|
|
212
|
+
contentWidth={1000}
|
|
213
|
+
focusedItemId={focusedItemId}
|
|
214
|
+
height={100}
|
|
215
|
+
items={gridItems}
|
|
216
|
+
onViewportChange={(viewport) => changes.push(viewport)}
|
|
217
|
+
renderItem={(item) => <span data-grid-item={item.id}>{item.id}</span>}
|
|
218
|
+
testID="grid"
|
|
219
|
+
viewportConstraints={constraints}
|
|
220
|
+
width={100}
|
|
221
|
+
/>
|
|
222
|
+
</Wrapper>,
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
const vertical = host.querySelector<HTMLElement>('[data-testid="grid-vertical-scroll"]');
|
|
226
|
+
const horizontal = host.querySelector<HTMLElement>('[data-testid="grid-horizontal-scroll"]');
|
|
227
|
+
if (!vertical || !horizontal) throw new Error('Missing scroll surfaces');
|
|
228
|
+
// RNW scrollTo({ y }) reaches the host as scroll({ top, left: 0 }), and scrollTo({ x }) as
|
|
229
|
+
// scroll({ top: 0, left }): only the requested axis carries a nonzero offset.
|
|
230
|
+
vertical.scroll = (options?: ScrollToOptions | number) => {
|
|
231
|
+
if (typeof options === 'object' && options.top !== undefined) {
|
|
232
|
+
requestedTops.push(options.top);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
horizontal.scroll = (options?: ScrollToOptions | number) => {
|
|
236
|
+
if (typeof options === 'object' && options.left !== undefined) {
|
|
237
|
+
requestedLefts.push(options.left);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
scrollToAsync: async (top: number) => {
|
|
242
|
+
const vertical = host.querySelector<HTMLElement>('[data-testid="grid-vertical-scroll"]');
|
|
243
|
+
if (!vertical) throw new Error('Missing vertical scroll surface');
|
|
244
|
+
vertical.scrollTop = top;
|
|
245
|
+
// RNW drops scroll events that arrive faster than the 16ms scrollEventThrottle.
|
|
246
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
247
|
+
await interactAsync(() =>
|
|
248
|
+
vertical.dispatchEvent(new browserWindow.Event('scroll', { bubbles: true })),
|
|
249
|
+
);
|
|
250
|
+
},
|
|
251
|
+
scrollLeftToAsync: async (left: number) => {
|
|
252
|
+
const horizontal = host.querySelector<HTMLElement>('[data-testid="grid-horizontal-scroll"]');
|
|
253
|
+
if (!horizontal) throw new Error('Missing horizontal scroll surface');
|
|
254
|
+
horizontal.scrollLeft = left;
|
|
255
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
256
|
+
await interactAsync(() =>
|
|
257
|
+
horizontal.dispatchEvent(new browserWindow.Event('scroll', { bubbles: true })),
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/*** Resolves as soon as a condition holds, failing instead of sleeping for a fixed time. */
|
|
264
|
+
async function waitUntilAsync(condition: () => boolean, timeoutMilliseconds = 1000) {
|
|
265
|
+
const deadline = Date.now() + timeoutMilliseconds;
|
|
266
|
+
while (!condition()) {
|
|
267
|
+
if (Date.now() > deadline) throw new Error('Condition was not met before the timeout.');
|
|
268
|
+
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 5)));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/*** Executes a React interaction after the renderer has a chance to flush effects. */
|
|
273
|
+
function interactAsync(action: () => void): Promise<void> {
|
|
274
|
+
return act(() => Promise.resolve().then(action));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/*** Mounts an isolated browser surface and restores global DOM bindings after a test. */
|
|
278
|
+
async function withBrowserAsync(
|
|
279
|
+
test: (browserWindow: Window, host: HTMLElement) => Promise<void>,
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
const browserWindow = new Window();
|
|
282
|
+
const keys = ['window', 'document', 'Node', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'] as const;
|
|
283
|
+
const previous = keys.map(
|
|
284
|
+
(key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
|
|
285
|
+
);
|
|
286
|
+
Object.assign(globalThis, {
|
|
287
|
+
IS_REACT_ACT_ENVIRONMENT: true,
|
|
288
|
+
window: browserWindow,
|
|
289
|
+
document: browserWindow.document,
|
|
290
|
+
Node: browserWindow.Node,
|
|
291
|
+
navigator: browserWindow.navigator,
|
|
292
|
+
});
|
|
293
|
+
const host = document.createElement('div');
|
|
294
|
+
document.body.appendChild(host);
|
|
295
|
+
try {
|
|
296
|
+
await test(browserWindow, host);
|
|
297
|
+
} finally {
|
|
298
|
+
browserWindow.close();
|
|
299
|
+
for (const [key, descriptor] of previous) {
|
|
300
|
+
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
|
301
|
+
else Reflect.deleteProperty(globalThis, key);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -17,9 +17,9 @@ import {
|
|
|
17
17
|
View as NativeView,
|
|
18
18
|
} from 'react-native';
|
|
19
19
|
|
|
20
|
-
import type { GridViewProps } from '../../../../types/grid-view';
|
|
20
|
+
import type { GridScrollAxis, GridScrollSync, GridViewProps } from '../../../../types/grid-view';
|
|
21
|
+
import { createGridScrollSync } from '../../application/createGridScrollSync';
|
|
21
22
|
|
|
22
|
-
type ScrollPosition = Readonly<{ x: number; y: number }>;
|
|
23
23
|
type PinchGesture = Readonly<{ distance: number; focalPoint: GridPoint }>;
|
|
24
24
|
type ScrollAxisMapping = Readonly<{ contentSize: number; origin: number; position: number }>;
|
|
25
25
|
type ScrollMapping = Readonly<{
|
|
@@ -61,7 +61,8 @@ export function GridView({
|
|
|
61
61
|
const horizontalScrollRef = React.useRef<NativeScrollView>(null);
|
|
62
62
|
const verticalScrollRef = React.useRef<NativeScrollView>(null);
|
|
63
63
|
const gridRef = React.useRef<React.ComponentRef<typeof NativeView>>(null);
|
|
64
|
-
const
|
|
64
|
+
const panRef = React.useRef<(displacement: GridPoint) => void>(() => undefined);
|
|
65
|
+
const scrollSyncRef = React.useRef<GridScrollSync | undefined>(undefined);
|
|
65
66
|
const pinchGestureRef = React.useRef<PinchGesture | undefined>(undefined);
|
|
66
67
|
const pinchMeasurementRef = React.useRef(0);
|
|
67
68
|
const pinchSessionRef = React.useRef(0);
|
|
@@ -131,6 +132,34 @@ export function GridView({
|
|
|
131
132
|
publishRef.current = publish;
|
|
132
133
|
}, [publish]);
|
|
133
134
|
|
|
135
|
+
React.useEffect(() => {
|
|
136
|
+
panRef.current = pan;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Declared before the viewport-sync effect so the arbiter exists when that effect runs.
|
|
140
|
+
React.useEffect(() => {
|
|
141
|
+
const scrollSync = createGridScrollSync({
|
|
142
|
+
onProgrammaticScroll: (axis, displacement) =>
|
|
143
|
+
panRef.current(axis === 'x' ? { x: -displacement, y: 0 } : { x: 0, y: -displacement }),
|
|
144
|
+
onUserScroll: (axis, displacement) =>
|
|
145
|
+
panRef.current(axis === 'x' ? { x: -displacement, y: 0 } : { x: 0, y: -displacement }),
|
|
146
|
+
scrollTo: (axis, offset, animated) => {
|
|
147
|
+
if (axis === 'x') horizontalScrollRef.current?.scrollTo({ animated, x: offset });
|
|
148
|
+
else verticalScrollRef.current?.scrollTo({ animated, y: offset });
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
scrollSyncRef.current = scrollSync;
|
|
152
|
+
return () => {
|
|
153
|
+
scrollSync.dispose();
|
|
154
|
+
scrollSyncRef.current = undefined;
|
|
155
|
+
};
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
/** Routes native scroll events to the arbiter unless a pinch owns the gesture. */
|
|
159
|
+
const handleNativeScroll = (axis: GridScrollAxis, offset: number) => {
|
|
160
|
+
if (!pinchGestureRef.current) scrollSyncRef.current?.scrolled(axis, offset);
|
|
161
|
+
};
|
|
162
|
+
|
|
134
163
|
const refreshViewportOrigin = React.useCallback(() => {
|
|
135
164
|
gridRef.current?.measureInWindow((x, y) => {
|
|
136
165
|
viewportOriginRef.current = { x, y };
|
|
@@ -187,13 +216,11 @@ export function GridView({
|
|
|
187
216
|
x: position.horizontal.position,
|
|
188
217
|
y: position.vertical.position,
|
|
189
218
|
};
|
|
190
|
-
if (
|
|
191
|
-
scrollPositionRef.current = nextPosition;
|
|
219
|
+
if (scrollSyncRef.current?.isCurrent(nextPosition)) return;
|
|
192
220
|
const animated =
|
|
193
221
|
focusRevealViewportRef.current !== undefined &&
|
|
194
222
|
areViewportsEqual(viewport, focusRevealViewportRef.current);
|
|
195
|
-
|
|
196
|
-
verticalScrollRef.current?.scrollTo({ animated, y: nextPosition.y });
|
|
223
|
+
scrollSyncRef.current?.request(nextPosition, animated);
|
|
197
224
|
if (animated) focusRevealViewportRef.current = undefined;
|
|
198
225
|
}, [constraints, viewport]);
|
|
199
226
|
|
|
@@ -259,9 +286,7 @@ export function GridView({
|
|
|
259
286
|
x: nextMapping.horizontal.position,
|
|
260
287
|
y: nextMapping.vertical.position,
|
|
261
288
|
};
|
|
262
|
-
|
|
263
|
-
horizontalScrollRef.current?.scrollTo({ animated: false, x: nextPosition.x });
|
|
264
|
-
verticalScrollRef.current?.scrollTo({ animated: false, y: nextPosition.y });
|
|
289
|
+
scrollSyncRef.current?.request(nextPosition, false);
|
|
265
290
|
};
|
|
266
291
|
|
|
267
292
|
return (
|
|
@@ -314,13 +339,13 @@ export function GridView({
|
|
|
314
339
|
scrollEventThrottle={16}
|
|
315
340
|
showsHorizontalScrollIndicator
|
|
316
341
|
style={{ height, width }}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
342
|
+
onMomentumScrollEnd={(event) =>
|
|
343
|
+
scrollSyncRef.current?.scrollEnded('x', event.nativeEvent.contentOffset.x)
|
|
344
|
+
}
|
|
345
|
+
onScroll={(event) => handleNativeScroll('x', event.nativeEvent.contentOffset.x)}
|
|
346
|
+
onScrollBeginDrag={(event) =>
|
|
347
|
+
scrollSyncRef.current?.dragBegan('x', event.nativeEvent.contentOffset.x)
|
|
348
|
+
}
|
|
324
349
|
>
|
|
325
350
|
<NativeScrollView
|
|
326
351
|
ref={verticalScrollRef}
|
|
@@ -329,13 +354,13 @@ export function GridView({
|
|
|
329
354
|
scrollEventThrottle={16}
|
|
330
355
|
showsVerticalScrollIndicator
|
|
331
356
|
style={{ height, width: scrollMapping.horizontal.contentSize }}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
357
|
+
onMomentumScrollEnd={(event) =>
|
|
358
|
+
scrollSyncRef.current?.scrollEnded('y', event.nativeEvent.contentOffset.y)
|
|
359
|
+
}
|
|
360
|
+
onScroll={(event) => handleNativeScroll('y', event.nativeEvent.contentOffset.y)}
|
|
361
|
+
onScrollBeginDrag={(event) =>
|
|
362
|
+
scrollSyncRef.current?.dragBegan('y', event.nativeEvent.contentOffset.y)
|
|
363
|
+
}
|
|
339
364
|
>
|
|
340
365
|
<NativeView
|
|
341
366
|
style={{
|
|
@@ -518,11 +543,6 @@ function areViewportsEqual(left: GridViewport, right: GridViewport): boolean {
|
|
|
518
543
|
);
|
|
519
544
|
}
|
|
520
545
|
|
|
521
|
-
/*** Avoids requesting native scrolling again when the physical position is already current. */
|
|
522
|
-
function areScrollPositionsEqual(left: ScrollPosition, right: ScrollPosition): boolean {
|
|
523
|
-
return left.x === right.x && left.y === right.y;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
546
|
/*** Skips a rejected controlled focus proposal until its focus or incoming viewport changes. */
|
|
527
547
|
function isRepeatedControlledFocusRevealProposal({
|
|
528
548
|
controlled,
|