@onekeyfe/react-native-pager-view 6.7.0-1 → 6.7.0-3

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 (38) hide show
  1. package/README.md +9 -0
  2. package/android/src/main/java/com/reactnativepagerview/CollapsiblePagerViewManager.kt +2 -2
  3. package/android/src/main/java/com/reactnativepagerview/NativeScrollerViewManager.kt +2 -2
  4. package/docs/SPEC.md +77 -4
  5. package/ios/RNCCollapsiblePagerViewComponentView.mm +84 -21
  6. package/ios/RNCNativeScrollerComponentView.mm +6 -5
  7. package/ios/tests/RNCCollapsiblePagerHitTestTests.mm +26 -1
  8. package/ios/tests/RNCNativeScrollerTests.mm +39 -0
  9. package/lib/module/CollapsiblePagerView.js +4 -4
  10. package/lib/module/CollapsiblePagerView.web.js +428 -37
  11. package/lib/module/CollapsiblePagerViewNativeComponent.ts +1 -1
  12. package/lib/module/NativeScroller.js +2 -2
  13. package/lib/module/NativeScroller.web.js +16 -3
  14. package/lib/module/NativeScrollerNativeComponent.ts +1 -1
  15. package/lib/module/ScrollEdgeEffect.js +8 -0
  16. package/lib/module/WebCollapsiblePagerPage.js +182 -0
  17. package/lib/module/index.js +3 -0
  18. package/lib/module/index.web.js +3 -0
  19. package/lib/typescript/src/CollapsiblePagerView.d.ts +4 -3
  20. package/lib/typescript/src/CollapsiblePagerView.web.d.ts +66 -4
  21. package/lib/typescript/src/CollapsiblePagerViewNativeComponent.d.ts +1 -1
  22. package/lib/typescript/src/NativeScroller.d.ts +3 -2
  23. package/lib/typescript/src/NativeScrollerNativeComponent.d.ts +1 -1
  24. package/lib/typescript/src/ScrollEdgeEffect.d.ts +5 -0
  25. package/lib/typescript/src/WebCollapsiblePagerPage.d.ts +42 -0
  26. package/lib/typescript/src/index.d.ts +5 -0
  27. package/lib/typescript/src/index.web.d.ts +5 -0
  28. package/package.json +2 -2
  29. package/src/CollapsiblePagerView.tsx +12 -7
  30. package/src/CollapsiblePagerView.web.tsx +640 -50
  31. package/src/CollapsiblePagerViewNativeComponent.ts +1 -1
  32. package/src/NativeScroller.tsx +6 -4
  33. package/src/NativeScroller.web.tsx +24 -2
  34. package/src/NativeScrollerNativeComponent.ts +1 -1
  35. package/src/ScrollEdgeEffect.ts +4 -0
  36. package/src/WebCollapsiblePagerPage.tsx +258 -0
  37. package/src/index.tsx +6 -0
  38. package/src/index.web.tsx +6 -0
@@ -1,7 +1,11 @@
1
+ import type { ScrollEdgeEffect } from "./ScrollEdgeEffect";
1
2
  import React from "react";
2
- import { I18nManager, StyleSheet, View } from "react-native";
3
+ import { I18nManager, Platform, StyleSheet, View } from "react-native";
3
4
  import type * as ReactNative from "react-native";
4
5
 
6
+ import { WebCollapsiblePagerPage } from "./WebCollapsiblePagerPage";
7
+ import type { WebCollapsiblePagerPageBridge } from "./WebCollapsiblePagerPage";
8
+
5
9
  export type OnPageScrollEventData = Readonly<{
6
10
  position: number;
7
11
  offset: number;
@@ -64,10 +68,11 @@ export interface CollapsiblePagerViewProps
64
68
  > {
65
69
  header: React.ReactNode;
66
70
  stickyHeader: React.ReactNode;
67
- headerHeight: number;
68
- stickyHeaderHeight: number;
71
+ headerHeight?: number;
72
+ stickyHeaderHeight?: number;
73
+ isActive?: boolean;
69
74
  topOverlay?: React.ReactNode;
70
- topScrollEdgeEffect?: "hard";
75
+ topScrollEdgeEffect?: `${ScrollEdgeEffect}`;
71
76
  topOverlayColorScheme?: "light" | "dark";
72
77
  externalTopOverlayTag?: number;
73
78
  pageRetentionDistance?: number;
@@ -82,7 +87,9 @@ type State = {
82
87
  };
83
88
 
84
89
  type ConfiguredScrollElement = Readonly<{
90
+ key: string;
85
91
  element: HTMLElement;
92
+ paddingTarget: HTMLElement;
86
93
  rangeSpacer: HTMLElement;
87
94
  position: string;
88
95
  paddingTop: string;
@@ -94,6 +101,14 @@ type ConfiguredScrollElement = Readonly<{
94
101
  pagerStickyInset: string;
95
102
  }>;
96
103
 
104
+ type WebRouteTarget = Readonly<{
105
+ key: string;
106
+ identity: string;
107
+ owner: HTMLElement;
108
+ generation: number;
109
+ top: number;
110
+ }>;
111
+
97
112
  const COLLAPSE_ANIMATION_NAME = "ok-collapsible-pager-collapse";
98
113
  const COLLAPSE_STYLE_ID = "ok-collapsible-pager-styles";
99
114
  const PAGER_HEADER_INSET = "--ok-collapsible-pager-header-inset";
@@ -123,6 +138,14 @@ const timelineStyle = (element: HTMLElement) =>
123
138
  scrollTimelineAxis: string;
124
139
  };
125
140
 
141
+ function scrollElementTo(element: HTMLElement, options: ScrollToOptions) {
142
+ // RN Web decorates ScrollView nodes with a React Native scrollTo signature.
143
+ const scrollTo: (options: ScrollToOptions) => void =
144
+ element.ownerDocument.defaultView?.Element.prototype.scrollTo ??
145
+ element.scrollTo;
146
+ scrollTo.call(element, options);
147
+ }
148
+
126
149
  function ensureCollapseAnimation(document: Document) {
127
150
  if (document.getElementById(COLLAPSE_STYLE_ID)) return;
128
151
  const style = document.createElement("style");
@@ -131,6 +154,19 @@ function ensureCollapseAnimation(document: Document) {
131
154
  document.head.appendChild(style);
132
155
  }
133
156
 
157
+ function supportsScrollTimeline(document: Document) {
158
+ const css = document.defaultView?.CSS;
159
+ return !!(
160
+ typeof css?.supports === "function" &&
161
+ css.supports("animation-timeline", "--ok-test") &&
162
+ css.supports("timeline-scope", "--ok-test") &&
163
+ css.supports("scroll-timeline-name", "--ok-test") &&
164
+ css.supports("scroll-timeline-axis", "block") &&
165
+ css.supports("animation-range", "0px 1px") &&
166
+ css.supports("animation-duration", "auto")
167
+ );
168
+ }
169
+
134
170
  const pageKey = (child: React.ReactNode, index: number) => {
135
171
  if (React.isValidElement(child) && child.key != null)
136
172
  return String(child.key);
@@ -152,11 +188,39 @@ export class CollapsiblePagerView extends React.PureComponent<
152
188
 
153
189
  private root: HTMLElement | null = null;
154
190
  private track: HTMLElement | null = null;
191
+ private headerSlot: HTMLElement | null = null;
192
+ private stickySlot: HTMLElement | null = null;
193
+ private cssTimelineSupported = false;
194
+ private overlayTouch: {
195
+ owner: HTMLElement;
196
+ target: HTMLElement;
197
+ x: number;
198
+ y: number;
199
+ lastY: number;
200
+ } | null = null;
155
201
  private touchStartX: number | null = null;
156
202
  private touchStartY: number | null = null;
157
203
  private horizontalTouch = false;
158
204
  private pageOffsets = new Map<string, number>();
159
205
  private pageHosts = new Map<number, HTMLElement>();
206
+ private pageScrollIdentities = new Map<string, string>();
207
+ private pageBridges = new Map<string, WebCollapsiblePagerPageBridge>();
208
+ private ownerRecords = new Map<
209
+ string,
210
+ { element: HTMLElement; identity: string; generation: number }
211
+ >();
212
+ private ownerGeneration = 0;
213
+ private lastRawTops = new Map<string, number>();
214
+ private routeTarget: WebRouteTarget | null = null;
215
+ private measuredHeaderHeight = 0;
216
+ private measuredStickyHeight = 0;
217
+ private headerMeasured = false;
218
+ private stickyMeasured = false;
219
+ private headerContent: HTMLElement | null = null;
220
+ private stickyContent: HTMLElement | null = null;
221
+ private chromeObserver: ResizeObserver | null = null;
222
+ private chromeMeasureFrame: number | null = null;
223
+ private disposed = false;
160
224
  private sharedHeaderOffset = 0;
161
225
  private selectionRestoreToken = 0;
162
226
  private bodyAlignment: Readonly<{
@@ -177,14 +241,79 @@ export class CollapsiblePagerView extends React.PureComponent<
177
241
  private configuredScrollElement: ConfiguredScrollElement | null = null;
178
242
  private readonly timelineName = `--ok-collapsible-pager-${++collapsiblePagerWebInstance}`;
179
243
 
244
+ private get headerHeight() {
245
+ return this.hasExplicitHeaderHeight
246
+ ? (this.props.headerHeight ?? this.measuredHeaderHeight)
247
+ : this.measuredHeaderHeight;
248
+ }
249
+
250
+ private get hasExplicitHeaderHeight() {
251
+ return this.props.headerHeight !== undefined &&
252
+ Number.isFinite(this.props.headerHeight) &&
253
+ this.props.headerHeight >= 0;
254
+ }
255
+
256
+ private get stickyHeaderHeight() {
257
+ return this.hasExplicitStickyHeaderHeight
258
+ ? (this.props.stickyHeaderHeight ?? this.measuredStickyHeight)
259
+ : this.measuredStickyHeight;
260
+ }
261
+
262
+ private get hasExplicitStickyHeaderHeight() {
263
+ return this.props.stickyHeaderHeight !== undefined &&
264
+ Number.isFinite(this.props.stickyHeaderHeight) &&
265
+ this.props.stickyHeaderHeight >= 0;
266
+ }
267
+
268
+ getSnapshotBeforeUpdate(previousProps: CollapsiblePagerViewProps) {
269
+ if (previousProps.isActive !== false && this.props.isActive === false)
270
+ return this.captureRouteTarget(previousProps);
271
+ return null;
272
+ }
273
+
180
274
  componentDidMount() {
181
- if (this.root) ensureCollapseAnimation(this.root.ownerDocument);
275
+ this.disposed = false;
276
+ if (Platform.OS !== "web" || typeof document === "undefined")
277
+ throw new Error("WebCollapsiblePagerView requires a Web DOM runtime");
278
+ if (!this.hasExplicitHeaderHeight) this.headerMeasured = false;
279
+ if (!this.hasExplicitStickyHeaderHeight) this.stickyMeasured = false;
280
+ this.observeChrome();
281
+ if (this.root) {
282
+ this.cssTimelineSupported = supportsScrollTimeline(
283
+ this.root.ownerDocument
284
+ );
285
+ if (this.cssTimelineSupported) {
286
+ ensureCollapseAnimation(this.root.ownerDocument);
287
+ this.forceUpdate();
288
+ }
289
+ }
182
290
  this.scheduleScrollConfiguration();
291
+ this.syncFallbackHeader();
183
292
  this.notifyMountedPagesChanged();
184
293
  this.emitDiagnostics("mounted");
185
294
  }
186
295
 
187
- componentDidUpdate(previousProps: CollapsiblePagerViewProps) {
296
+ componentDidUpdate(
297
+ previousProps: CollapsiblePagerViewProps,
298
+ _previousState: State,
299
+ routeSnapshot: WebRouteTarget | null,
300
+ ) {
301
+ if (routeSnapshot) {
302
+ const record = this.ownerRecords.get(routeSnapshot.key);
303
+ if (
304
+ this.pages().map(pageKey).includes(routeSnapshot.key) &&
305
+ record?.element === routeSnapshot.owner &&
306
+ record.identity === routeSnapshot.identity &&
307
+ record.generation === routeSnapshot.generation
308
+ )
309
+ this.setRouteTarget(routeSnapshot);
310
+ }
311
+ if (previousProps.isActive === false && this.props.isActive !== false) {
312
+ if (!this.hasExplicitHeaderHeight) this.headerMeasured = false;
313
+ if (!this.hasExplicitStickyHeaderHeight) this.stickyMeasured = false;
314
+ this.scheduleChromeMeasurement();
315
+ this.restoreScrollPosition();
316
+ }
188
317
  if (previousProps.scrollEnabled !== this.props.scrollEnabled) {
189
318
  this.scrollEnabled = this.props.scrollEnabled ?? true;
190
319
  if (!this.scrollEnabled) {
@@ -206,11 +335,37 @@ export class CollapsiblePagerView extends React.PureComponent<
206
335
  });
207
336
  return;
208
337
  }
338
+ // Child layout effects can run before page-host refs are reattached.
339
+ // Recheck identities in the same commit, before a new user command runs.
340
+ this.mountedPages().forEach((index) =>
341
+ this.synchronizeScrollOwnerIdentity(index)
342
+ );
343
+ const currentKeys = new Set(this.pages().map(pageKey));
344
+ const ownedKeys = new Set([
345
+ ...this.pageScrollIdentities.keys(),
346
+ ...this.pageOffsets.keys(),
347
+ ...this.lastRawTops.keys(),
348
+ ...this.ownerRecords.keys(),
349
+ ]);
350
+ for (const key of ownedKeys) {
351
+ if (!currentKeys.has(key)) {
352
+ this.pageScrollIdentities.delete(key);
353
+ this.pageOffsets.delete(key);
354
+ this.lastRawTops.delete(key);
355
+ this.ownerRecords.delete(key);
356
+ if (this.routeTarget?.key === key) this.setRouteTarget(null);
357
+ }
358
+ }
209
359
  this.scheduleScrollConfiguration();
360
+ this.syncFallbackHeader();
210
361
  this.notifyMountedPagesChanged();
211
362
  }
212
363
 
213
364
  componentWillUnmount() {
365
+ // Activity can disconnect effects before the inactive update commits.
366
+ const routeTarget = this.captureRouteTarget(this.props);
367
+ if (routeTarget) this.setRouteTarget(routeTarget);
368
+ this.disposed = true;
214
369
  this.selectionRestoreToken += 1;
215
370
  this.cancelPendingTopFrame();
216
371
  this.savePageOffset(this.state.selectedPage);
@@ -223,9 +378,196 @@ export class CollapsiblePagerView extends React.PureComponent<
223
378
  this.transitionTimer = null;
224
379
  }
225
380
  this.setTrack(null);
381
+ this.setRoot(null);
382
+ this.chromeObserver?.disconnect();
383
+ this.chromeObserver = null;
384
+ if (this.chromeMeasureFrame !== null) {
385
+ cancelAnimationFrame(this.chromeMeasureFrame);
386
+ this.chromeMeasureFrame = null;
387
+ }
388
+ this.pageBridges.clear();
226
389
  this.restoreConfiguredScrollElement();
227
390
  }
228
391
 
392
+ private setHeaderContent = (node: unknown) => {
393
+ const element = webElement(node);
394
+ if (element === this.headerContent) return;
395
+ if (this.headerContent) this.chromeObserver?.unobserve(this.headerContent);
396
+ this.headerContent = element;
397
+ this.headerMeasured = false;
398
+ if (element) this.chromeObserver?.observe(element);
399
+ this.scheduleChromeMeasurement();
400
+ };
401
+
402
+ private setStickyContent = (node: unknown) => {
403
+ const element = webElement(node);
404
+ if (element === this.stickyContent) return;
405
+ if (this.stickyContent) this.chromeObserver?.unobserve(this.stickyContent);
406
+ this.stickyContent = element;
407
+ this.stickyMeasured = false;
408
+ if (element) this.chromeObserver?.observe(element);
409
+ this.scheduleChromeMeasurement();
410
+ };
411
+
412
+ private observeChrome() {
413
+ this.chromeObserver = new ResizeObserver(this.scheduleChromeMeasurement);
414
+ if (this.headerContent) this.chromeObserver.observe(this.headerContent);
415
+ if (this.stickyContent) this.chromeObserver.observe(this.stickyContent);
416
+ this.scheduleChromeMeasurement();
417
+ }
418
+
419
+ private scheduleChromeMeasurement = () => {
420
+ if (this.disposed || this.chromeMeasureFrame !== null || typeof requestAnimationFrame !== "function")
421
+ return;
422
+ this.chromeMeasureFrame = requestAnimationFrame(() => {
423
+ this.chromeMeasureFrame = null;
424
+ const header = this.headerContent;
425
+ const sticky = this.stickyContent;
426
+ const nextHeader = header?.isConnected
427
+ ? Math.max(0, header.getBoundingClientRect().height)
428
+ : 0;
429
+ const nextSticky = sticky?.isConnected
430
+ ? Math.max(0, sticky.getBoundingClientRect().height)
431
+ : 0;
432
+ const headerChanged =
433
+ this.measuredHeaderHeight !== nextHeader ||
434
+ this.headerMeasured !== Boolean(header?.isConnected);
435
+ const stickyChanged =
436
+ this.measuredStickyHeight !== nextSticky ||
437
+ this.stickyMeasured !== Boolean(sticky?.isConnected);
438
+ this.measuredHeaderHeight = nextHeader;
439
+ this.measuredStickyHeight = nextSticky;
440
+ this.headerMeasured = Boolean(header?.isConnected);
441
+ this.stickyMeasured = Boolean(sticky?.isConnected);
442
+ if (headerChanged || stickyChanged) {
443
+ this.forceUpdate();
444
+ this.scheduleScrollConfiguration();
445
+ }
446
+ });
447
+ };
448
+
449
+ private onPageBridge = (key: string, bridge: WebCollapsiblePagerPageBridge | null) => {
450
+ if (bridge) {
451
+ this.pageBridges.set(key, bridge);
452
+ bridge.setPendingRouteScrollTop(
453
+ this.routeTarget?.key === key ? this.routeTarget.top : null,
454
+ );
455
+ const index = this.pages().map(pageKey).indexOf(key);
456
+ if (index >= 0) this.syncPageOwner(index);
457
+ } else {
458
+ this.pageBridges.delete(key);
459
+ }
460
+ };
461
+
462
+ private setRouteTarget(target: WebRouteTarget | null) {
463
+ const oldKey = this.routeTarget?.key;
464
+ this.routeTarget = target;
465
+ if (oldKey) this.pageBridges.get(oldKey)?.setPendingRouteScrollTop(null);
466
+ if (target)
467
+ this.pageBridges.get(target.key)?.setPendingRouteScrollTop(target.top);
468
+ }
469
+
470
+ private syncPageOwner(index: number) {
471
+ const key = this.pages().map(pageKey)[index];
472
+ const element = this.pageScrollElement(index);
473
+ if (!key || !element) return;
474
+ const identity = element.getAttribute("data-collapsible-pager-scroll-key") ?? key;
475
+ const previous = this.ownerRecords.get(key);
476
+ if (previous?.element !== element || previous.identity !== identity) {
477
+ if (this.routeTarget?.key === key) this.setRouteTarget(null);
478
+ this.ownerRecords.set(key, {
479
+ element,
480
+ identity,
481
+ generation: ++this.ownerGeneration,
482
+ });
483
+ this.lastRawTops.delete(key);
484
+ }
485
+ this.pageBridges.get(key)?.setOwner(element);
486
+ }
487
+
488
+ private captureRouteTarget(previousProps: CollapsiblePagerViewProps) {
489
+ const index = this.state.selectedPage;
490
+ const oldKey = React.Children.toArray(previousProps.children).map(pageKey)[index];
491
+ const key = this.configuredScrollElement?.key ?? oldKey;
492
+ const record = key ? this.ownerRecords.get(key) : null;
493
+ if (!record?.element.isConnected) return null;
494
+ if (
495
+ this.routeTarget?.key === key &&
496
+ this.routeTarget.identity === record.identity &&
497
+ this.routeTarget.owner === record.element &&
498
+ this.routeTarget.generation === record.generation
499
+ )
500
+ return this.routeTarget;
501
+ const top =
502
+ record.element.clientHeight > 0
503
+ ? record.element.scrollTop
504
+ : this.lastRawTops.get(key) ?? record.element.scrollTop;
505
+ return {
506
+ key,
507
+ identity: record.identity,
508
+ owner: record.element,
509
+ generation: record.generation,
510
+ top: Math.max(0, top),
511
+ };
512
+ }
513
+
514
+ public restoreScrollPosition = () => {
515
+ if (this.disposed || this.props.isActive === false) return false;
516
+ const pending = this.routeTarget;
517
+ if (!pending) return true;
518
+ const key = this.pages().map(pageKey)[this.state.selectedPage];
519
+ if (key !== pending.key) return false;
520
+ this.syncPageOwner(this.state.selectedPage);
521
+ const record = this.ownerRecords.get(key);
522
+ if (
523
+ !record ||
524
+ record.element !== pending.owner ||
525
+ record.identity !== pending.identity ||
526
+ record.generation !== pending.generation
527
+ )
528
+ return false;
529
+ const element = record.element;
530
+ const configured = this.configuredScrollElement;
531
+ if (
532
+ !element.isConnected ||
533
+ element.clientHeight <= 0 ||
534
+ configured?.element !== element ||
535
+ configured.key !== key ||
536
+ (!this.hasExplicitHeaderHeight && !this.headerMeasured) ||
537
+ (!this.hasExplicitStickyHeaderHeight && !this.stickyMeasured) ||
538
+ element.getAttribute("data-collapsible-pager-active") !== "true" ||
539
+ element.style.getPropertyValue(PAGER_HEADER_INSET).trim() !==
540
+ `${this.headerHeight}px` ||
541
+ element.style.getPropertyValue(PAGER_STICKY_INSET).trim() !==
542
+ `${this.stickyHeaderHeight}px` ||
543
+ element.scrollHeight - element.clientHeight < pending.top
544
+ )
545
+ return false;
546
+ this.selectionRestoreToken += 1;
547
+ this.cancelPendingTopFrame();
548
+ this.setRouteTarget(null);
549
+ this.lastRawTops.set(key, pending.top);
550
+ const D = this.headerHeight;
551
+ this.sharedHeaderOffset = Math.min(pending.top, D);
552
+ this.pageOffsets.set(key, Math.max(0, pending.top - D));
553
+ this.bodyAlignment = { element, key };
554
+ scrollElementTo(element, { top: pending.top, behavior: "instant" });
555
+ this.syncFallbackHeader();
556
+ return true;
557
+ };
558
+
559
+ private onPageScroll = (key: string, top: number) => {
560
+ if (this.props.isActive !== false) this.lastRawTops.set(key, top);
561
+ };
562
+
563
+ private onPageGeometryChange = (key: string) => {
564
+ if (this.routeTarget?.key === key) this.restoreScrollPosition();
565
+ };
566
+
567
+ private onPageUserInput = (key: string) => {
568
+ if (this.routeTarget?.key === key) this.setRouteTarget(null);
569
+ };
570
+
229
571
  public setPage = (selectedPage: number) => {
230
572
  this.selectPage(selectedPage, true);
231
573
  };
@@ -240,7 +582,7 @@ export class CollapsiblePagerView extends React.PureComponent<
240
582
 
241
583
  public scrollToStickyTop = (options?: Readonly<{ animated?: boolean }>) => {
242
584
  this.scrollCurrentPageToTop(
243
- Math.max(0, this.props.headerHeight),
585
+ this.headerHeight,
244
586
  options?.animated ?? true
245
587
  );
246
588
  };
@@ -307,6 +649,7 @@ export class CollapsiblePagerView extends React.PureComponent<
307
649
  const element = this.pageScrollElement(this.state.selectedPage);
308
650
  const key = this.pages().map(pageKey)[this.state.selectedPage];
309
651
  if (!element?.isConnected || !key) return;
652
+ this.setRouteTarget(null);
310
653
  this.selectionRestoreToken += 1;
311
654
  this.cancelPendingTopFrame();
312
655
  const EventConstructor = element.ownerDocument.defaultView?.Event;
@@ -314,7 +657,7 @@ export class CollapsiblePagerView extends React.PureComponent<
314
657
  element.dispatchEvent(
315
658
  new EventConstructor("ok-collapsible-pager-cancel-scroll-command")
316
659
  );
317
- element.scrollTo({
660
+ scrollElementTo(element, {
318
661
  left: element.scrollLeft,
319
662
  top: element.scrollTop,
320
663
  behavior: "instant",
@@ -333,10 +676,11 @@ export class CollapsiblePagerView extends React.PureComponent<
333
676
  this.pageOffsets.set(key, 0);
334
677
  this.sharedHeaderOffset = offset;
335
678
  this.bodyAlignment = { element, key };
336
- element.scrollTo({
679
+ scrollElementTo(element, {
337
680
  top: offset,
338
681
  behavior: animated ? "smooth" : "instant",
339
682
  });
683
+ this.syncFallbackHeader();
340
684
  });
341
685
  this.pendingTopFrame = { frame, element, key };
342
686
  }
@@ -410,12 +754,186 @@ export class CollapsiblePagerView extends React.PureComponent<
410
754
  private pageScrollElement(index: number) {
411
755
  const pageHost = this.pageHosts.get(index);
412
756
  return (
413
- pageHost?.querySelector<HTMLElement>(
414
- ".ok-native-list-viewport, [data-collapsible-pager-scroll]"
415
- ) ?? pageHost
757
+ pageHost?.querySelector<HTMLElement>("[data-collapsible-pager-scroll]") ??
758
+ pageHost?.querySelector<HTMLElement>(".ok-native-list-viewport") ??
759
+ pageHost
760
+ );
761
+ }
762
+
763
+ private setRoot = (node: unknown) => {
764
+ const root = webElement(node);
765
+ if (root === this.root) return;
766
+ this.root?.removeEventListener(
767
+ "ok-collapsible-pager-scroll-key-changed",
768
+ this.onScrollOwnerIdentityChanged
769
+ );
770
+ this.root?.removeEventListener("wheel", this.onOverlayWheel);
771
+ this.root?.removeEventListener("touchstart", this.onOverlayTouchStart);
772
+ this.root?.removeEventListener("touchmove", this.onOverlayTouchMove);
773
+ this.root?.removeEventListener("touchend", this.onOverlayTouchEnd);
774
+ this.root?.removeEventListener("touchcancel", this.onOverlayTouchEnd);
775
+ this.overlayTouch = null;
776
+ this.root = root;
777
+ root?.addEventListener(
778
+ "ok-collapsible-pager-scroll-key-changed",
779
+ this.onScrollOwnerIdentityChanged
416
780
  );
781
+ root?.addEventListener("wheel", this.onOverlayWheel, { passive: false });
782
+ root?.addEventListener("touchstart", this.onOverlayTouchStart, {
783
+ passive: true,
784
+ });
785
+ root?.addEventListener("touchmove", this.onOverlayTouchMove, {
786
+ passive: false,
787
+ });
788
+ root?.addEventListener("touchend", this.onOverlayTouchEnd);
789
+ root?.addEventListener("touchcancel", this.onOverlayTouchEnd);
790
+ };
791
+
792
+ private overlayTarget(event: Event) {
793
+ const target = event.target as HTMLElement | null;
794
+ if (
795
+ !target ||
796
+ (!this.headerSlot?.contains(target) &&
797
+ !this.stickySlot?.contains(target)) ||
798
+ target.closest("input,textarea,select,[contenteditable=true]")
799
+ )
800
+ return null;
801
+ return target;
417
802
  }
418
803
 
804
+ private nestedScrollConsumes(target: HTMLElement, delta: number) {
805
+ for (
806
+ let node: HTMLElement | null = target;
807
+ node && node !== this.root;
808
+ node = node.parentElement
809
+ ) {
810
+ const style = node.ownerDocument.defaultView?.getComputedStyle(node);
811
+ if (
812
+ /auto|scroll/.test(style?.overflowY ?? "") &&
813
+ node.scrollHeight > node.clientHeight &&
814
+ (delta < 0
815
+ ? node.scrollTop > 0
816
+ : node.scrollTop < node.scrollHeight - node.clientHeight)
817
+ )
818
+ return true;
819
+ }
820
+ return false;
821
+ }
822
+
823
+ private scrollOverlayOwner(owner: HTMLElement, delta: number, event: Event) {
824
+ if (!event.cancelable) return;
825
+ const EventConstructor = owner.ownerDocument.defaultView?.Event;
826
+ if (EventConstructor)
827
+ owner.dispatchEvent(
828
+ new EventConstructor("ok-collapsible-pager-user-scroll")
829
+ );
830
+ const top = Math.min(
831
+ Math.max(0, owner.scrollTop + delta),
832
+ owner.scrollHeight - owner.clientHeight
833
+ );
834
+ if (top === owner.scrollTop) return;
835
+ this.takeOverVerticalScroll();
836
+ scrollElementTo(owner, { top, behavior: "instant" });
837
+ event.preventDefault();
838
+ }
839
+
840
+ private onOverlayWheel = (event: WheelEvent) => {
841
+ const target = this.overlayTarget(event);
842
+ const owner = this.pageScrollElement(this.state.selectedPage);
843
+ if (
844
+ !target ||
845
+ !owner ||
846
+ event.defaultPrevented ||
847
+ event.ctrlKey ||
848
+ Math.abs(event.deltaX) > Math.abs(event.deltaY)
849
+ )
850
+ return;
851
+ const lineHeight =
852
+ Number.parseFloat(
853
+ target.ownerDocument.defaultView?.getComputedStyle(target).lineHeight ??
854
+ ""
855
+ ) || 16;
856
+ const delta =
857
+ event.deltaY *
858
+ (event.deltaMode === 1
859
+ ? lineHeight
860
+ : event.deltaMode === 2
861
+ ? owner.clientHeight
862
+ : 1);
863
+ if (!this.nestedScrollConsumes(target, delta))
864
+ this.scrollOverlayOwner(owner, delta, event);
865
+ };
866
+
867
+ private onOverlayTouchStart = (event: TouchEvent) => {
868
+ const target = this.overlayTarget(event);
869
+ const owner = this.pageScrollElement(this.state.selectedPage);
870
+ const touch = event.touches.length === 1 ? event.touches[0] : null;
871
+ this.overlayTouch =
872
+ target && owner && touch
873
+ ? {
874
+ owner,
875
+ target,
876
+ x: touch.clientX,
877
+ y: touch.clientY,
878
+ lastY: touch.clientY,
879
+ }
880
+ : null;
881
+ };
882
+
883
+ private onOverlayTouchMove = (event: TouchEvent) => {
884
+ if (event.defaultPrevented) return;
885
+ const pending = this.overlayTouch;
886
+ const touch = event.touches.length === 1 ? event.touches[0] : null;
887
+ if (
888
+ !pending ||
889
+ !touch ||
890
+ pending.owner !== this.pageScrollElement(this.state.selectedPage)
891
+ ) {
892
+ this.overlayTouch = null;
893
+ return;
894
+ }
895
+ const delta = pending.lastY - touch.clientY;
896
+ pending.lastY = touch.clientY;
897
+ if (
898
+ Math.abs(touch.clientY - pending.y) < 8 ||
899
+ Math.abs(touch.clientX - pending.x) >= Math.abs(touch.clientY - pending.y)
900
+ )
901
+ return;
902
+ if (!this.nestedScrollConsumes(pending.target, delta))
903
+ this.scrollOverlayOwner(pending.owner, delta, event);
904
+ };
905
+
906
+ private onOverlayTouchEnd = () => {
907
+ this.overlayTouch = null;
908
+ };
909
+
910
+ private synchronizeScrollOwnerIdentity(index: number) {
911
+ const key = this.pages().map(pageKey)[index];
912
+ const element = this.pageScrollElement(index);
913
+ if (!key || !element) return;
914
+ const identity =
915
+ element.getAttribute("data-collapsible-pager-scroll-key") ?? key;
916
+ const previous = this.pageScrollIdentities.get(key);
917
+ this.pageScrollIdentities.set(key, identity);
918
+ this.syncPageOwner(index);
919
+ if (previous === undefined || previous === identity) return;
920
+ if (index === this.state.selectedPage) {
921
+ this.selectionRestoreToken += 1;
922
+ this.cancelPendingTopFrame();
923
+ this.sharedHeaderOffset = 0;
924
+ this.bodyAlignment = null;
925
+ scrollElementTo(element, { top: 0, behavior: "instant" });
926
+ }
927
+ this.pageOffsets.delete(key);
928
+ }
929
+
930
+ private onScrollOwnerIdentityChanged = (event: Event) => {
931
+ this.pages().forEach((_, index) => {
932
+ if (event.target === this.pageScrollElement(index))
933
+ this.synchronizeScrollOwnerIdentity(index);
934
+ });
935
+ };
936
+
419
937
  private notifyInsetsChanged(element: HTMLElement) {
420
938
  const EventConstructor = element.ownerDocument.defaultView?.Event;
421
939
  if (EventConstructor) {
@@ -443,10 +961,11 @@ export class CollapsiblePagerView extends React.PureComponent<
443
961
  "scrollend",
444
962
  this.onVerticalScrollSettled
445
963
  );
964
+ configured.element.removeEventListener("scroll", this.syncFallbackHeader);
446
965
  const style = timelineStyle(configured.element);
447
966
  configured.rangeSpacer.remove();
448
967
  style.position = configured.position;
449
- style.paddingTop = configured.paddingTop;
968
+ configured.paddingTarget.style.paddingTop = configured.paddingTop;
450
969
  style.boxSizing = configured.boxSizing;
451
970
  style.scrollTimelineName = configured.scrollTimelineName;
452
971
  style.scrollTimelineAxis = configured.scrollTimelineAxis;
@@ -455,6 +974,7 @@ export class CollapsiblePagerView extends React.PureComponent<
455
974
  configured.element.removeAttribute("data-collapsible-pager-active");
456
975
  this.notifyInsetsChanged(configured.element);
457
976
  this.configuredScrollElement = null;
977
+ this.syncFallbackHeader();
458
978
  if (this.bodyAlignment?.element === configured.element)
459
979
  this.bodyAlignment = null;
460
980
  }
@@ -463,13 +983,19 @@ export class CollapsiblePagerView extends React.PureComponent<
463
983
  this.configureFrame = null;
464
984
  const element = this.pageScrollElement(this.state.selectedPage);
465
985
  if (!element) return;
986
+ this.synchronizeScrollOwnerIdentity(this.state.selectedPage);
466
987
  if (this.configuredScrollElement?.element !== element) {
467
988
  this.restoreConfiguredScrollElement();
468
989
  const style = timelineStyle(element);
990
+ const paddingTarget = element.hasAttribute(
991
+ "data-collapsible-pager-scroll"
992
+ )
993
+ ? webElement(element.firstElementChild) ?? element
994
+ : element;
469
995
  const computedPaddingTop =
470
- element.ownerDocument.defaultView?.getComputedStyle(element)
996
+ element.ownerDocument.defaultView?.getComputedStyle(paddingTarget)
471
997
  .paddingTop ||
472
- style.paddingTop ||
998
+ paddingTarget.style.paddingTop ||
473
999
  "0px";
474
1000
  const rangeSpacer = element.ownerDocument.createElement("div");
475
1001
  Object.assign(rangeSpacer.style, {
@@ -489,10 +1015,12 @@ export class CollapsiblePagerView extends React.PureComponent<
489
1015
  style.position = "relative";
490
1016
  element.appendChild(rangeSpacer);
491
1017
  this.configuredScrollElement = {
1018
+ key: this.pages().map(pageKey)[this.state.selectedPage],
492
1019
  element,
1020
+ paddingTarget,
493
1021
  rangeSpacer,
494
1022
  position,
495
- paddingTop: style.paddingTop,
1023
+ paddingTop: paddingTarget.style.paddingTop,
496
1024
  basePaddingTop: computedPaddingTop,
497
1025
  boxSizing: style.boxSizing,
498
1026
  scrollTimelineName: style.scrollTimelineName,
@@ -508,11 +1036,17 @@ export class CollapsiblePagerView extends React.PureComponent<
508
1036
  element.addEventListener("wheel", this.onQueuedTopWheel);
509
1037
  element.addEventListener("pointerdown", this.onScrollbarPointerDown);
510
1038
  element.addEventListener("keydown", this.onQueuedTopKey);
1039
+ if (!this.cssTimelineSupported)
1040
+ element.addEventListener("scroll", this.syncFallbackHeader, {
1041
+ passive: true,
1042
+ });
511
1043
  }
512
1044
 
513
1045
  const style = timelineStyle(element);
514
- const headerInset = Math.max(0, this.props.headerHeight);
515
- const stickyInset = Math.max(0, this.props.stickyHeaderHeight);
1046
+ const paddingStyle =
1047
+ this.configuredScrollElement?.paddingTarget.style ?? style;
1048
+ const headerInset = this.headerHeight;
1049
+ const stickyInset = this.stickyHeaderHeight;
516
1050
  if (this.configuredScrollElement)
517
1051
  this.configuredScrollElement.rangeSpacer.style.height = `calc(100% + ${headerInset}px)`;
518
1052
  const basePaddingTop =
@@ -523,10 +1057,10 @@ export class CollapsiblePagerView extends React.PureComponent<
523
1057
  const headerInsetValue = `${headerInset}px`;
524
1058
  const stickyInsetValue = `${stickyInset}px`;
525
1059
  const insetsChanged =
526
- style.paddingTop !== paddingTop ||
1060
+ paddingStyle.paddingTop !== paddingTop ||
527
1061
  style.getPropertyValue(PAGER_HEADER_INSET) !== headerInsetValue ||
528
1062
  style.getPropertyValue(PAGER_STICKY_INSET) !== stickyInsetValue;
529
- style.paddingTop = paddingTop;
1063
+ paddingStyle.paddingTop = paddingTop;
530
1064
  style.boxSizing = "border-box";
531
1065
  style.scrollTimelineName = this.timelineName;
532
1066
  style.scrollTimelineAxis = "block";
@@ -534,10 +1068,25 @@ export class CollapsiblePagerView extends React.PureComponent<
534
1068
  style.setProperty(PAGER_STICKY_INSET, stickyInsetValue);
535
1069
  element.setAttribute("data-collapsible-pager-active", "true");
536
1070
  if (insetsChanged) this.notifyInsetsChanged(element);
1071
+ this.syncFallbackHeader();
1072
+ this.pageBridges.get(this.pages().map(pageKey)[this.state.selectedPage])?.requestMeasure();
1073
+ this.restoreScrollPosition();
1074
+ };
1075
+
1076
+ private syncFallbackHeader = () => {
1077
+ if (this.cssTimelineSupported) return;
1078
+ const top = this.configuredScrollElement?.element.scrollTop ?? 0;
1079
+ const collapse = Math.min(
1080
+ Math.max(top, 0),
1081
+ this.headerHeight
1082
+ );
1083
+ const transform = `translateY(${-collapse}px)`;
1084
+ if (this.headerSlot) this.headerSlot.style.transform = transform;
1085
+ if (this.stickySlot) this.stickySlot.style.transform = transform;
537
1086
  };
538
1087
 
539
1088
  private scheduleScrollConfiguration() {
540
- if (this.configureFrame !== null) return;
1089
+ if (this.disposed || this.configureFrame !== null) return;
541
1090
  if (typeof requestAnimationFrame === "function") {
542
1091
  this.configureFrame = requestAnimationFrame(
543
1092
  this.configureActiveScrollElement
@@ -548,6 +1097,7 @@ export class CollapsiblePagerView extends React.PureComponent<
548
1097
  }
549
1098
 
550
1099
  private takeOverVerticalScroll = () => {
1100
+ this.setRouteTarget(null);
551
1101
  this.selectionRestoreToken += 1;
552
1102
  this.bodyAlignment = null;
553
1103
  this.cancelPendingTopFrame();
@@ -572,15 +1122,18 @@ export class CollapsiblePagerView extends React.PureComponent<
572
1122
  };
573
1123
 
574
1124
  private savePageOffset(index: number) {
575
- const key = this.pages().map(pageKey)[index];
576
- const scrollElement = this.pageScrollElement(index);
1125
+ const configured =
1126
+ index === this.state.selectedPage ? this.configuredScrollElement : null;
1127
+ const key = configured?.key ?? this.pages().map(pageKey)[index];
1128
+ const scrollElement = configured?.element ?? this.pageScrollElement(index);
1129
+ if (configured && !scrollElement?.isConnected) return;
577
1130
  if (key && scrollElement) {
578
1131
  if (
579
1132
  this.bodyAlignment?.key === key &&
580
1133
  this.bodyAlignment.element === scrollElement
581
1134
  )
582
1135
  return;
583
- const D = Math.max(0, this.props.headerHeight);
1136
+ const D = this.headerHeight;
584
1137
  this.pageOffsets.set(key, Math.max(0, scrollElement.scrollTop - D));
585
1138
  this.sharedHeaderOffset = Math.min(
586
1139
  Math.max(scrollElement.scrollTop, 0),
@@ -593,11 +1146,12 @@ export class CollapsiblePagerView extends React.PureComponent<
593
1146
  const key = this.pages().map(pageKey)[index];
594
1147
  const scrollElement = this.pageScrollElement(index);
595
1148
  if (!key || !scrollElement) return;
596
- const D = Math.max(0, this.props.headerHeight);
1149
+ const D = this.headerHeight;
597
1150
  const C = Math.min(Math.max(0, this.sharedHeaderOffset), D);
598
1151
  const expectedQ = C + (C === D ? this.pageOffsets.get(key) ?? 0 : 0);
599
1152
  this.bodyAlignment = { element: scrollElement, key };
600
1153
  scrollElement.scrollTop = expectedQ;
1154
+ this.syncFallbackHeader();
601
1155
  }
602
1156
 
603
1157
  private selectPage(selectedPage: number, animated: boolean) {
@@ -611,8 +1165,11 @@ export class CollapsiblePagerView extends React.PureComponent<
611
1165
  ) {
612
1166
  return;
613
1167
  }
1168
+ this.synchronizeScrollOwnerIdentity(this.state.selectedPage);
614
1169
  const selectionToken = ++this.selectionRestoreToken;
615
1170
  const expectedKey = this.pages().map(pageKey)[position];
1171
+ if (this.routeTarget && this.routeTarget.key !== expectedKey)
1172
+ this.setRouteTarget(null);
616
1173
  this.cancelPendingTopFrame();
617
1174
  this.savePageOffset(this.state.selectedPage);
618
1175
 
@@ -631,6 +1188,12 @@ export class CollapsiblePagerView extends React.PureComponent<
631
1188
  )
632
1189
  return;
633
1190
  this.configureActiveScrollElement();
1191
+ if (
1192
+ this.selectionRestoreToken !== selectionToken ||
1193
+ this.routeTarget?.key === expectedKey
1194
+ ) {
1195
+ return;
1196
+ }
634
1197
  this.restorePageOffset(position);
635
1198
  };
636
1199
  if (typeof requestAnimationFrame === "function") {
@@ -678,7 +1241,7 @@ export class CollapsiblePagerView extends React.PureComponent<
678
1241
  this.pageScrollElement(this.state.selectedPage)?.scrollTop ?? 0,
679
1242
  0
680
1243
  ),
681
- Math.max(0, this.props.headerHeight)
1244
+ this.headerHeight
682
1245
  ),
683
1246
  nativePageCount: pages.length,
684
1247
  attachedPageCount: retainedPages.length,
@@ -769,6 +1332,7 @@ export class CollapsiblePagerView extends React.PureComponent<
769
1332
  externalTopOverlayTag: _externalTopOverlayTag,
770
1333
  headerHeight: _headerHeight,
771
1334
  stickyHeaderHeight: _stickyHeaderHeight,
1335
+ isActive: _isActive,
772
1336
  pageRetentionDistance: _pageRetentionDistance,
773
1337
  layoutDirection: _layoutDirection,
774
1338
  scrollEnabled: _scrollEnabled,
@@ -786,18 +1350,20 @@ export class CollapsiblePagerView extends React.PureComponent<
786
1350
  const keys = pages.map(pageKey);
787
1351
  const retained = new Set(this.mountedPages(pages.length));
788
1352
  const pageWidth = pages.length > 0 ? `${100 / pages.length}%` : "100%";
789
- const collapseAnimation = {
790
- "--ok-collapsible-header-height": `${Math.max(
791
- 0,
792
- this.props.headerHeight
793
- )}px`,
794
- animationName: COLLAPSE_ANIMATION_NAME,
795
- animationDuration: "auto",
796
- animationFillMode: "both",
797
- animationTimingFunction: "linear",
798
- animationTimeline: this.timelineName,
799
- animationRange: `0px ${Math.max(1, this.props.headerHeight)}px`,
800
- } as never;
1353
+ const collapseAnimation = this.cssTimelineSupported
1354
+ ? ({
1355
+ "--ok-collapsible-header-height": `${Math.max(
1356
+ 0,
1357
+ this.headerHeight
1358
+ )}px`,
1359
+ animationName: COLLAPSE_ANIMATION_NAME,
1360
+ animationDuration: "auto",
1361
+ animationFillMode: "both",
1362
+ animationTimingFunction: "linear",
1363
+ animationTimeline: this.timelineName,
1364
+ animationRange: `0px ${Math.max(1, this.headerHeight)}px`,
1365
+ } as never)
1366
+ : undefined;
801
1367
  const trackStyle = [
802
1368
  styles.track,
803
1369
  {
@@ -817,35 +1383,45 @@ export class CollapsiblePagerView extends React.PureComponent<
817
1383
  return (
818
1384
  <View
819
1385
  {...viewProps}
820
- ref={(node) => {
821
- this.root = webElement(node);
822
- }}
1386
+ ref={this.setRoot}
823
1387
  style={[
824
1388
  styles.root,
825
- { timelineScope: this.timelineName } as never,
1389
+ this.cssTimelineSupported
1390
+ ? ({ timelineScope: this.timelineName } as never)
1391
+ : undefined,
826
1392
  style,
827
1393
  ]}
828
1394
  >
829
1395
  <View
1396
+ ref={(node) => {
1397
+ this.headerSlot = webElement(node);
1398
+ }}
830
1399
  style={[
831
1400
  styles.header,
832
- { height: Math.max(0, this.props.headerHeight) },
1401
+ { height: this.headerHeight },
833
1402
  collapseAnimation,
834
1403
  ]}
835
1404
  >
836
- {header}
1405
+ {this.hasExplicitHeaderHeight ? header : (
1406
+ <View ref={this.setHeaderContent}>{header}</View>
1407
+ )}
837
1408
  </View>
838
1409
  <View
1410
+ ref={(node) => {
1411
+ this.stickySlot = webElement(node);
1412
+ }}
839
1413
  style={[
840
1414
  styles.sticky,
841
1415
  {
842
- top: Math.max(0, this.props.headerHeight),
843
- height: Math.max(0, this.props.stickyHeaderHeight),
1416
+ top: this.headerHeight,
1417
+ height: this.stickyHeaderHeight,
844
1418
  },
845
1419
  collapseAnimation,
846
1420
  ]}
847
1421
  >
848
- {stickyHeader}
1422
+ {this.hasExplicitStickyHeaderHeight ? stickyHeader : (
1423
+ <View ref={this.setStickyContent}>{stickyHeader}</View>
1424
+ )}
849
1425
  </View>
850
1426
  <View
851
1427
  ref={this.setTrack}
@@ -870,7 +1446,21 @@ export class CollapsiblePagerView extends React.PureComponent<
870
1446
  style={[styles.page, { width: pageWidth }]}
871
1447
  aria-hidden={index !== this.state.selectedPage}
872
1448
  >
873
- {retained.has(index) ? page : null}
1449
+ {retained.has(index) ? (
1450
+ <WebCollapsiblePagerPage
1451
+ pageKey={keys[index]}
1452
+ isFocused={index === this.state.selectedPage}
1453
+ isRouteActive={this.props.isActive !== false}
1454
+ headerHeight={this.headerHeight}
1455
+ stickyHeaderHeight={this.stickyHeaderHeight}
1456
+ onBridge={this.onPageBridge}
1457
+ onGeometryChange={this.onPageGeometryChange}
1458
+ onScroll={this.onPageScroll}
1459
+ onUserInput={this.onPageUserInput}
1460
+ >
1461
+ {page}
1462
+ </WebCollapsiblePagerPage>
1463
+ ) : null}
874
1464
  </View>
875
1465
  ))}
876
1466
  </View>
@@ -882,7 +1472,7 @@ export class CollapsiblePagerView extends React.PureComponent<
882
1472
  const styles = StyleSheet.create({
883
1473
  root: {
884
1474
  flex: 1,
885
- overflow: "hidden",
1475
+ overflow: "clip",
886
1476
  position: "relative",
887
1477
  } as never,
888
1478
  header: {
@@ -898,7 +1488,7 @@ const styles = StyleSheet.create({
898
1488
  left: 0,
899
1489
  right: 0,
900
1490
  zIndex: 4,
901
- overflow: "hidden",
1491
+ overflow: "visible",
902
1492
  } as never,
903
1493
  track: {
904
1494
  position: "absolute",