@onekeyfe/react-native-pager-view 6.7.0-2 → 6.7.0-4

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.
@@ -1,8 +1,11 @@
1
1
  import type { ScrollEdgeEffect } from "./ScrollEdgeEffect";
2
2
  import React from "react";
3
- import { I18nManager, StyleSheet, View } from "react-native";
3
+ import { I18nManager, Platform, StyleSheet, View } from "react-native";
4
4
  import type * as ReactNative from "react-native";
5
5
 
6
+ import { WebCollapsiblePagerPage } from "./WebCollapsiblePagerPage";
7
+ import type { WebCollapsiblePagerPageBridge } from "./WebCollapsiblePagerPage";
8
+
6
9
  export type OnPageScrollEventData = Readonly<{
7
10
  position: number;
8
11
  offset: number;
@@ -65,8 +68,9 @@ export interface CollapsiblePagerViewProps
65
68
  > {
66
69
  header: React.ReactNode;
67
70
  stickyHeader: React.ReactNode;
68
- headerHeight: number;
69
- stickyHeaderHeight: number;
71
+ headerHeight?: number;
72
+ stickyHeaderHeight?: number;
73
+ isActive?: boolean;
70
74
  topOverlay?: React.ReactNode;
71
75
  topScrollEdgeEffect?: `${ScrollEdgeEffect}`;
72
76
  topOverlayColorScheme?: "light" | "dark";
@@ -83,7 +87,9 @@ type State = {
83
87
  };
84
88
 
85
89
  type ConfiguredScrollElement = Readonly<{
90
+ key: string;
86
91
  element: HTMLElement;
92
+ paddingTarget: HTMLElement;
87
93
  rangeSpacer: HTMLElement;
88
94
  position: string;
89
95
  paddingTop: string;
@@ -95,6 +101,14 @@ type ConfiguredScrollElement = Readonly<{
95
101
  pagerStickyInset: string;
96
102
  }>;
97
103
 
104
+ type WebRouteTarget = Readonly<{
105
+ key: string;
106
+ identity: string;
107
+ owner: HTMLElement;
108
+ generation: number;
109
+ top: number;
110
+ }>;
111
+
98
112
  const COLLAPSE_ANIMATION_NAME = "ok-collapsible-pager-collapse";
99
113
  const COLLAPSE_STYLE_ID = "ok-collapsible-pager-styles";
100
114
  const PAGER_HEADER_INSET = "--ok-collapsible-pager-header-inset";
@@ -124,6 +138,14 @@ const timelineStyle = (element: HTMLElement) =>
124
138
  scrollTimelineAxis: string;
125
139
  };
126
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
+
127
149
  function ensureCollapseAnimation(document: Document) {
128
150
  if (document.getElementById(COLLAPSE_STYLE_ID)) return;
129
151
  const style = document.createElement("style");
@@ -132,6 +154,19 @@ function ensureCollapseAnimation(document: Document) {
132
154
  document.head.appendChild(style);
133
155
  }
134
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
+
135
170
  const pageKey = (child: React.ReactNode, index: number) => {
136
171
  if (React.isValidElement(child) && child.key != null)
137
172
  return String(child.key);
@@ -153,11 +188,39 @@ export class CollapsiblePagerView extends React.PureComponent<
153
188
 
154
189
  private root: HTMLElement | null = null;
155
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;
156
201
  private touchStartX: number | null = null;
157
202
  private touchStartY: number | null = null;
158
203
  private horizontalTouch = false;
159
204
  private pageOffsets = new Map<string, number>();
160
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;
161
224
  private sharedHeaderOffset = 0;
162
225
  private selectionRestoreToken = 0;
163
226
  private bodyAlignment: Readonly<{
@@ -178,14 +241,79 @@ export class CollapsiblePagerView extends React.PureComponent<
178
241
  private configuredScrollElement: ConfiguredScrollElement | null = null;
179
242
  private readonly timelineName = `--ok-collapsible-pager-${++collapsiblePagerWebInstance}`;
180
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
+
181
274
  componentDidMount() {
182
- 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
+ }
183
290
  this.scheduleScrollConfiguration();
291
+ this.syncFallbackHeader();
184
292
  this.notifyMountedPagesChanged();
185
293
  this.emitDiagnostics("mounted");
186
294
  }
187
295
 
188
- 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
+ }
189
317
  if (previousProps.scrollEnabled !== this.props.scrollEnabled) {
190
318
  this.scrollEnabled = this.props.scrollEnabled ?? true;
191
319
  if (!this.scrollEnabled) {
@@ -207,11 +335,37 @@ export class CollapsiblePagerView extends React.PureComponent<
207
335
  });
208
336
  return;
209
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
+ }
210
359
  this.scheduleScrollConfiguration();
360
+ this.syncFallbackHeader();
211
361
  this.notifyMountedPagesChanged();
212
362
  }
213
363
 
214
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;
215
369
  this.selectionRestoreToken += 1;
216
370
  this.cancelPendingTopFrame();
217
371
  this.savePageOffset(this.state.selectedPage);
@@ -224,9 +378,196 @@ export class CollapsiblePagerView extends React.PureComponent<
224
378
  this.transitionTimer = null;
225
379
  }
226
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();
227
389
  this.restoreConfiguredScrollElement();
228
390
  }
229
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
+
230
571
  public setPage = (selectedPage: number) => {
231
572
  this.selectPage(selectedPage, true);
232
573
  };
@@ -241,7 +582,7 @@ export class CollapsiblePagerView extends React.PureComponent<
241
582
 
242
583
  public scrollToStickyTop = (options?: Readonly<{ animated?: boolean }>) => {
243
584
  this.scrollCurrentPageToTop(
244
- Math.max(0, this.props.headerHeight),
585
+ this.headerHeight,
245
586
  options?.animated ?? true
246
587
  );
247
588
  };
@@ -308,6 +649,7 @@ export class CollapsiblePagerView extends React.PureComponent<
308
649
  const element = this.pageScrollElement(this.state.selectedPage);
309
650
  const key = this.pages().map(pageKey)[this.state.selectedPage];
310
651
  if (!element?.isConnected || !key) return;
652
+ this.setRouteTarget(null);
311
653
  this.selectionRestoreToken += 1;
312
654
  this.cancelPendingTopFrame();
313
655
  const EventConstructor = element.ownerDocument.defaultView?.Event;
@@ -315,7 +657,7 @@ export class CollapsiblePagerView extends React.PureComponent<
315
657
  element.dispatchEvent(
316
658
  new EventConstructor("ok-collapsible-pager-cancel-scroll-command")
317
659
  );
318
- element.scrollTo({
660
+ scrollElementTo(element, {
319
661
  left: element.scrollLeft,
320
662
  top: element.scrollTop,
321
663
  behavior: "instant",
@@ -334,10 +676,11 @@ export class CollapsiblePagerView extends React.PureComponent<
334
676
  this.pageOffsets.set(key, 0);
335
677
  this.sharedHeaderOffset = offset;
336
678
  this.bodyAlignment = { element, key };
337
- element.scrollTo({
679
+ scrollElementTo(element, {
338
680
  top: offset,
339
681
  behavior: animated ? "smooth" : "instant",
340
682
  });
683
+ this.syncFallbackHeader();
341
684
  });
342
685
  this.pendingTopFrame = { frame, element, key };
343
686
  }
@@ -411,12 +754,186 @@ export class CollapsiblePagerView extends React.PureComponent<
411
754
  private pageScrollElement(index: number) {
412
755
  const pageHost = this.pageHosts.get(index);
413
756
  return (
414
- pageHost?.querySelector<HTMLElement>(
415
- ".ok-native-list-viewport, [data-collapsible-pager-scroll]"
416
- ) ?? 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
417
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;
418
802
  }
419
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
+
420
937
  private notifyInsetsChanged(element: HTMLElement) {
421
938
  const EventConstructor = element.ownerDocument.defaultView?.Event;
422
939
  if (EventConstructor) {
@@ -444,10 +961,11 @@ export class CollapsiblePagerView extends React.PureComponent<
444
961
  "scrollend",
445
962
  this.onVerticalScrollSettled
446
963
  );
964
+ configured.element.removeEventListener("scroll", this.syncFallbackHeader);
447
965
  const style = timelineStyle(configured.element);
448
966
  configured.rangeSpacer.remove();
449
967
  style.position = configured.position;
450
- style.paddingTop = configured.paddingTop;
968
+ configured.paddingTarget.style.paddingTop = configured.paddingTop;
451
969
  style.boxSizing = configured.boxSizing;
452
970
  style.scrollTimelineName = configured.scrollTimelineName;
453
971
  style.scrollTimelineAxis = configured.scrollTimelineAxis;
@@ -456,6 +974,7 @@ export class CollapsiblePagerView extends React.PureComponent<
456
974
  configured.element.removeAttribute("data-collapsible-pager-active");
457
975
  this.notifyInsetsChanged(configured.element);
458
976
  this.configuredScrollElement = null;
977
+ this.syncFallbackHeader();
459
978
  if (this.bodyAlignment?.element === configured.element)
460
979
  this.bodyAlignment = null;
461
980
  }
@@ -464,13 +983,19 @@ export class CollapsiblePagerView extends React.PureComponent<
464
983
  this.configureFrame = null;
465
984
  const element = this.pageScrollElement(this.state.selectedPage);
466
985
  if (!element) return;
986
+ this.synchronizeScrollOwnerIdentity(this.state.selectedPage);
467
987
  if (this.configuredScrollElement?.element !== element) {
468
988
  this.restoreConfiguredScrollElement();
469
989
  const style = timelineStyle(element);
990
+ const paddingTarget = element.hasAttribute(
991
+ "data-collapsible-pager-scroll"
992
+ )
993
+ ? webElement(element.firstElementChild) ?? element
994
+ : element;
470
995
  const computedPaddingTop =
471
- element.ownerDocument.defaultView?.getComputedStyle(element)
996
+ element.ownerDocument.defaultView?.getComputedStyle(paddingTarget)
472
997
  .paddingTop ||
473
- style.paddingTop ||
998
+ paddingTarget.style.paddingTop ||
474
999
  "0px";
475
1000
  const rangeSpacer = element.ownerDocument.createElement("div");
476
1001
  Object.assign(rangeSpacer.style, {
@@ -490,10 +1015,12 @@ export class CollapsiblePagerView extends React.PureComponent<
490
1015
  style.position = "relative";
491
1016
  element.appendChild(rangeSpacer);
492
1017
  this.configuredScrollElement = {
1018
+ key: this.pages().map(pageKey)[this.state.selectedPage],
493
1019
  element,
1020
+ paddingTarget,
494
1021
  rangeSpacer,
495
1022
  position,
496
- paddingTop: style.paddingTop,
1023
+ paddingTop: paddingTarget.style.paddingTop,
497
1024
  basePaddingTop: computedPaddingTop,
498
1025
  boxSizing: style.boxSizing,
499
1026
  scrollTimelineName: style.scrollTimelineName,
@@ -509,11 +1036,17 @@ export class CollapsiblePagerView extends React.PureComponent<
509
1036
  element.addEventListener("wheel", this.onQueuedTopWheel);
510
1037
  element.addEventListener("pointerdown", this.onScrollbarPointerDown);
511
1038
  element.addEventListener("keydown", this.onQueuedTopKey);
1039
+ if (!this.cssTimelineSupported)
1040
+ element.addEventListener("scroll", this.syncFallbackHeader, {
1041
+ passive: true,
1042
+ });
512
1043
  }
513
1044
 
514
1045
  const style = timelineStyle(element);
515
- const headerInset = Math.max(0, this.props.headerHeight);
516
- 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;
517
1050
  if (this.configuredScrollElement)
518
1051
  this.configuredScrollElement.rangeSpacer.style.height = `calc(100% + ${headerInset}px)`;
519
1052
  const basePaddingTop =
@@ -524,10 +1057,10 @@ export class CollapsiblePagerView extends React.PureComponent<
524
1057
  const headerInsetValue = `${headerInset}px`;
525
1058
  const stickyInsetValue = `${stickyInset}px`;
526
1059
  const insetsChanged =
527
- style.paddingTop !== paddingTop ||
1060
+ paddingStyle.paddingTop !== paddingTop ||
528
1061
  style.getPropertyValue(PAGER_HEADER_INSET) !== headerInsetValue ||
529
1062
  style.getPropertyValue(PAGER_STICKY_INSET) !== stickyInsetValue;
530
- style.paddingTop = paddingTop;
1063
+ paddingStyle.paddingTop = paddingTop;
531
1064
  style.boxSizing = "border-box";
532
1065
  style.scrollTimelineName = this.timelineName;
533
1066
  style.scrollTimelineAxis = "block";
@@ -535,10 +1068,25 @@ export class CollapsiblePagerView extends React.PureComponent<
535
1068
  style.setProperty(PAGER_STICKY_INSET, stickyInsetValue);
536
1069
  element.setAttribute("data-collapsible-pager-active", "true");
537
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;
538
1086
  };
539
1087
 
540
1088
  private scheduleScrollConfiguration() {
541
- if (this.configureFrame !== null) return;
1089
+ if (this.disposed || this.configureFrame !== null) return;
542
1090
  if (typeof requestAnimationFrame === "function") {
543
1091
  this.configureFrame = requestAnimationFrame(
544
1092
  this.configureActiveScrollElement
@@ -549,6 +1097,7 @@ export class CollapsiblePagerView extends React.PureComponent<
549
1097
  }
550
1098
 
551
1099
  private takeOverVerticalScroll = () => {
1100
+ this.setRouteTarget(null);
552
1101
  this.selectionRestoreToken += 1;
553
1102
  this.bodyAlignment = null;
554
1103
  this.cancelPendingTopFrame();
@@ -573,15 +1122,18 @@ export class CollapsiblePagerView extends React.PureComponent<
573
1122
  };
574
1123
 
575
1124
  private savePageOffset(index: number) {
576
- const key = this.pages().map(pageKey)[index];
577
- 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;
578
1130
  if (key && scrollElement) {
579
1131
  if (
580
1132
  this.bodyAlignment?.key === key &&
581
1133
  this.bodyAlignment.element === scrollElement
582
1134
  )
583
1135
  return;
584
- const D = Math.max(0, this.props.headerHeight);
1136
+ const D = this.headerHeight;
585
1137
  this.pageOffsets.set(key, Math.max(0, scrollElement.scrollTop - D));
586
1138
  this.sharedHeaderOffset = Math.min(
587
1139
  Math.max(scrollElement.scrollTop, 0),
@@ -594,11 +1146,12 @@ export class CollapsiblePagerView extends React.PureComponent<
594
1146
  const key = this.pages().map(pageKey)[index];
595
1147
  const scrollElement = this.pageScrollElement(index);
596
1148
  if (!key || !scrollElement) return;
597
- const D = Math.max(0, this.props.headerHeight);
1149
+ const D = this.headerHeight;
598
1150
  const C = Math.min(Math.max(0, this.sharedHeaderOffset), D);
599
1151
  const expectedQ = C + (C === D ? this.pageOffsets.get(key) ?? 0 : 0);
600
1152
  this.bodyAlignment = { element: scrollElement, key };
601
1153
  scrollElement.scrollTop = expectedQ;
1154
+ this.syncFallbackHeader();
602
1155
  }
603
1156
 
604
1157
  private selectPage(selectedPage: number, animated: boolean) {
@@ -612,8 +1165,11 @@ export class CollapsiblePagerView extends React.PureComponent<
612
1165
  ) {
613
1166
  return;
614
1167
  }
1168
+ this.synchronizeScrollOwnerIdentity(this.state.selectedPage);
615
1169
  const selectionToken = ++this.selectionRestoreToken;
616
1170
  const expectedKey = this.pages().map(pageKey)[position];
1171
+ if (this.routeTarget && this.routeTarget.key !== expectedKey)
1172
+ this.setRouteTarget(null);
617
1173
  this.cancelPendingTopFrame();
618
1174
  this.savePageOffset(this.state.selectedPage);
619
1175
 
@@ -632,6 +1188,12 @@ export class CollapsiblePagerView extends React.PureComponent<
632
1188
  )
633
1189
  return;
634
1190
  this.configureActiveScrollElement();
1191
+ if (
1192
+ this.selectionRestoreToken !== selectionToken ||
1193
+ this.routeTarget?.key === expectedKey
1194
+ ) {
1195
+ return;
1196
+ }
635
1197
  this.restorePageOffset(position);
636
1198
  };
637
1199
  if (typeof requestAnimationFrame === "function") {
@@ -679,7 +1241,7 @@ export class CollapsiblePagerView extends React.PureComponent<
679
1241
  this.pageScrollElement(this.state.selectedPage)?.scrollTop ?? 0,
680
1242
  0
681
1243
  ),
682
- Math.max(0, this.props.headerHeight)
1244
+ this.headerHeight
683
1245
  ),
684
1246
  nativePageCount: pages.length,
685
1247
  attachedPageCount: retainedPages.length,
@@ -770,6 +1332,7 @@ export class CollapsiblePagerView extends React.PureComponent<
770
1332
  externalTopOverlayTag: _externalTopOverlayTag,
771
1333
  headerHeight: _headerHeight,
772
1334
  stickyHeaderHeight: _stickyHeaderHeight,
1335
+ isActive: _isActive,
773
1336
  pageRetentionDistance: _pageRetentionDistance,
774
1337
  layoutDirection: _layoutDirection,
775
1338
  scrollEnabled: _scrollEnabled,
@@ -787,18 +1350,20 @@ export class CollapsiblePagerView extends React.PureComponent<
787
1350
  const keys = pages.map(pageKey);
788
1351
  const retained = new Set(this.mountedPages(pages.length));
789
1352
  const pageWidth = pages.length > 0 ? `${100 / pages.length}%` : "100%";
790
- const collapseAnimation = {
791
- "--ok-collapsible-header-height": `${Math.max(
792
- 0,
793
- this.props.headerHeight
794
- )}px`,
795
- animationName: COLLAPSE_ANIMATION_NAME,
796
- animationDuration: "auto",
797
- animationFillMode: "both",
798
- animationTimingFunction: "linear",
799
- animationTimeline: this.timelineName,
800
- animationRange: `0px ${Math.max(1, this.props.headerHeight)}px`,
801
- } 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;
802
1367
  const trackStyle = [
803
1368
  styles.track,
804
1369
  {
@@ -818,35 +1383,45 @@ export class CollapsiblePagerView extends React.PureComponent<
818
1383
  return (
819
1384
  <View
820
1385
  {...viewProps}
821
- ref={(node) => {
822
- this.root = webElement(node);
823
- }}
1386
+ ref={this.setRoot}
824
1387
  style={[
825
1388
  styles.root,
826
- { timelineScope: this.timelineName } as never,
1389
+ this.cssTimelineSupported
1390
+ ? ({ timelineScope: this.timelineName } as never)
1391
+ : undefined,
827
1392
  style,
828
1393
  ]}
829
1394
  >
830
1395
  <View
1396
+ ref={(node) => {
1397
+ this.headerSlot = webElement(node);
1398
+ }}
831
1399
  style={[
832
1400
  styles.header,
833
- { height: Math.max(0, this.props.headerHeight) },
1401
+ { height: this.headerHeight },
834
1402
  collapseAnimation,
835
1403
  ]}
836
1404
  >
837
- {header}
1405
+ {this.hasExplicitHeaderHeight ? header : (
1406
+ <View ref={this.setHeaderContent}>{header}</View>
1407
+ )}
838
1408
  </View>
839
1409
  <View
1410
+ ref={(node) => {
1411
+ this.stickySlot = webElement(node);
1412
+ }}
840
1413
  style={[
841
1414
  styles.sticky,
842
1415
  {
843
- top: Math.max(0, this.props.headerHeight),
844
- height: Math.max(0, this.props.stickyHeaderHeight),
1416
+ top: this.headerHeight,
1417
+ height: this.stickyHeaderHeight,
845
1418
  },
846
1419
  collapseAnimation,
847
1420
  ]}
848
1421
  >
849
- {stickyHeader}
1422
+ {this.hasExplicitStickyHeaderHeight ? stickyHeader : (
1423
+ <View ref={this.setStickyContent}>{stickyHeader}</View>
1424
+ )}
850
1425
  </View>
851
1426
  <View
852
1427
  ref={this.setTrack}
@@ -871,7 +1446,21 @@ export class CollapsiblePagerView extends React.PureComponent<
871
1446
  style={[styles.page, { width: pageWidth }]}
872
1447
  aria-hidden={index !== this.state.selectedPage}
873
1448
  >
874
- {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}
875
1464
  </View>
876
1465
  ))}
877
1466
  </View>
@@ -883,7 +1472,7 @@ export class CollapsiblePagerView extends React.PureComponent<
883
1472
  const styles = StyleSheet.create({
884
1473
  root: {
885
1474
  flex: 1,
886
- overflow: "hidden",
1475
+ overflow: "clip",
887
1476
  position: "relative",
888
1477
  } as never,
889
1478
  header: {
@@ -899,7 +1488,7 @@ const styles = StyleSheet.create({
899
1488
  left: 0,
900
1489
  right: 0,
901
1490
  zIndex: 4,
902
- overflow: "hidden",
1491
+ overflow: "visible",
903
1492
  } as never,
904
1493
  track: {
905
1494
  position: "absolute",