@ankhorage/zora 25.8.11 → 25.8.13
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 +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/GridInteractionsKeyboardProxy.web.d.ts.map +1 -1
- package/dist/features/grid-interactions/adapters/inbound/GridInteractionsKeyboardProxy.web.js +9 -2
- package/dist/features/grid-interactions/adapters/inbound/GridInteractionsKeyboardProxy.web.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 +70 -1
- package/src/features/grid-interactions/adapters/inbound/GridInteractions.tsx +12 -5
- package/src/features/grid-interactions/adapters/inbound/GridInteractionsKeyboardProxy.web.tsx +10 -1
- 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 +33 -4
- 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,292 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, jest, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import type { GridScrollAxis } from '../../../types/grid-view';
|
|
4
|
+
import { createGridScrollSync } from './createGridScrollSync';
|
|
5
|
+
|
|
6
|
+
type ScrollRequest = readonly [axis: GridScrollAxis, offset: number, animated: boolean];
|
|
7
|
+
type UserScroll = readonly [axis: GridScrollAxis, displacement: number];
|
|
8
|
+
|
|
9
|
+
let clock = 0;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
clock = 0;
|
|
13
|
+
jest.useFakeTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
jest.useRealTimers();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/*** Advances the injected clock and the fake timers together. */
|
|
21
|
+
function advance(milliseconds: number) {
|
|
22
|
+
clock += milliseconds;
|
|
23
|
+
jest.advanceTimersByTime(milliseconds);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/*** Creates a controller whose native requests and user scrolls are recorded. */
|
|
27
|
+
function createRecordedSync() {
|
|
28
|
+
const programmaticScrolls: UserScroll[] = [];
|
|
29
|
+
const requests: ScrollRequest[] = [];
|
|
30
|
+
const userScrolls: UserScroll[] = [];
|
|
31
|
+
const sync = createGridScrollSync({
|
|
32
|
+
now: () => clock,
|
|
33
|
+
onProgrammaticScroll: (axis, displacement) => programmaticScrolls.push([axis, displacement]),
|
|
34
|
+
onUserScroll: (axis, displacement) => userScrolls.push([axis, displacement]),
|
|
35
|
+
scrollTo: (axis, offset, animated) => requests.push([axis, offset, animated]),
|
|
36
|
+
});
|
|
37
|
+
return { programmaticScrolls, requests, sync, userScrolls };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test('adopts native scrolling as user input when nothing was requested', () => {
|
|
41
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
42
|
+
|
|
43
|
+
sync.scrolled('y', 30);
|
|
44
|
+
sync.scrolled('y', 45);
|
|
45
|
+
|
|
46
|
+
expect(userScrolls).toEqual([
|
|
47
|
+
['y', 30],
|
|
48
|
+
['y', 15],
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('requests only axes that moved beyond the native tolerance', () => {
|
|
53
|
+
const { requests, sync } = createRecordedSync();
|
|
54
|
+
|
|
55
|
+
sync.request({ x: 0.4, y: 200 }, true);
|
|
56
|
+
|
|
57
|
+
expect(requests).toEqual([['y', 200, true]]);
|
|
58
|
+
expect(sync.isCurrent({ x: 0.9, y: 200.5 })).toBe(true);
|
|
59
|
+
expect(sync.isCurrent({ x: 0, y: 202 })).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('ignores animation frames and the settled echo of a programmatic scroll', () => {
|
|
63
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
64
|
+
sync.request({ x: 0, y: 200 }, true);
|
|
65
|
+
|
|
66
|
+
advance(16);
|
|
67
|
+
sync.scrolled('y', 60);
|
|
68
|
+
advance(16);
|
|
69
|
+
sync.scrolled('y', 140);
|
|
70
|
+
advance(16);
|
|
71
|
+
sync.scrolled('y', 200.4);
|
|
72
|
+
advance(500);
|
|
73
|
+
|
|
74
|
+
expect(userScrolls).toEqual([]);
|
|
75
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
76
|
+
sync.scrolled('y', 230);
|
|
77
|
+
expect(userScrolls).toEqual([['y', 30]]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('keeps delayed animated frames from becoming user pans after a 120ms quiet period', () => {
|
|
81
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
82
|
+
sync.request({ x: 0, y: 200 }, true);
|
|
83
|
+
|
|
84
|
+
advance(16);
|
|
85
|
+
sync.scrolled('y', 40);
|
|
86
|
+
advance(150);
|
|
87
|
+
expect(userScrolls).toEqual([]);
|
|
88
|
+
sync.scrolled('y', 100);
|
|
89
|
+
advance(150);
|
|
90
|
+
sync.scrolled('y', 200);
|
|
91
|
+
advance(400);
|
|
92
|
+
|
|
93
|
+
expect(userScrolls).toEqual([]);
|
|
94
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
95
|
+
sync.scrolled('y', 230);
|
|
96
|
+
expect(userScrolls).toEqual([['y', 30]]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('ignores a first native animation frame arriving after the initial request deadline', () => {
|
|
100
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
101
|
+
sync.request({ x: 300, y: 0 }, true);
|
|
102
|
+
|
|
103
|
+
advance(800);
|
|
104
|
+
sync.scrolled('x', 100);
|
|
105
|
+
sync.scrolled('x', 299.7);
|
|
106
|
+
|
|
107
|
+
expect(userScrolls).toEqual([]);
|
|
108
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
109
|
+
sync.scrolled('x', 320);
|
|
110
|
+
expect(userScrolls).toEqual([['x', 20]]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('keeps the axes independent: settling vertical leaves a pending horizontal scroll alone', () => {
|
|
114
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
115
|
+
sync.request({ x: 300, y: 200 }, true);
|
|
116
|
+
|
|
117
|
+
sync.scrolled('y', 40);
|
|
118
|
+
advance(150);
|
|
119
|
+
expect(userScrolls).toEqual([]);
|
|
120
|
+
sync.scrollEnded('y', 40);
|
|
121
|
+
expect(userScrolls).toEqual([['y', -160]]);
|
|
122
|
+
|
|
123
|
+
sync.scrolled('x', 100);
|
|
124
|
+
sync.scrolled('x', 299.6);
|
|
125
|
+
expect(userScrolls).toEqual([['y', -160]]);
|
|
126
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('keeps a vertical animation pending while the user drags horizontally', () => {
|
|
130
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
131
|
+
sync.request({ x: 300, y: 200 }, true);
|
|
132
|
+
|
|
133
|
+
sync.dragBegan('x', 120);
|
|
134
|
+
sync.scrolled('y', 80);
|
|
135
|
+
sync.scrolled('y', 199.7);
|
|
136
|
+
|
|
137
|
+
expect(userScrolls).toEqual([['x', -180]]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('adopts the native offset reported when a drag interrupts an animation', () => {
|
|
141
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
142
|
+
sync.request({ x: 0, y: 200 }, true);
|
|
143
|
+
sync.scrolled('y', 40);
|
|
144
|
+
|
|
145
|
+
sync.dragBegan('y', 55);
|
|
146
|
+
|
|
147
|
+
expect(userScrolls).toEqual([['y', -145]]);
|
|
148
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
149
|
+
sync.scrolled('y', 70);
|
|
150
|
+
expect(userScrolls).toEqual([
|
|
151
|
+
['y', -145],
|
|
152
|
+
['y', 15],
|
|
153
|
+
]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('adopts the final native offset when an unreachable animation ends short', () => {
|
|
157
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
158
|
+
sync.request({ x: 0, y: 500 }, true);
|
|
159
|
+
sync.scrolled('y', 120);
|
|
160
|
+
|
|
161
|
+
sync.scrollEnded('y', 180);
|
|
162
|
+
|
|
163
|
+
expect(userScrolls).toEqual([['y', -320]]);
|
|
164
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('reconciles a stalled animation at its request deadline instead of the quiet interval', () => {
|
|
168
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
169
|
+
sync.request({ x: 0, y: 200 }, true);
|
|
170
|
+
sync.scrolled('y', 40);
|
|
171
|
+
sync.scrolled('y', 60);
|
|
172
|
+
|
|
173
|
+
advance(120);
|
|
174
|
+
expect(userScrolls).toEqual([]);
|
|
175
|
+
advance(579);
|
|
176
|
+
expect(userScrolls).toEqual([]);
|
|
177
|
+
advance(1);
|
|
178
|
+
|
|
179
|
+
expect(userScrolls).toEqual([['y', -140]]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('suppresses late animation echoes even after fallback reconciles a stalled axis', () => {
|
|
183
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
184
|
+
sync.request({ x: 0, y: 200 }, true);
|
|
185
|
+
sync.scrolled('y', 40);
|
|
186
|
+
|
|
187
|
+
advance(700);
|
|
188
|
+
expect(userScrolls).toEqual([['y', -160]]);
|
|
189
|
+
|
|
190
|
+
// A delayed native animation may resume after the request deadline.
|
|
191
|
+
sync.scrolled('y', 100);
|
|
192
|
+
sync.scrolled('y', 200);
|
|
193
|
+
expect(userScrolls).toEqual([['y', -160]]);
|
|
194
|
+
|
|
195
|
+
// A real drag explicitly takes ownership and adopts the current physical offset.
|
|
196
|
+
sync.dragBegan('y', 200);
|
|
197
|
+
sync.scrolled('y', 230);
|
|
198
|
+
expect(userScrolls).toEqual([
|
|
199
|
+
['y', -160],
|
|
200
|
+
['y', 30],
|
|
201
|
+
]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('converges the logical position when a delayed animation resumes after fallback', () => {
|
|
205
|
+
const { programmaticScrolls, sync, userScrolls } = createRecordedSync();
|
|
206
|
+
sync.request({ x: 0, y: 200 }, true);
|
|
207
|
+
sync.scrolled('y', 40);
|
|
208
|
+
|
|
209
|
+
advance(700);
|
|
210
|
+
sync.scrolled('y', 100);
|
|
211
|
+
sync.scrolled('y', 200);
|
|
212
|
+
expect(sync.isCurrent({ x: 0, y: 200 })).toBe(true);
|
|
213
|
+
sync.scrollEnded('y', 200);
|
|
214
|
+
|
|
215
|
+
expect(userScrolls).toEqual([['y', -160]]);
|
|
216
|
+
expect(programmaticScrolls).toEqual([
|
|
217
|
+
['y', 60],
|
|
218
|
+
['y', 100],
|
|
219
|
+
]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('retargeting discards residual echo ownership from an earlier animation', () => {
|
|
223
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
224
|
+
sync.request({ x: 0, y: 200 }, true);
|
|
225
|
+
sync.scrolled('y', 40);
|
|
226
|
+
advance(700);
|
|
227
|
+
|
|
228
|
+
sync.request({ x: 0, y: 400 }, true);
|
|
229
|
+
sync.scrolled('y', 100);
|
|
230
|
+
advance(700);
|
|
231
|
+
|
|
232
|
+
expect(userScrolls).toEqual([
|
|
233
|
+
['y', -160],
|
|
234
|
+
['y', -300],
|
|
235
|
+
]);
|
|
236
|
+
expect(sync.isCurrent({ x: 0, y: 100 })).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('a genuine reversal after a stalled animation exits residual echo suppression', () => {
|
|
240
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
241
|
+
sync.request({ x: 0, y: 200 }, true);
|
|
242
|
+
sync.scrolled('y', 40);
|
|
243
|
+
advance(700);
|
|
244
|
+
|
|
245
|
+
sync.scrolled('y', 100);
|
|
246
|
+
sync.scrolled('y', 20);
|
|
247
|
+
expect(userScrolls).toEqual([
|
|
248
|
+
['y', -160],
|
|
249
|
+
['y', -80],
|
|
250
|
+
]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('discards stale in-flight offsets and timers when a scroll is retargeted', () => {
|
|
254
|
+
const { requests, sync, userScrolls } = createRecordedSync();
|
|
255
|
+
sync.request({ x: 0, y: 200 }, true);
|
|
256
|
+
sync.scrolled('y', 40);
|
|
257
|
+
|
|
258
|
+
sync.request({ x: 0, y: 400 }, true);
|
|
259
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
260
|
+
advance(500);
|
|
261
|
+
sync.scrolled('y', 399.5);
|
|
262
|
+
|
|
263
|
+
expect(requests).toEqual([
|
|
264
|
+
['y', 200, true],
|
|
265
|
+
['y', 400, true],
|
|
266
|
+
]);
|
|
267
|
+
expect(userScrolls).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('returns control to the user once the programmatic scroll window expires', () => {
|
|
271
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
272
|
+
sync.request({ x: 0, y: 200 }, false);
|
|
273
|
+
|
|
274
|
+
advance(121);
|
|
275
|
+
sync.scrolled('y', 90);
|
|
276
|
+
|
|
277
|
+
expect(userScrolls).toEqual([['y', -110]]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('cancels every pending settle timer on dispose', () => {
|
|
281
|
+
const { sync, userScrolls } = createRecordedSync();
|
|
282
|
+
sync.request({ x: 300, y: 200 }, true);
|
|
283
|
+
sync.scrolled('x', 20);
|
|
284
|
+
sync.scrolled('y', 40);
|
|
285
|
+
expect(jest.getTimerCount()).toBe(2);
|
|
286
|
+
|
|
287
|
+
sync.dispose();
|
|
288
|
+
advance(1000);
|
|
289
|
+
|
|
290
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
291
|
+
expect(userScrolls).toEqual([]);
|
|
292
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GridPendingScroll,
|
|
3
|
+
GridScrollAxis,
|
|
4
|
+
GridScrollPosition,
|
|
5
|
+
GridScrollSync,
|
|
6
|
+
GridScrollSyncOptions,
|
|
7
|
+
} from '../../../types/grid-view';
|
|
8
|
+
import {
|
|
9
|
+
ANIMATED_SCROLL_WINDOW_MS,
|
|
10
|
+
IMMEDIATE_SCROLL_WINDOW_MS,
|
|
11
|
+
SCROLL_POSITION_TOLERANCE,
|
|
12
|
+
} from '../constants/scroll';
|
|
13
|
+
import { resolveScrollEventSource } from './resolveScrollEventSource';
|
|
14
|
+
|
|
15
|
+
const AXES: readonly GridScrollAxis[] = ['x', 'y'];
|
|
16
|
+
interface EchoTail {
|
|
17
|
+
lastOffset: number;
|
|
18
|
+
target: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/***
|
|
22
|
+
* Arbitrates each native scroll axis between programmatic requests and the user,
|
|
23
|
+
* so echoes of GridView's own scrollTo calls never become viewport pans.
|
|
24
|
+
*/
|
|
25
|
+
export function createGridScrollSync({
|
|
26
|
+
scrollTo,
|
|
27
|
+
onUserScroll,
|
|
28
|
+
onProgrammaticScroll,
|
|
29
|
+
now = Date.now,
|
|
30
|
+
schedule = setTimeout,
|
|
31
|
+
cancel = clearTimeout,
|
|
32
|
+
}: GridScrollSyncOptions): GridScrollSync {
|
|
33
|
+
let position: GridScrollPosition = { x: 0, y: 0 };
|
|
34
|
+
const pending = new Map<GridScrollAxis, GridPendingScroll>();
|
|
35
|
+
const inFlight = new Map<GridScrollAxis, number>();
|
|
36
|
+
// Keep ownership of late frames even after a stalled animation is reconciled.
|
|
37
|
+
const echoTails = new Map<GridScrollAxis, EchoTail>();
|
|
38
|
+
const timers = new Map<GridScrollAxis, ReturnType<typeof setTimeout>>();
|
|
39
|
+
|
|
40
|
+
const clearTimer = (axis: GridScrollAxis) => {
|
|
41
|
+
const timer = timers.get(axis);
|
|
42
|
+
if (timer !== undefined) cancel(timer);
|
|
43
|
+
timers.delete(axis);
|
|
44
|
+
};
|
|
45
|
+
const release = (axis: GridScrollAxis) => {
|
|
46
|
+
clearTimer(axis);
|
|
47
|
+
pending.delete(axis);
|
|
48
|
+
inFlight.delete(axis);
|
|
49
|
+
};
|
|
50
|
+
const adopt = (axis: GridScrollAxis, offset: number) => {
|
|
51
|
+
const displacement = offset - readAxis(position, axis);
|
|
52
|
+
position = withAxis(position, axis, offset);
|
|
53
|
+
if (displacement !== 0) onUserScroll(axis, displacement);
|
|
54
|
+
};
|
|
55
|
+
const settle = (axis: GridScrollAxis, finalOffset?: number) => {
|
|
56
|
+
const pendingScroll = pending.get(axis);
|
|
57
|
+
const offset = finalOffset ?? inFlight.get(axis);
|
|
58
|
+
release(axis);
|
|
59
|
+
if (offset !== undefined) {
|
|
60
|
+
if (
|
|
61
|
+
finalOffset === undefined &&
|
|
62
|
+
pendingScroll?.animated &&
|
|
63
|
+
!isSameOffset(offset, pendingScroll.target)
|
|
64
|
+
) {
|
|
65
|
+
echoTails.set(axis, { lastOffset: offset, target: pendingScroll.target });
|
|
66
|
+
} else {
|
|
67
|
+
echoTails.delete(axis);
|
|
68
|
+
}
|
|
69
|
+
adopt(axis, offset);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const scrolled = (axis: GridScrollAxis, offset: number) => {
|
|
73
|
+
const tail = echoTails.get(axis);
|
|
74
|
+
if (tail) {
|
|
75
|
+
if (isContinuingProgrammaticEcho(tail, offset)) {
|
|
76
|
+
const displacement = offset - readAxis(position, axis);
|
|
77
|
+
position = withAxis(position, axis, offset);
|
|
78
|
+
if (displacement !== 0) onProgrammaticScroll(axis, displacement);
|
|
79
|
+
if (isSameOffset(offset, tail.target)) echoTails.delete(axis);
|
|
80
|
+
else tail.lastOffset = offset;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
echoTails.delete(axis);
|
|
84
|
+
}
|
|
85
|
+
let pendingScroll = pending.get(axis);
|
|
86
|
+
if (pendingScroll?.animated && now() > pendingScroll.expiresAt) {
|
|
87
|
+
// A native scroll may deliver its first frame after the initial deadline.
|
|
88
|
+
// Without an explicit drag/end, that is still an echo of our scrollTo.
|
|
89
|
+
pendingScroll = { ...pendingScroll, expiresAt: now() + ANIMATED_SCROLL_WINDOW_MS };
|
|
90
|
+
pending.set(axis, pendingScroll);
|
|
91
|
+
}
|
|
92
|
+
const source = resolveScrollEventSource(pendingScroll, offset, now());
|
|
93
|
+
if (source === 'in-flight' && pendingScroll) {
|
|
94
|
+
clearTimer(axis);
|
|
95
|
+
inFlight.set(axis, offset);
|
|
96
|
+
// Native animation frames can pause longer than 120ms; only reconcile at the
|
|
97
|
+
// original request deadline, or earlier on an explicit drag/end/target echo.
|
|
98
|
+
timers.set(
|
|
99
|
+
axis,
|
|
100
|
+
schedule(() => settle(axis), Math.max(0, pendingScroll.expiresAt - now())),
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
release(axis);
|
|
105
|
+
if (source === 'user') adopt(axis, offset);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
isCurrent: (next) =>
|
|
110
|
+
AXES.every((axis) => isSameOffset(readAxis(position, axis), readAxis(next, axis))),
|
|
111
|
+
request: (next, animated) => {
|
|
112
|
+
const windowMs = animated ? ANIMATED_SCROLL_WINDOW_MS : IMMEDIATE_SCROLL_WINDOW_MS;
|
|
113
|
+
const expiresAt = now() + windowMs;
|
|
114
|
+
for (const axis of AXES) {
|
|
115
|
+
const target = readAxis(next, axis);
|
|
116
|
+
if (isSameOffset(readAxis(position, axis), target)) continue;
|
|
117
|
+
release(axis);
|
|
118
|
+
echoTails.delete(axis);
|
|
119
|
+
pending.set(axis, { animated, expiresAt, target });
|
|
120
|
+
scrollTo(axis, target, animated);
|
|
121
|
+
}
|
|
122
|
+
position = next;
|
|
123
|
+
},
|
|
124
|
+
scrolled,
|
|
125
|
+
dragBegan: (axis, offset) => {
|
|
126
|
+
const hadTail = echoTails.delete(axis);
|
|
127
|
+
if (pending.has(axis) || inFlight.has(axis)) settle(axis, offset);
|
|
128
|
+
else if (hadTail) adopt(axis, offset);
|
|
129
|
+
},
|
|
130
|
+
scrollEnded: (axis, offset) => {
|
|
131
|
+
scrolled(axis, offset);
|
|
132
|
+
echoTails.delete(axis);
|
|
133
|
+
if (pending.has(axis)) settle(axis, offset);
|
|
134
|
+
},
|
|
135
|
+
dispose: () => {
|
|
136
|
+
for (const axis of AXES) release(axis);
|
|
137
|
+
echoTails.clear();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/*** Suppresses delayed monotonic frames toward the old programmatic target until an actual drag/end. */
|
|
143
|
+
function isContinuingProgrammaticEcho({ lastOffset, target }: EchoTail, offset: number): boolean {
|
|
144
|
+
const step = offset - lastOffset;
|
|
145
|
+
if (Math.abs(step) <= SCROLL_POSITION_TOLERANCE) return true;
|
|
146
|
+
return (
|
|
147
|
+
Math.sign(step) === Math.sign(target - lastOffset) &&
|
|
148
|
+
Math.abs(target - offset) <= Math.abs(target - lastOffset) + SCROLL_POSITION_TOLERANCE
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/*** Reads one axis of a physical scroll position. */
|
|
153
|
+
function readAxis(value: GridScrollPosition, axis: GridScrollAxis): number {
|
|
154
|
+
return axis === 'x' ? value.x : value.y;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/*** Replaces one axis of a physical scroll position. */
|
|
158
|
+
function withAxis(
|
|
159
|
+
value: GridScrollPosition,
|
|
160
|
+
axis: GridScrollAxis,
|
|
161
|
+
offset: number,
|
|
162
|
+
): GridScrollPosition {
|
|
163
|
+
return axis === 'x' ? { ...value, x: offset } : { ...value, y: offset };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/*** Treats offsets within the native positioning tolerance as the same scroll offset. */
|
|
167
|
+
function isSameOffset(left: number, right: number): boolean {
|
|
168
|
+
return Math.abs(left - right) <= SCROLL_POSITION_TOLERANCE;
|
|
169
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { resolveScrollEventSource } from './resolveScrollEventSource';
|
|
4
|
+
|
|
5
|
+
const pending = { animated: true, expiresAt: 1000, target: 100 };
|
|
6
|
+
|
|
7
|
+
test('treats every offset as user input when no programmatic scroll is pending', () => {
|
|
8
|
+
expect(resolveScrollEventSource(undefined, 40, 0)).toBe('user');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('keeps intermediate animation frames separate from user input until the target is reached', () => {
|
|
12
|
+
expect(resolveScrollEventSource(pending, 10, 500)).toBe('in-flight');
|
|
13
|
+
expect(resolveScrollEventSource(pending, 99.4, 500)).toBe('settled');
|
|
14
|
+
expect(resolveScrollEventSource(pending, 100.9, 500)).toBe('settled');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('returns control to the user once the programmatic scroll window has expired', () => {
|
|
18
|
+
expect(resolveScrollEventSource(pending, 10, 1001)).toBe('user');
|
|
19
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { GridPendingScroll } from '../../../types/grid-view';
|
|
2
|
+
import { SCROLL_POSITION_TOLERANCE } from '../constants/scroll';
|
|
3
|
+
|
|
4
|
+
/*** Classifies a native scroll offset as a user gesture, the settled echo of a programmatic scroll, or an in-flight frame of one. */
|
|
5
|
+
export function resolveScrollEventSource(
|
|
6
|
+
pending: GridPendingScroll | undefined,
|
|
7
|
+
offset: number,
|
|
8
|
+
now: number,
|
|
9
|
+
): 'in-flight' | 'settled' | 'user' {
|
|
10
|
+
if (pending === undefined || now > pending.expiresAt) return 'user';
|
|
11
|
+
return Math.abs(offset - pending.target) <= SCROLL_POSITION_TOLERANCE ? 'settled' : 'in-flight';
|
|
12
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Largest native-versus-requested scroll offset difference, in pixels, still treated as the same position. */
|
|
2
|
+
export const SCROLL_POSITION_TOLERANCE = 1;
|
|
3
|
+
|
|
4
|
+
/** Upper bound for a native animated `scrollTo` to deliver its settled offset. */
|
|
5
|
+
export const ANIMATED_SCROLL_WINDOW_MS = 700;
|
|
6
|
+
|
|
7
|
+
/** Upper bound for an immediate `scrollTo` to deliver its settled offset. */
|
|
8
|
+
export const IMMEDIATE_SCROLL_WINDOW_MS = 120;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { ScrollGestureLock } from '../../../../types/layout';
|
|
4
|
+
|
|
5
|
+
/*** Carries the nearest ZORA ScrollView gesture lock to descendant gesture surfaces. */
|
|
6
|
+
export const ScrollGestureLockContext = React.createContext<ScrollGestureLock | undefined>(
|
|
7
|
+
undefined,
|
|
8
|
+
);
|