@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.
Files changed (221) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -1
  3. package/dist/features/grid-interactions/adapters/inbound/GridInteractions.d.ts.map +1 -1
  4. package/dist/features/grid-interactions/adapters/inbound/GridInteractions.js +11 -2
  5. package/dist/features/grid-interactions/adapters/inbound/GridInteractions.js.map +1 -1
  6. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.d.ts +2 -0
  7. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.d.ts.map +1 -0
  8. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.js +2 -0
  9. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.js.map +1 -0
  10. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.native.d.ts +4 -0
  11. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.native.d.ts.map +1 -0
  12. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.native.js +11 -0
  13. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.native.js.map +1 -0
  14. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.web.d.ts +4 -0
  15. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.web.d.ts.map +1 -0
  16. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.web.js +9 -0
  17. package/dist/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.web.js.map +1 -0
  18. package/dist/features/grid-interactions/application/createGridInteractionScrollLock.d.ts +5 -0
  19. package/dist/features/grid-interactions/application/createGridInteractionScrollLock.d.ts.map +1 -0
  20. package/dist/features/grid-interactions/application/createGridInteractionScrollLock.js +14 -0
  21. package/dist/features/grid-interactions/application/createGridInteractionScrollLock.js.map +1 -0
  22. package/dist/features/grid-view/adapters/inbound/GridView.d.ts.map +1 -1
  23. package/dist/features/grid-view/adapters/inbound/GridView.js +34 -30
  24. package/dist/features/grid-view/adapters/inbound/GridView.js.map +1 -1
  25. package/dist/features/grid-view/application/createGridScrollSync.d.ts +7 -0
  26. package/dist/features/grid-view/application/createGridScrollSync.d.ts.map +1 -0
  27. package/dist/features/grid-view/application/createGridScrollSync.js +141 -0
  28. package/dist/features/grid-view/application/createGridScrollSync.js.map +1 -0
  29. package/dist/features/grid-view/application/resolveScrollEventSource.d.ts +4 -0
  30. package/dist/features/grid-view/application/resolveScrollEventSource.d.ts.map +1 -0
  31. package/dist/features/grid-view/application/resolveScrollEventSource.js +8 -0
  32. package/dist/features/grid-view/application/resolveScrollEventSource.js.map +1 -0
  33. package/dist/features/grid-view/constants/scroll.d.ts +7 -0
  34. package/dist/features/grid-view/constants/scroll.d.ts.map +1 -0
  35. package/dist/features/grid-view/constants/scroll.js +7 -0
  36. package/dist/features/grid-view/constants/scroll.js.map +1 -0
  37. package/dist/features/layout/adapters/inbound/ScrollGestureLockContext.d.ts +5 -0
  38. package/dist/features/layout/adapters/inbound/ScrollGestureLockContext.d.ts.map +1 -0
  39. package/dist/features/layout/adapters/inbound/ScrollGestureLockContext.js +4 -0
  40. package/dist/features/layout/adapters/inbound/ScrollGestureLockContext.js.map +1 -0
  41. package/dist/features/layout/adapters/inbound/ScrollView.d.ts.map +1 -1
  42. package/dist/features/layout/adapters/inbound/ScrollView.js +11 -3
  43. package/dist/features/layout/adapters/inbound/ScrollView.js.map +1 -1
  44. package/dist/features/layout/adapters/inbound/useScrollGestureLock.d.ts +4 -0
  45. package/dist/features/layout/adapters/inbound/useScrollGestureLock.d.ts.map +1 -0
  46. package/dist/features/layout/adapters/inbound/useScrollGestureLock.js +8 -0
  47. package/dist/features/layout/adapters/inbound/useScrollGestureLock.js.map +1 -0
  48. package/dist/features/layout/public.d.ts +2 -1
  49. package/dist/features/layout/public.d.ts.map +1 -1
  50. package/dist/features/layout/public.js +1 -0
  51. package/dist/features/layout/public.js.map +1 -1
  52. package/dist/features/layout/utils/createScrollGestureLock.d.ts +4 -0
  53. package/dist/features/layout/utils/createScrollGestureLock.d.ts.map +1 -0
  54. package/dist/features/layout/utils/createScrollGestureLock.js +18 -0
  55. package/dist/features/layout/utils/createScrollGestureLock.js.map +1 -0
  56. package/dist/types/grid-interactions.d.ts +5 -0
  57. package/dist/types/grid-interactions.d.ts.map +1 -1
  58. package/dist/types/grid-interactions.js.map +1 -1
  59. package/dist/types/grid-view.d.ts +40 -0
  60. package/dist/types/grid-view.d.ts.map +1 -1
  61. package/dist/types/grid-view.js.map +1 -1
  62. package/dist/types/layout.d.ts +5 -0
  63. package/dist/types/layout.d.ts.map +1 -1
  64. package/dist/types/layout.js.map +1 -1
  65. package/package.json +2 -2
  66. package/src/features/grid-interactions/adapters/inbound/GridInteractions.scrollLock.test.tsx +265 -0
  67. package/src/features/grid-interactions/adapters/inbound/GridInteractions.test.tsx +64 -0
  68. package/src/features/grid-interactions/adapters/inbound/GridInteractions.tsx +12 -4
  69. package/src/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.native.ts +17 -0
  70. package/src/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.ts +1 -0
  71. package/src/features/grid-interactions/adapters/inbound/useGridInteractionScrollLock.web.ts +11 -0
  72. package/src/features/grid-interactions/application/createGridInteractionScrollLock.test.ts +41 -0
  73. package/src/features/grid-interactions/application/createGridInteractionScrollLock.ts +18 -0
  74. package/src/features/grid-view/adapters/inbound/GridView.nativeScrollSync.test.tsx +186 -0
  75. package/src/features/grid-view/adapters/inbound/GridView.scrollSync.interaction.test.tsx +304 -0
  76. package/src/features/grid-view/adapters/inbound/GridView.tsx +49 -29
  77. package/src/features/grid-view/application/createGridScrollSync.test.ts +292 -0
  78. package/src/features/grid-view/application/createGridScrollSync.ts +169 -0
  79. package/src/features/grid-view/application/resolveScrollEventSource.test.ts +19 -0
  80. package/src/features/grid-view/application/resolveScrollEventSource.ts +12 -0
  81. package/src/features/grid-view/constants/scroll.ts +8 -0
  82. package/src/features/layout/adapters/inbound/ScrollGestureLockContext.ts +8 -0
  83. package/src/features/layout/adapters/inbound/ScrollView.test.tsx +234 -0
  84. package/src/features/layout/adapters/inbound/ScrollView.tsx +18 -2
  85. package/src/features/layout/adapters/inbound/useScrollGestureLock.ts +11 -0
  86. package/src/features/layout/public.ts +2 -0
  87. package/src/features/layout/utils/createScrollGestureLock.test.ts +34 -0
  88. package/src/features/layout/utils/createScrollGestureLock.ts +21 -0
  89. package/src/types/grid-interactions.ts +6 -0
  90. package/src/types/grid-view.ts +45 -0
  91. package/src/types/layout.ts +6 -0
  92. package/web-dist/chunks/{Accordion-v3rzy273.js → Accordion-11ej93t5.js} +3 -3
  93. package/web-dist/chunks/{Accordion-ddrn94v3.js → Accordion-1hbt9k69.js} +1 -1
  94. package/web-dist/chunks/{Accordion-wpk8dxqp.js → Accordion-26prtnm7.js} +1 -1
  95. package/web-dist/chunks/{Accordion-0q2wzjdd.js → Accordion-2jy53kzk.js} +2 -2
  96. package/web-dist/chunks/{Accordion-geah5s4f.js → Accordion-4yq92mxp.js} +1 -1
  97. package/web-dist/chunks/{Accordion-8pkw32q9.js → Accordion-5pr1wqe3.js} +1 -1
  98. package/web-dist/chunks/{Accordion-y3083394.js → Accordion-799z2zf6.js} +1 -1
  99. package/web-dist/chunks/{Accordion-hecpczme.js → Accordion-9aczb0k2.js} +2 -2
  100. package/web-dist/chunks/{Accordion-rctfgyfv.js → Accordion-a4pz3vjj.js} +1 -1
  101. package/web-dist/chunks/{Accordion-9jrxktne.js → Accordion-b337t2df.js} +1 -1
  102. package/web-dist/chunks/{Accordion-h4ekm5cp.js → Accordion-czcaf5x2.js} +1 -1
  103. package/web-dist/chunks/{Accordion-vbzjq3z8.js → Accordion-ep35792s.js} +44 -2
  104. package/web-dist/chunks/{Accordion-ts788t7b.js → Accordion-g08740h4.js} +1 -1
  105. package/web-dist/chunks/{Accordion-9ygakvwk.js → Accordion-g2aqhct0.js} +4 -4
  106. package/web-dist/chunks/{Accordion-p1ebrt17.js → Accordion-gn8ptzwb.js} +1 -1
  107. package/web-dist/chunks/{Accordion-sq7ya2ye.js → Accordion-h9brgsk8.js} +1 -1
  108. package/web-dist/chunks/{Accordion-ztw7zp6v.js → Accordion-m2hcc8g4.js} +2 -2
  109. package/web-dist/chunks/{Accordion-sdnzqktc.js → Accordion-m6mp8aea.js} +3 -3
  110. package/web-dist/chunks/{Accordion-vxg5sw01.js → Accordion-p7d5svfq.js} +1 -1
  111. package/web-dist/chunks/{Accordion-k9cxpg08.js → Accordion-rqhh2m5b.js} +3 -3
  112. package/web-dist/chunks/{Accordion-wcmrasqx.js → Accordion-rqr10gn4.js} +4 -4
  113. package/web-dist/chunks/{Accordion-hn43wt45.js → Accordion-t0k1cjca.js} +181 -29
  114. package/web-dist/chunks/{Accordion-w59y0jdf.js → Accordion-tyh8jj9d.js} +1 -1
  115. package/web-dist/chunks/{Accordion-dk0dtyc0.js → Accordion-vjcd5kxc.js} +1 -1
  116. package/web-dist/chunks/{Accordion-t5scffdr.js → Accordion-vkd002gw.js} +5 -5
  117. package/web-dist/chunks/{Accordion-r87xx7mg.js → Accordion-w87gvpbg.js} +1 -1
  118. package/web-dist/chunks/{Accordion-yexfbrzg.js → Accordion-wqyrrptg.js} +2 -2
  119. package/web-dist/chunks/{Accordion-c50pywj3.js → Accordion-y2pss5p0.js} +1 -1
  120. package/web-dist/components/accordion/Accordion.js +2 -2
  121. package/web-dist/components/accordion-item/AccordionItem.js +2 -2
  122. package/web-dist/components/app-bar/AppBar.js +4 -4
  123. package/web-dist/components/app-header/AppHeader.js +4 -4
  124. package/web-dist/components/app-shell/AppShell.js +1 -1
  125. package/web-dist/components/auth-screen/AuthScreen.js +6 -6
  126. package/web-dist/components/avatar/Avatar.js +2 -2
  127. package/web-dist/components/avatar-group/AvatarGroup.js +2 -2
  128. package/web-dist/components/barcode-scanner-view/BarcodeScannerView.js +6 -6
  129. package/web-dist/components/bottom-sheet/BottomSheet.js +2 -2
  130. package/web-dist/components/bottom-sheet-flat-list/BottomSheetFlatList.js +2 -2
  131. package/web-dist/components/bottom-sheet-scroll-view/BottomSheetScrollView.js +2 -2
  132. package/web-dist/components/bottom-sheet-section-list/BottomSheetSectionList.js +2 -2
  133. package/web-dist/components/bottom-sheet-view/BottomSheetView.js +2 -2
  134. package/web-dist/components/bottom-sheet-virtualized-list/BottomSheetVirtualizedList.js +2 -2
  135. package/web-dist/components/breadcrumbs/Breadcrumbs.js +2 -2
  136. package/web-dist/components/button/Button.js +2 -2
  137. package/web-dist/components/button-group/ButtonGroup.js +2 -2
  138. package/web-dist/components/camera-permission-view/CameraPermissionView.js +6 -6
  139. package/web-dist/components/card/Card.js +5 -5
  140. package/web-dist/components/chat-list-item/ChatListItem.js +3 -3
  141. package/web-dist/components/checkbox/Checkbox.js +2 -2
  142. package/web-dist/components/checkbox-group/CheckboxGroup.js +2 -2
  143. package/web-dist/components/chip/Chip.js +2 -2
  144. package/web-dist/components/chip-group/ChipGroup.js +2 -2
  145. package/web-dist/components/collection-editor/CollectionEditor.js +5 -5
  146. package/web-dist/components/content-rail/ContentRail.js +2 -2
  147. package/web-dist/components/data-table/DataTable.js +10 -10
  148. package/web-dist/components/date-picker/DatePicker.js +2 -2
  149. package/web-dist/components/dialog/Dialog.js +2 -2
  150. package/web-dist/components/divider/Divider.js +1 -1
  151. package/web-dist/components/empty-state/EmptyState.js +6 -6
  152. package/web-dist/components/explorer/Explorer.js +4 -4
  153. package/web-dist/components/field/Field.js +2 -2
  154. package/web-dist/components/file-explorer/FileExplorer.js +4 -4
  155. package/web-dist/components/flat-list/FlatList.js +3 -3
  156. package/web-dist/components/forgot-password-form/ForgotPasswordForm.js +6 -6
  157. package/web-dist/components/form/Form.js +4 -4
  158. package/web-dist/components/form-actions/FormActions.js +4 -4
  159. package/web-dist/components/form-error/FormError.js +4 -4
  160. package/web-dist/components/game/Game.js +2 -2
  161. package/web-dist/components/game-entity/GameEntity.js +2 -2
  162. package/web-dist/components/game-field/GameField.js +2 -2
  163. package/web-dist/components/game-input-zone/GameInputZone.js +2 -2
  164. package/web-dist/components/game-measurement-probe/GameMeasurementProbe.js +2 -2
  165. package/web-dist/components/game-overlay/GameOverlay.js +2 -2
  166. package/web-dist/components/gradient/Gradient.js +2 -2
  167. package/web-dist/components/gradient-renderer-provider/GradientRendererProvider.js +2 -2
  168. package/web-dist/components/grid/Grid.js +1 -1
  169. package/web-dist/components/grid-interactions/GridInteractions.js +24 -2
  170. package/web-dist/components/grid-view/GridView.js +1 -1
  171. package/web-dist/components/hero/Hero.js +5 -5
  172. package/web-dist/components/icon-button/IconButton.js +2 -2
  173. package/web-dist/components/image/Image.js +2 -2
  174. package/web-dist/components/list/List.js +3 -3
  175. package/web-dist/components/list-item/ListItem.js +3 -3
  176. package/web-dist/components/list-section/ListSection.js +3 -3
  177. package/web-dist/components/matrix-grid/MatrixGrid.js +1 -1
  178. package/web-dist/components/media-card/MediaCard.js +5 -5
  179. package/web-dist/components/media-explorer/MediaExplorer.js +4 -4
  180. package/web-dist/components/message-bubble/MessageBubble.js +3 -3
  181. package/web-dist/components/metric-card/MetricCard.js +5 -5
  182. package/web-dist/components/missing-element/MissingElement.js +1 -1
  183. package/web-dist/components/o-auth-provider-button/OAuthProviderButton.js +6 -6
  184. package/web-dist/components/o-auth-provider-list/OAuthProviderList.js +6 -6
  185. package/web-dist/components/otp-form/OtpForm.js +6 -6
  186. package/web-dist/components/pagination/Pagination.js +2 -2
  187. package/web-dist/components/palette-item/PaletteItem.js +5 -5
  188. package/web-dist/components/popover-menu/PopoverMenu.js +2 -2
  189. package/web-dist/components/post-card/PostCard.js +5 -5
  190. package/web-dist/components/product-card/ProductCard.js +5 -5
  191. package/web-dist/components/progress/Progress.js +2 -2
  192. package/web-dist/components/progress-ring/ProgressRing.js +2 -2
  193. package/web-dist/components/radio/Radio.js +2 -2
  194. package/web-dist/components/radio-group/RadioGroup.js +2 -2
  195. package/web-dist/components/rating/Rating.js +1 -1
  196. package/web-dist/components/reader/Reader.js +6 -6
  197. package/web-dist/components/scan-overlay/ScanOverlay.js +6 -6
  198. package/web-dist/components/screen/Screen.js +1 -1
  199. package/web-dist/components/screen-section/ScreenSection.js +2 -2
  200. package/web-dist/components/scroll-view/ScrollView.js +1 -1
  201. package/web-dist/components/search-input/SearchInput.js +3 -3
  202. package/web-dist/components/section-header/SectionHeader.js +2 -2
  203. package/web-dist/components/section-list/SectionList.js +3 -3
  204. package/web-dist/components/select/Select.js +2 -2
  205. package/web-dist/components/sign-in-form/SignInForm.js +6 -6
  206. package/web-dist/components/sign-up-form/SignUpForm.js +6 -6
  207. package/web-dist/components/skeleton/Skeleton.js +8 -8
  208. package/web-dist/components/skeleton-card/SkeletonCard.js +8 -8
  209. package/web-dist/components/skeleton-list/SkeletonList.js +8 -8
  210. package/web-dist/components/skeleton-text/SkeletonText.js +8 -8
  211. package/web-dist/components/spatial-grid/SpatialGrid.js +1 -1
  212. package/web-dist/components/text-input/TextInput.js +3 -3
  213. package/web-dist/components/tile-grid/TileGrid.js +1 -1
  214. package/web-dist/components/time-grid/TimeGrid.js +1 -1
  215. package/web-dist/components/time-picker/TimePicker.js +2 -2
  216. package/web-dist/components/timeline/Timeline.js +1 -1
  217. package/web-dist/components/toolbar/Toolbar.js +5 -5
  218. package/web-dist/components/tree-item/TreeItem.js +4 -4
  219. package/web-dist/components/uploader/Uploader.js +9 -9
  220. package/web-dist/components/view/View.js +1 -1
  221. package/web-dist/manifest.json +326 -326
@@ -0,0 +1,265 @@
1
+ // Drives the native GridInteractions adapter and the real ZORA ScrollView through
2
+ // react-native-web's responder system in a private happy-dom window. The module mocks are
3
+ // process-wide in Bun, so this file relies on the repository's `bun test --isolate` script;
4
+ // DOM globals are restored in afterAll.
5
+ import { afterAll, expect, mock, 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 { GridInteractionIntent } from '../../../../types/grid-interactions';
11
+
12
+ const browser = new Window();
13
+ const globalKeys = [
14
+ 'window',
15
+ 'document',
16
+ 'Node',
17
+ 'navigator',
18
+ 'ShadowRoot',
19
+ 'IS_REACT_ACT_ENVIRONMENT',
20
+ ] as const;
21
+ const previousGlobals = globalKeys.map(
22
+ (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
23
+ );
24
+ Object.assign(globalThis, {
25
+ IS_REACT_ACT_ENVIRONMENT: true,
26
+ Node: browser.Node,
27
+ ShadowRoot: browser.ShadowRoot,
28
+ document: browser.document,
29
+ navigator: browser.navigator,
30
+ window: browser,
31
+ });
32
+
33
+ const reactNativeWeb = await import('react-native-web');
34
+ await mock.module('react-native', () => reactNativeWeb);
35
+ // The layout feature only needs these Surface primitives; the real package would load
36
+ // native-only peers, so DOM-capable react-native-web primitives stand in for them.
37
+ await mock.module('@ankhorage/surface', () => ({
38
+ Divider: reactNativeWeb.View,
39
+ Grid: reactNativeWeb.View,
40
+ ScrollView: reactNativeWeb.ScrollView,
41
+ ThemeScope: ({ children }: { readonly children: React.ReactNode }) => children,
42
+ View: reactNativeWeb.View,
43
+ useTheme: () => ({}),
44
+ }));
45
+ const { ScrollView } = await import('../../../layout/public');
46
+ const { GridInteractions } = await import('./GridInteractions');
47
+ const defaultScrollLockModule = await import('./useGridInteractionScrollLock');
48
+ const nativeScrollLockModule = await import('./useGridInteractionScrollLock.native');
49
+
50
+ afterAll(() => {
51
+ browser.close();
52
+ for (const [key, descriptor] of previousGlobals) {
53
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
54
+ else Reflect.deleteProperty(globalThis, key);
55
+ }
56
+ });
57
+
58
+ const viewport = {
59
+ height: 100,
60
+ offsetX: 0,
61
+ offsetY: 0,
62
+ pixelsPerUnitX: 2,
63
+ pixelsPerUnitY: 4,
64
+ width: 100,
65
+ };
66
+ const items = [{ height: 2, id: 'a', width: 3, x: 1, y: 1 }];
67
+
68
+ type TouchType = 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel';
69
+ interface PageOptions {
70
+ readonly interactionPolicy?: 'passive';
71
+ readonly interactions?: boolean;
72
+ readonly nested?: boolean;
73
+ }
74
+ interface Page {
75
+ readonly intents: GridInteractionIntent[];
76
+ readonly render: (options: PageOptions) => Promise<void>;
77
+ readonly scroll: (testID: string) => Promise<boolean>;
78
+ readonly startDrag: () => Promise<void>;
79
+ readonly touch: (type: TouchType, pageY: number) => Promise<void>;
80
+ readonly unmount: () => Promise<void>;
81
+ }
82
+
83
+ /*** Mounts ScrollView(s) around a GridInteractions surface and exposes observable page input. */
84
+ function mountPage(): Page {
85
+ const host = document.createElement('div');
86
+ document.body.append(host);
87
+ const root = createRoot(host);
88
+ const intents: GridInteractionIntent[] = [];
89
+ const scrolls: string[] = [];
90
+ const track = (id: string) => () => scrolls.push(id);
91
+ const touch: Page['touch'] = async (type, pageY) => {
92
+ const target = host.querySelector<HTMLElement>('[data-testid="surface"]');
93
+ if (!target) throw new Error('Missing interaction surface');
94
+ const point = { clientX: 5, clientY: pageY, identifier: 0, pageX: 5, pageY, target };
95
+ const event = new browser.Event(type, { bubbles: true, cancelable: true });
96
+ const active = type === 'touchend' || type === 'touchcancel' ? [] : [point];
97
+ Object.defineProperty(event, 'touches', { value: active });
98
+ Object.defineProperty(event, 'changedTouches', { value: [point] });
99
+ await act(() => Promise.resolve().then(() => target.dispatchEvent(event)));
100
+ };
101
+ return {
102
+ intents,
103
+ render: ({ interactionPolicy, interactions = true, nested = false }) => {
104
+ const surface = interactions ? (
105
+ <GridInteractions
106
+ interactionPolicy={interactionPolicy}
107
+ items={items}
108
+ onIntent={(intent) => intents.push(intent)}
109
+ selectedIds={['a']}
110
+ testID="surface"
111
+ viewport={viewport}
112
+ >
113
+ <span>slate</span>
114
+ </GridInteractions>
115
+ ) : null;
116
+ const page = (
117
+ <ScrollView testID="page" onWheel={track('page')}>
118
+ {nested ? (
119
+ <ScrollView testID="panel" onWheel={track('panel')}>
120
+ {surface}
121
+ </ScrollView>
122
+ ) : (
123
+ surface
124
+ )}
125
+ </ScrollView>
126
+ );
127
+ return act(() => Promise.resolve().then(() => root.render(page)));
128
+ },
129
+ // A disabled react-native-web ScrollView stops delivering wheel input to its handlers.
130
+ scroll: async (testID) => {
131
+ const target = host.querySelector<HTMLElement>(`[data-testid="${testID}"]`);
132
+ if (!target) throw new Error(`Missing ${testID}`);
133
+ const before = scrolls.length;
134
+ await act(() =>
135
+ Promise.resolve().then(() =>
136
+ target.dispatchEvent(new browser.Event('wheel', { bubbles: true })),
137
+ ),
138
+ );
139
+ return scrolls.slice(before).includes(testID);
140
+ },
141
+ // Moving after the start only emits intents once the responder system granted ownership.
142
+ startDrag: async () => {
143
+ await touch('touchstart', 6);
144
+ await touch('touchmove', 30);
145
+ expect(intents.length).toBeGreaterThan(0);
146
+ },
147
+ touch,
148
+ unmount: async () => {
149
+ await act(() => Promise.resolve().then(() => root.unmount()));
150
+ host.remove();
151
+ },
152
+ };
153
+ }
154
+
155
+ test('exercises the native scroll-lock hook rather than the web no-op adapter', () => {
156
+ expect(defaultScrollLockModule.useGridInteractionScrollLock).toBe(
157
+ nativeScrollLockModule.useGridInteractionScrollLock,
158
+ );
159
+ });
160
+
161
+ test('suspends the page scroll for the whole drag and restores it on release', async () => {
162
+ const page = mountPage();
163
+ try {
164
+ await page.render({});
165
+ expect(await page.scroll('page')).toBe(true);
166
+
167
+ await page.startDrag();
168
+ expect(await page.scroll('page')).toBe(false);
169
+
170
+ await page.touch('touchend', 30);
171
+ expect(await page.scroll('page')).toBe(true);
172
+ } finally {
173
+ await page.unmount();
174
+ }
175
+ });
176
+
177
+ test('restores the page scroll and ends ownership when the responder gesture is cancelled', async () => {
178
+ const page = mountPage();
179
+ try {
180
+ await page.render({});
181
+ await page.startDrag();
182
+ expect(await page.scroll('page')).toBe(false);
183
+ const intentsBeforeCancel = page.intents.length;
184
+
185
+ await page.touch('touchcancel', 30);
186
+ expect(await page.scroll('page')).toBe(true);
187
+ await page.touch('touchmove', 60);
188
+ expect(page.intents).toHaveLength(intentsBeforeCancel);
189
+ } finally {
190
+ await page.unmount();
191
+ }
192
+ });
193
+
194
+ test('suspends both nested ScrollViews during a drag and restores both together', async () => {
195
+ const page = mountPage();
196
+ try {
197
+ await page.render({ nested: true });
198
+
199
+ await page.startDrag();
200
+ expect(await page.scroll('panel')).toBe(false);
201
+ expect(await page.scroll('page')).toBe(false);
202
+
203
+ await page.touch('touchend', 30);
204
+ expect(await page.scroll('panel')).toBe(true);
205
+ expect(await page.scroll('page')).toBe(true);
206
+ } finally {
207
+ await page.unmount();
208
+ }
209
+ });
210
+
211
+ test('releases the scroll lock when the surface unmounts mid-drag', async () => {
212
+ const page = mountPage();
213
+ try {
214
+ await page.render({});
215
+ await page.startDrag();
216
+ expect(await page.scroll('page')).toBe(false);
217
+
218
+ await page.render({ interactions: false });
219
+ expect(await page.scroll('page')).toBe(true);
220
+ } finally {
221
+ await page.unmount();
222
+ }
223
+ });
224
+
225
+ test('never takes ownership or the page scroll while the interaction policy is passive', async () => {
226
+ const page = mountPage();
227
+ try {
228
+ await page.render({ interactionPolicy: 'passive' });
229
+
230
+ await page.touch('touchstart', 6);
231
+ await page.touch('touchmove', 30);
232
+ expect(page.intents).toEqual([]);
233
+ expect(await page.scroll('page')).toBe(true);
234
+ await page.touch('touchend', 30);
235
+ expect(await page.scroll('page')).toBe(true);
236
+ } finally {
237
+ await page.unmount();
238
+ }
239
+ });
240
+
241
+ test('leaves no lock behind when the policy turns passive mid-drag, and locks again once active', async () => {
242
+ const page = mountPage();
243
+ try {
244
+ await page.render({});
245
+ await page.startDrag();
246
+ expect(await page.scroll('page')).toBe(false);
247
+
248
+ await page.render({ interactionPolicy: 'passive' });
249
+ await page.touch('touchend', 30);
250
+ expect(await page.scroll('page')).toBe(true);
251
+
252
+ await page.render({});
253
+ const intentsBeforeSecondDrag = page.intents.length;
254
+ await page.touch('touchstart', 6);
255
+ // A different distance, because the controller drops an intent identical to the previous one.
256
+ await page.touch('touchmove', 40);
257
+ expect(page.intents.length).toBeGreaterThan(intentsBeforeSecondDrag);
258
+ expect(await page.scroll('page')).toBe(false);
259
+
260
+ await page.touch('touchend', 30);
261
+ expect(await page.scroll('page')).toBe(true);
262
+ } finally {
263
+ await page.unmount();
264
+ }
265
+ });
@@ -209,6 +209,70 @@ test('clears the web Space modifier on blur, disable, and unmount', async () =>
209
209
  }
210
210
  });
211
211
 
212
+ test('keeps an active RNW drag when an ancestor scroll container scrolls', async () => {
213
+ const browser = new Window();
214
+ const restore = installBrowserGlobals(browser);
215
+ const host = document.createElement('div');
216
+ document.body.append(host);
217
+ const root = createRoot(host);
218
+ const { GridInteractions } = await loadGridInteractions();
219
+ const intents: unknown[] = [];
220
+
221
+ try {
222
+ await renderGridInteractions(
223
+ root,
224
+ <GridInteractions
225
+ items={[{ height: 2, id: 'a', width: 3, x: 1, y: 1 }]}
226
+ onIntent={(intent) => intents.push(intent)}
227
+ selectedIds={['a']}
228
+ testID="grid-interactions"
229
+ viewport={{
230
+ height: 100,
231
+ offsetX: 0,
232
+ offsetY: 0,
233
+ pixelsPerUnitX: 2,
234
+ pixelsPerUnitY: 4,
235
+ width: 100,
236
+ }}
237
+ >
238
+ <span>nested target</span>
239
+ </GridInteractions>,
240
+ );
241
+ const surface = host.querySelector<HTMLElement>('[data-testid="grid-interactions"]');
242
+ if (!surface) throw new Error('Missing interaction surface');
243
+
244
+ await dispatchTouch(browser, surface, 'touchstart', 6);
245
+ await dispatchTouch(browser, surface, 'touchmove', 30);
246
+ const intentsBeforeScroll = intents.length;
247
+ expect(intentsBeforeScroll).toBeGreaterThan(0);
248
+
249
+ // A scrolling ancestor must not take the responder from an active drag.
250
+ await act(() => Promise.resolve().then(() => host.dispatchEvent(new browser.Event('scroll'))));
251
+ await dispatchTouch(browser, surface, 'touchmove', 60);
252
+
253
+ expect(intents.length).toBeGreaterThan(intentsBeforeScroll);
254
+ } finally {
255
+ await act(() => Promise.resolve().then(() => root.unmount()));
256
+ host.remove();
257
+ browser.close();
258
+ restore();
259
+ }
260
+ });
261
+
262
+ /*** Dispatches one single-finger touch at a fixed horizontal position on the interaction surface. */
263
+ function dispatchTouch(
264
+ browser: Window,
265
+ surface: HTMLElement,
266
+ type: 'touchstart' | 'touchmove',
267
+ pageY: number,
268
+ ) {
269
+ const touch = { clientX: 5, clientY: pageY, identifier: 0, pageX: 5, pageY, target: surface };
270
+ const event = new browser.Event(type, { bubbles: true, cancelable: true });
271
+ Object.defineProperty(event, 'touches', { value: [touch] });
272
+ Object.defineProperty(event, 'changedTouches', { value: [touch] });
273
+ return dispatch(surface, event);
274
+ }
275
+
212
276
  /*** Installs the DOM globals that the independently bundled RNW adapter requires. */
213
277
  function installBrowserGlobals(browser: Window): () => void {
214
278
  const keys = [
@@ -9,6 +9,7 @@ import { createGridInteractionsController } from '../../application/createGridIn
9
9
  import { createGridInteractionsPointerQueue } from './createGridInteractionsPointerQueue';
10
10
  import { GridInteractionsKeyboardProxy } from './GridInteractionsKeyboardProxy';
11
11
  import { resolveGridInteractionPointer } from './resolveGridInteractionPointer';
12
+ import { useGridInteractionScrollLock } from './useGridInteractionScrollLock';
12
13
 
13
14
  /*** Adapts native responder and keyboard events to the controlled grid interaction boundary. */
14
15
  export function GridInteractions({
@@ -28,6 +29,7 @@ export function GridInteractions({
28
29
  const surfaceRef = React.useRef<View>(null);
29
30
  const spaceKeyRef = React.useRef(false);
30
31
  const [pointerQueue] = React.useState(() => createGridInteractionsPointerQueue());
32
+ const scrollLock = useGridInteractionScrollLock();
31
33
  React.useLayoutEffect(() => controller.update(controllerProps), [controller, controllerProps]);
32
34
  React.useLayoutEffect(() => {
33
35
  pointerQueue.activate();
@@ -61,16 +63,22 @@ export function GridInteractions({
61
63
  accessibilityRole="adjustable"
62
64
  ref={surfaceRef}
63
65
  testID={testID}
64
- onResponderGrant={(event) =>
65
- withPointer(event, (pointer) => controller.begin(pointer, resizeHandle))
66
- }
66
+ onResponderGrant={(event) => {
67
+ scrollLock.acquire();
68
+ withPointer(event, (pointer) => controller.begin(pointer, resizeHandle));
69
+ }}
67
70
  onResponderMove={(event) => withPointer(event, controller.move)}
68
- onResponderRelease={(event) => withPointer(event, controller.end)}
71
+ onResponderRelease={(event) => {
72
+ scrollLock.release();
73
+ withPointer(event, controller.end);
74
+ }}
69
75
  onResponderTerminate={() => {
76
+ scrollLock.release();
70
77
  setSpaceKey(false);
71
78
  pointerQueue.cancel();
72
79
  controller.cancel();
73
80
  }}
81
+ onResponderTerminationRequest={() => false}
74
82
  onStartShouldSetResponder={() => enabled}
75
83
  >
76
84
  <GridInteractionsKeyboardProxy
@@ -0,0 +1,17 @@
1
+ import React from 'react';
2
+
3
+ import type { GridInteractionScrollLock } from '../../../../types/grid-interactions';
4
+ import { useScrollGestureLock } from '../../../layout/public';
5
+ import { createGridInteractionScrollLock } from '../../application/createGridInteractionScrollLock';
6
+
7
+ /*** Keeps ancestor ZORA scrolling suspended while one native responder gesture owns the surface. */
8
+ export function useGridInteractionScrollLock(): GridInteractionScrollLock {
9
+ const scrollLock = useScrollGestureLock();
10
+ const gestureLock = React.useMemo(
11
+ () => createGridInteractionScrollLock(scrollLock),
12
+ [scrollLock],
13
+ );
14
+ React.useEffect(() => gestureLock.release, [gestureLock]);
15
+
16
+ return gestureLock;
17
+ }
@@ -0,0 +1 @@
1
+ export { useGridInteractionScrollLock } from './useGridInteractionScrollLock.native';
@@ -0,0 +1,11 @@
1
+ import type { GridInteractionScrollLock } from '../../../../types/grid-interactions';
2
+
3
+ const webScrollLock: GridInteractionScrollLock = {
4
+ acquire: () => undefined,
5
+ release: () => undefined,
6
+ };
7
+
8
+ /*** Leaves browser scrolling to the RNW responder system, which already honors termination requests. */
9
+ export function useGridInteractionScrollLock(): GridInteractionScrollLock {
10
+ return webScrollLock;
11
+ }
@@ -0,0 +1,41 @@
1
+ import { expect, test } from 'bun:test';
2
+
3
+ import type { ScrollGestureLock } from '../../../types/layout';
4
+ import { createGridInteractionScrollLock } from './createGridInteractionScrollLock';
5
+
6
+ /*** Counts held and released ancestor scroll locks. */
7
+ function createCountingScrollLock() {
8
+ const state = { acquired: 0, released: 0 };
9
+ const lock: ScrollGestureLock = {
10
+ acquire: () => {
11
+ state.acquired += 1;
12
+ return () => {
13
+ state.released += 1;
14
+ };
15
+ },
16
+ };
17
+ return { lock, state };
18
+ }
19
+
20
+ test('takes the ancestor scroll lock once per gesture even when grant repeats', () => {
21
+ const { lock, state } = createCountingScrollLock();
22
+ const gesture = createGridInteractionScrollLock(lock);
23
+
24
+ gesture.acquire();
25
+ gesture.acquire();
26
+
27
+ expect(state).toEqual({ acquired: 1, released: 0 });
28
+ });
29
+
30
+ test('releases on release or termination and can lock again for the next gesture', () => {
31
+ const { lock, state } = createCountingScrollLock();
32
+ const gesture = createGridInteractionScrollLock(lock);
33
+
34
+ gesture.acquire();
35
+ gesture.release();
36
+ gesture.release();
37
+ gesture.acquire();
38
+ gesture.release();
39
+
40
+ expect(state).toEqual({ acquired: 2, released: 2 });
41
+ });
@@ -0,0 +1,18 @@
1
+ import type { GridInteractionScrollLock } from '../../../types/grid-interactions';
2
+ import type { ScrollGestureLock } from '../../../types/layout';
3
+
4
+ /*** Holds at most one ancestor scroll lock for the active responder gesture of an interaction surface. */
5
+ export function createGridInteractionScrollLock(
6
+ scrollLock: ScrollGestureLock,
7
+ ): GridInteractionScrollLock {
8
+ let releaseScrollLock: (() => void) | undefined;
9
+ return {
10
+ acquire: () => {
11
+ releaseScrollLock ??= scrollLock.acquire();
12
+ },
13
+ release: () => {
14
+ releaseScrollLock?.();
15
+ releaseScrollLock = undefined;
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,186 @@
1
+ import type { GridRectItem, GridViewport } from '@ankhorage/grid-view';
2
+ import { afterAll, afterEach, beforeEach, expect, jest, mock, test } from 'bun:test';
3
+ import { Window } from 'happy-dom';
4
+ import React from 'react';
5
+ import { createRoot } from 'react-dom/client';
6
+
7
+ import type { GridScrollAxis } from '../../../../types/grid-view';
8
+
9
+ type NativeScrollEvent = Readonly<{
10
+ nativeEvent: Readonly<{ contentOffset: { x: number; y: number } }>;
11
+ }>;
12
+ type NativeScrollProps = Readonly<{
13
+ onMomentumScrollEnd?: (event: NativeScrollEvent) => void;
14
+ onScroll?: (event: NativeScrollEvent) => void;
15
+ testID?: string;
16
+ }>;
17
+ type ScrollRequest = Readonly<{ animated?: boolean; x?: number; y?: number }>;
18
+
19
+ const browser = new Window();
20
+ const globalKeys = ['window', 'document', 'Node', 'navigator', 'IS_REACT_ACT_ENVIRONMENT'] as const;
21
+ const previousGlobals = globalKeys.map(
22
+ (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
23
+ );
24
+ Object.assign(globalThis, {
25
+ IS_REACT_ACT_ENVIRONMENT: true,
26
+ Node: browser.Node,
27
+ document: browser.document,
28
+ navigator: browser.navigator,
29
+ window: browser,
30
+ });
31
+
32
+ const scrollHandlers = new Map<string, NativeScrollProps>();
33
+ const nativeRequests: (readonly [GridScrollAxis, number, boolean])[] = [];
34
+ const ScrollView = React.forwardRef<
35
+ { scrollTo: (request: ScrollRequest) => void },
36
+ React.PropsWithChildren<NativeScrollProps>
37
+ >(function ScrollView({ children, onMomentumScrollEnd, onScroll, testID }, ref) {
38
+ React.useEffect(() => {
39
+ if (testID !== undefined) scrollHandlers.set(testID, { onMomentumScrollEnd, onScroll, testID });
40
+ return () => {
41
+ if (testID !== undefined) scrollHandlers.delete(testID);
42
+ };
43
+ }, [onMomentumScrollEnd, onScroll, testID]);
44
+ React.useImperativeHandle(ref, () => ({
45
+ scrollTo: ({ animated = false, x, y }) => {
46
+ if (x !== undefined) nativeRequests.push(['x', x, animated]);
47
+ if (y !== undefined) nativeRequests.push(['y', y, animated]);
48
+ },
49
+ }));
50
+ return <div data-testid={testID}>{children}</div>;
51
+ });
52
+
53
+ const View = React.forwardRef<
54
+ { measureInWindow: (callback: (x: number, y: number) => void) => void },
55
+ React.PropsWithChildren<{ testID?: string }>
56
+ >(function View({ children, testID }, ref) {
57
+ React.useImperativeHandle(ref, () => ({ measureInWindow: (callback) => callback(0, 0) }));
58
+ return <div data-testid={testID}>{children}</div>;
59
+ });
60
+
61
+ await mock.module('react-native', () => ({
62
+ Pressable: ({ children }: React.PropsWithChildren) => <button>{children}</button>,
63
+ ScrollView,
64
+ Text: ({ children }: React.PropsWithChildren) => <span>{children}</span>,
65
+ View,
66
+ }));
67
+
68
+ const { GridView } = await import('./GridView');
69
+
70
+ beforeEach(() => {
71
+ jest.useFakeTimers();
72
+ nativeRequests.length = 0;
73
+ scrollHandlers.clear();
74
+ });
75
+
76
+ afterEach(() => {
77
+ jest.useRealTimers();
78
+ });
79
+
80
+ afterAll(() => {
81
+ browser.close();
82
+ for (const [key, descriptor] of previousGlobals) {
83
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
84
+ else Reflect.deleteProperty(globalThis, key);
85
+ }
86
+ });
87
+
88
+ test('reconciles the published viewport with late native animation frames after watchdog fallback', async () => {
89
+ const host = document.createElement('div');
90
+ document.body.append(host);
91
+ const root = createRoot(host);
92
+ const published: GridViewport[] = [];
93
+ const physicalOffsets: number[] = [];
94
+ const items: readonly GridRectItem[] = [{ height: 20, id: 'far', width: 20, x: 10, y: 272 }];
95
+
96
+ function Consumer() {
97
+ return (
98
+ <GridView
99
+ contentHeight={1000}
100
+ contentWidth={1000}
101
+ focusedItemId="far"
102
+ height={100}
103
+ items={items}
104
+ onViewportChange={(next) => {
105
+ published.push(next);
106
+ }}
107
+ renderItem={() => null}
108
+ testID="grid"
109
+ viewportConstraints={{ world: { height: 1000, width: 1000, x: 0, y: 0 } }}
110
+ width={100}
111
+ />
112
+ );
113
+ }
114
+
115
+ try {
116
+ await React.act(() =>
117
+ Promise.resolve().then(() => {
118
+ root.render(<Consumer />);
119
+ }),
120
+ );
121
+ expect(nativeRequests).toEqual([['y', 200, true]]);
122
+ expect(published.map((viewport) => viewport.offsetY)).toEqual([200]);
123
+
124
+ await dispatchNativeScrollAsync('grid-vertical-scroll', 40, physicalOffsets);
125
+ await React.act(() =>
126
+ Promise.resolve().then(() => {
127
+ jest.advanceTimersByTime(700);
128
+ }),
129
+ );
130
+ expect(published.map((viewport) => viewport.offsetY)).toEqual([200, 40]);
131
+
132
+ await dispatchNativeScrollAsync('grid-vertical-scroll', 100, physicalOffsets);
133
+ await dispatchNativeScrollAsync('grid-vertical-scroll', 200, physicalOffsets);
134
+ await dispatchNativeScrollEndAsync('grid-vertical-scroll', 200, physicalOffsets);
135
+
136
+ expect(physicalOffsets).toEqual([40, 100, 200, 200]);
137
+ expect(published.map((viewport) => viewport.offsetY)).toEqual([200, 40, 100, 200]);
138
+ expect(published.at(-1)?.offsetY).toBe(physicalOffsets.at(-1));
139
+ expect(nativeRequests).toEqual([['y', 200, true]]);
140
+ expect(jest.getTimerCount()).toBe(0);
141
+ } finally {
142
+ await React.act(() =>
143
+ Promise.resolve().then(() => {
144
+ root.unmount();
145
+ }),
146
+ );
147
+ host.remove();
148
+ }
149
+ });
150
+
151
+ /*** Delivers one physical native scroll frame through the mounted GridView boundary. */
152
+ async function dispatchNativeScrollAsync(
153
+ testID: string,
154
+ offset: number,
155
+ physicalOffsets: number[],
156
+ ): Promise<void> {
157
+ const handler = scrollHandlers.get(testID)?.onScroll;
158
+ if (!handler) throw new Error(`Missing native scroll handler for ${testID}.`);
159
+ physicalOffsets.push(offset);
160
+ await React.act(() =>
161
+ Promise.resolve().then(() => {
162
+ handler(createNativeScrollEvent(offset));
163
+ }),
164
+ );
165
+ }
166
+
167
+ /*** Delivers the native momentum-end signal after the final physical offset is observed. */
168
+ async function dispatchNativeScrollEndAsync(
169
+ testID: string,
170
+ offset: number,
171
+ physicalOffsets: number[],
172
+ ): Promise<void> {
173
+ const handler = scrollHandlers.get(testID)?.onMomentumScrollEnd;
174
+ if (!handler) throw new Error(`Missing native scroll-end handler for ${testID}.`);
175
+ physicalOffsets.push(offset);
176
+ await React.act(() =>
177
+ Promise.resolve().then(() => {
178
+ handler(createNativeScrollEvent(offset));
179
+ }),
180
+ );
181
+ }
182
+
183
+ /*** Builds the native event shape GridView receives from either scroll axis. */
184
+ function createNativeScrollEvent(offset: number): NativeScrollEvent {
185
+ return { nativeEvent: { contentOffset: { x: 0, y: offset } } };
186
+ }