react-native-divkit 1.6.5 → 1.8.0

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 (97) hide show
  1. package/README.md +18 -15
  2. package/dist/DivKit.d.ts.map +1 -1
  3. package/dist/DivKit.js +115 -4
  4. package/dist/DivKit.js.map +1 -1
  5. package/dist/components/DivComponent.d.ts.map +1 -1
  6. package/dist/components/DivComponent.js +6 -2
  7. package/dist/components/DivComponent.js.map +1 -1
  8. package/dist/components/index.d.ts +4 -0
  9. package/dist/components/index.d.ts.map +1 -1
  10. package/dist/components/index.js +2 -0
  11. package/dist/components/index.js.map +1 -1
  12. package/dist/components/indicator/DivIndicator.d.ts +19 -0
  13. package/dist/components/indicator/DivIndicator.d.ts.map +1 -0
  14. package/dist/components/indicator/DivIndicator.js +112 -0
  15. package/dist/components/indicator/DivIndicator.js.map +1 -0
  16. package/dist/components/indicator/index.d.ts +3 -0
  17. package/dist/components/indicator/index.d.ts.map +1 -0
  18. package/dist/components/indicator/index.js +2 -0
  19. package/dist/components/indicator/index.js.map +1 -0
  20. package/dist/components/indicator/utils.d.ts +61 -0
  21. package/dist/components/indicator/utils.d.ts.map +1 -0
  22. package/dist/components/indicator/utils.js +104 -0
  23. package/dist/components/indicator/utils.js.map +1 -0
  24. package/dist/components/pager/DivPager.d.ts +22 -0
  25. package/dist/components/pager/DivPager.d.ts.map +1 -0
  26. package/dist/components/pager/DivPager.js +269 -0
  27. package/dist/components/pager/DivPager.js.map +1 -0
  28. package/dist/components/pager/index.d.ts +3 -0
  29. package/dist/components/pager/index.d.ts.map +1 -0
  30. package/dist/components/pager/index.js +2 -0
  31. package/dist/components/pager/index.js.map +1 -0
  32. package/dist/components/pager/utils.d.ts +96 -0
  33. package/dist/components/pager/utils.d.ts.map +1 -0
  34. package/dist/components/pager/utils.js +142 -0
  35. package/dist/components/pager/utils.js.map +1 -0
  36. package/dist/components/state/DivState.d.ts +11 -12
  37. package/dist/components/state/DivState.d.ts.map +1 -1
  38. package/dist/components/state/DivState.js +263 -35
  39. package/dist/components/state/DivState.js.map +1 -1
  40. package/dist/components/utilities/Background.d.ts.map +1 -1
  41. package/dist/components/utilities/Background.js +4 -3
  42. package/dist/components/utilities/Background.js.map +1 -1
  43. package/dist/components/utilities/Outer.d.ts.map +1 -1
  44. package/dist/components/utilities/Outer.js +175 -78
  45. package/dist/components/utilities/Outer.js.map +1 -1
  46. package/dist/context/DivStateScopeContext.d.ts +18 -0
  47. package/dist/context/DivStateScopeContext.d.ts.map +1 -0
  48. package/dist/context/DivStateScopeContext.js +7 -0
  49. package/dist/context/DivStateScopeContext.js.map +1 -0
  50. package/dist/context/PagerContext.d.ts +30 -0
  51. package/dist/context/PagerContext.d.ts.map +1 -0
  52. package/dist/context/PagerContext.js +76 -0
  53. package/dist/context/PagerContext.js.map +1 -0
  54. package/dist/context/index.d.ts +1 -0
  55. package/dist/context/index.d.ts.map +1 -1
  56. package/dist/context/index.js +1 -0
  57. package/dist/context/index.js.map +1 -1
  58. package/dist/hooks/useAppearanceTransition.d.ts +86 -0
  59. package/dist/hooks/useAppearanceTransition.d.ts.map +1 -0
  60. package/dist/hooks/useAppearanceTransition.js +490 -0
  61. package/dist/hooks/useAppearanceTransition.js.map +1 -0
  62. package/dist/hooks/useChangeBoundsTransition.d.ts +46 -0
  63. package/dist/hooks/useChangeBoundsTransition.d.ts.map +1 -0
  64. package/dist/hooks/useChangeBoundsTransition.js +151 -0
  65. package/dist/hooks/useChangeBoundsTransition.js.map +1 -0
  66. package/dist/utils/configureChangeBoundsLayout.d.ts +11 -0
  67. package/dist/utils/configureChangeBoundsLayout.d.ts.map +1 -0
  68. package/dist/utils/configureChangeBoundsLayout.js +65 -0
  69. package/dist/utils/configureChangeBoundsLayout.js.map +1 -0
  70. package/dist/utils/flattenTransition.d.ts +5 -0
  71. package/dist/utils/flattenTransition.d.ts.map +1 -0
  72. package/dist/utils/flattenTransition.js +27 -0
  73. package/dist/utils/flattenTransition.js.map +1 -0
  74. package/package.json +3 -1
  75. package/src/DivKit.tsx +131 -5
  76. package/src/components/DivComponent.tsx +8 -2
  77. package/src/components/README.md +59 -5
  78. package/src/components/index.ts +4 -0
  79. package/src/components/indicator/DivIndicator.tsx +175 -0
  80. package/src/components/indicator/index.ts +2 -0
  81. package/src/components/indicator/utils.ts +149 -0
  82. package/src/components/pager/DivPager.tsx +393 -0
  83. package/src/components/pager/index.ts +2 -0
  84. package/src/components/pager/utils.ts +214 -0
  85. package/src/components/state/DivState.tsx +308 -39
  86. package/src/components/utilities/Background.tsx +4 -3
  87. package/src/components/utilities/Outer.tsx +192 -75
  88. package/src/context/DivStateScopeContext.tsx +23 -0
  89. package/src/context/PagerContext.tsx +108 -0
  90. package/src/context/index.ts +8 -0
  91. package/src/hooks/useAppearanceTransition.ts +621 -0
  92. package/src/hooks/useChangeBoundsTransition.ts +193 -0
  93. package/src/types/indicator.d.ts +32 -0
  94. package/src/types/pager.d.ts +36 -0
  95. package/src/types/shape.d.ts +26 -0
  96. package/src/utils/configureChangeBoundsLayout.ts +74 -0
  97. package/src/utils/flattenTransition.ts +36 -0
@@ -1,18 +1,22 @@
1
- import React, { ReactNode, useMemo, useRef, useCallback } from 'react';
2
- import { View, Pressable, Animated, ViewStyle, StyleSheet, Easing, EasingFunction } from 'react-native';
1
+ import React, { ReactNode, useMemo, useRef, useCallback, useState } from 'react';
2
+ import { View, Pressable, Animated, ViewStyle, StyleSheet, Easing, EasingFunction, LayoutChangeEvent } from 'react-native';
3
3
  import type { ComponentContext } from '../../types/componentContext';
4
4
  import type { DivBaseData } from '../../types/base';
5
- import type { Visibility } from '../../types/base';
5
+ import type { Visibility, AppearanceTransition, TransitionChange } from '../../types/base';
6
6
  import type { FixedSize, MatchParentSize, WrapContentSize } from '../../types/sizes';
7
7
  import type { MaybeMissing } from '../../expressions/json';
8
8
  import type { Animation } from '../../types/animation';
9
9
  import type { Interpolation } from '../../../typings/common';
10
10
  import { useDerivedFromVarsSimple } from '../../hooks/useDerivedFromVars';
11
11
  import { useActionHandler, useHasActions } from '../../hooks/useAction';
12
+ import { useAppearanceTransition } from '../../hooks/useAppearanceTransition';
13
+ import { useChangeBoundsTransition } from '../../hooks/useChangeBoundsTransition';
12
14
  import { useDivKitContext } from '../../context/DivKitContext';
13
15
  import { useLayoutParams } from '../../context/LayoutParamsContext';
16
+ import { useDivStateScopeOptional } from '../../context/DivStateScopeContext';
14
17
  import { Background } from './Background';
15
18
  import { flattenAnimation } from '../../utils/flattenAnimation';
19
+ import { configureChangeBoundsLayout } from '../../utils/configureChangeBoundsLayout';
16
20
 
17
21
  function resolveAlignSelf(
18
22
  alignment: string | undefined,
@@ -122,6 +126,7 @@ export function Outer<T extends DivBaseData = DivBaseData>({
122
126
  const { direction } = useDivKitContext();
123
127
  const layoutParams = useLayoutParams();
124
128
  const { json, variables } = componentContext;
129
+ const testID = (json as any).id as string | undefined;
125
130
 
126
131
  // Only use reactive hooks for truly dynamic properties (visibility, alpha)
127
132
  const visibility = useDerivedFromVarsSimple<Visibility>(json.visibility || 'visible', variables || new Map());
@@ -194,20 +199,72 @@ export function Outer<T extends DivBaseData = DivBaseData>({
194
199
  Animated.parallel(anims).start();
195
200
  }, [parsedAnimations, animOpacity, animScale]);
196
201
 
197
- // Early return for gone visibility
198
- if (visibility === 'gone') {
199
- return null;
200
- }
202
+ // Appearance transitions (transition_in / transition_out) and change_bounds (transition_change)
203
+ const transitionIn = (json as any).transition_in as MaybeMissing<AppearanceTransition> | undefined;
204
+ const transitionOut = (json as any).transition_out as MaybeMissing<AppearanceTransition> | undefined;
205
+ const transitionChange = (json as any).transition_change as MaybeMissing<TransitionChange> | undefined;
206
+ const hasAppearanceTransitions = Boolean(transitionIn || transitionOut);
207
+ const stateScope = useDivStateScopeOptional();
208
+ const insideDivState = Boolean(stateScope);
209
+
210
+ // For neighbors: when WE appear/collapse, queue a coarse LayoutAnimation so they reflow smoothly.
211
+ const triggerChangeBoundsForNeighbors = useCallback(() => {
212
+ configureChangeBoundsLayout(transitionChange);
213
+ }, [transitionChange]);
214
+
215
+ // FLIP-based bounds animation for THIS element: tracks layout deltas and animates via
216
+ // transform (translate+scale) so the spec's interpolator/cubic-bezier actually applies.
217
+ const bounds = useChangeBoundsTransition({
218
+ transitionChange
219
+ });
220
+
221
+ // Measured size for off-center pivot scale (translate-scale-translate emulation).
222
+ // We reuse the same onLayout for both FLIP and pivot calculations.
223
+ const [layoutSize, setLayoutSize] = useState<{ width: number; height: number } | null>(null);
224
+ const onLayoutMeasure = useCallback((e: LayoutChangeEvent) => {
225
+ const { width, height } = e.nativeEvent.layout;
226
+ setLayoutSize(prev => {
227
+ if (prev && prev.width === width && prev.height === height) return prev;
228
+ return { width, height };
229
+ });
230
+ bounds.onLayout(e);
231
+ }, [bounds]);
232
+
233
+ // Whether this element lives inside a DivState. If so, on state-change DivState will call
234
+ // our playOut (via the scope context) and await it before swapping children. In that mode we
235
+ // disable the visibility-driven first-mount transition_in (DivState will trigger playIn on
236
+ // the new children automatically through their mount-effect with auto-in mode).
237
+ const appearance = useAppearanceTransition({
238
+ visibility: visibility as Visibility,
239
+ transitionIn,
240
+ transitionOut,
241
+ enabled: hasAppearanceTransitions,
242
+ mode: insideDivState ? 'auto-in' : 'visibility',
243
+ onBeforeCollapse: triggerChangeBoundsForNeighbors,
244
+ onBeforeExpand: triggerChangeBoundsForNeighbors,
245
+ layoutWidth: layoutSize?.width,
246
+ layoutHeight: layoutSize?.height,
247
+ });
248
+
249
+ // Register transition_out player with the enclosing DivState scope so it can play before unmount.
250
+ React.useEffect(() => {
251
+ if (!stateScope || !appearance.hasTransitionOut) return;
252
+ return stateScope.registerTransitionOutPlayer(appearance.playOut);
253
+ }, [stateScope, appearance.hasTransitionOut, appearance.playOut]);
201
254
 
202
255
  // Build styles
203
256
  const containerStyle = useMemo(() => {
204
257
  const styles: ViewStyle = {};
205
258
 
206
259
  // Visibility (invisible = opacity 0, but still takes space)
207
- if (visibility === 'invisible') {
208
- styles.opacity = 0;
209
- } else if (typeof alpha === 'number' && alpha !== 1) {
210
- styles.opacity = Math.max(0, Math.min(1, alpha));
260
+ // When appearance transitions are present, opacity is driven by Animated values below,
261
+ // not by static styles otherwise the static value would override animation.
262
+ if (!hasAppearanceTransitions) {
263
+ if (visibility === 'invisible') {
264
+ styles.opacity = 0;
265
+ } else if (typeof alpha === 'number' && alpha !== 1) {
266
+ styles.opacity = Math.max(0, Math.min(1, alpha));
267
+ }
211
268
  }
212
269
 
213
270
  // Resolve effective alignment (explicit > parent fallback > 'start')
@@ -435,7 +492,7 @@ export function Outer<T extends DivBaseData = DivBaseData>({
435
492
  }
436
493
 
437
494
  return styles;
438
- }, [visibility, alpha, width, height, paddings, margins, background, border, direction, layoutParams, alignmentHorizontal, alignmentVertical]);
495
+ }, [visibility, alpha, width, height, paddings, margins, background, border, direction, layoutParams, alignmentHorizontal, alignmentVertical, hasAppearanceTransitions]);
439
496
 
440
497
  const finalStyle = useMemo(() => {
441
498
  return StyleSheet.flatten([containerStyle, customStyle]);
@@ -452,83 +509,143 @@ export function Outer<T extends DivBaseData = DivBaseData>({
452
509
  return res;
453
510
  }, [finalStyle]);
454
511
 
455
- // Render with actions and animation
456
- if (hasActions) {
457
- const hasAnimation = parsedAnimations.length > 0;
458
-
459
- if (hasAnimation) {
460
- // Split finalStyle into outer (layout) and inner (visual) styles
461
- // Margins must be on Pressable to affect parent layout
462
- const {
463
- alignSelf, flexGrow, flexShrink, flexBasis,
464
- width: w, height: h, minWidth, maxWidth, minHeight, maxHeight,
465
- marginTop, marginBottom, marginLeft, marginRight,
466
- ...innerStyle
467
- } = (finalStyle || {}) as any;
468
-
469
- const outerStyle: ViewStyle = {};
470
- if (alignSelf !== undefined) outerStyle.alignSelf = alignSelf;
471
- if (flexGrow !== undefined) outerStyle.flexGrow = flexGrow;
472
- if (flexShrink !== undefined) outerStyle.flexShrink = flexShrink;
473
- if (flexBasis !== undefined) outerStyle.flexBasis = flexBasis;
474
- if (w !== undefined) outerStyle.width = w;
475
- if (h !== undefined) outerStyle.height = h;
476
- if (minWidth !== undefined) outerStyle.minWidth = minWidth;
477
- if (maxWidth !== undefined) outerStyle.maxWidth = maxWidth;
478
- if (minHeight !== undefined) outerStyle.minHeight = minHeight;
479
- if (maxHeight !== undefined) outerStyle.maxHeight = maxHeight;
480
- if (marginTop !== undefined) outerStyle.marginTop = marginTop;
481
- if (marginBottom !== undefined) outerStyle.marginBottom = marginBottom;
482
- if (marginLeft !== undefined) outerStyle.marginLeft = marginLeft;
483
- if (marginRight !== undefined) outerStyle.marginRight = marginRight;
484
-
485
- // Build animated style from inner (visual) properties
486
- const shouldFillInner =
487
- w !== undefined ||
488
- h !== undefined ||
489
- minWidth !== undefined ||
490
- maxWidth !== undefined ||
491
- minHeight !== undefined ||
492
- maxHeight !== undefined ||
493
- flexGrow !== undefined ||
494
- flexShrink !== undefined ||
495
- flexBasis !== undefined;
496
-
497
- const animatedStyle: any = shouldFillInner
498
- ? { ...innerStyle, flex: 1 }
499
- : { ...innerStyle };
500
-
501
- if (hasFadeAnimation) {
502
- const staticOpacity = animatedStyle.opacity;
503
- if (staticOpacity !== undefined && staticOpacity !== 1) {
504
- animatedStyle.opacity = Animated.multiply(animOpacity, staticOpacity);
505
- } else {
506
- animatedStyle.opacity = animOpacity;
507
- }
508
- }
512
+ // Collapsed via appearance transitions (or static 'gone' without transitions) — render nothing
513
+ if (appearance.collapsed) {
514
+ return null;
515
+ }
516
+ if (!hasAppearanceTransitions && visibility === 'gone') {
517
+ return null;
518
+ }
519
+
520
+ const hasActionAnim = parsedAnimations.length > 0;
521
+ const hasTransitionChange = Boolean(transitionChange);
522
+ const needsAnimatedWrapper = hasActionAnim || hasAppearanceTransitions || hasTransitionChange;
523
+
524
+ if (needsAnimatedWrapper) {
525
+ // Split finalStyle into outer (layout) and inner (visual) styles.
526
+ // Margins/sizing/alignSelf live on the wrapper that participates in parent layout (Pressable or outer View),
527
+ // visual styles go inside Animated.View.
528
+ const {
529
+ alignSelf, flexGrow, flexShrink, flexBasis,
530
+ width: w, height: h, minWidth, maxWidth, minHeight, maxHeight,
531
+ marginTop, marginBottom, marginLeft, marginRight,
532
+ ...innerStyle
533
+ } = (finalStyle || {}) as any;
534
+
535
+ const outerStyle: ViewStyle = {};
536
+ if (alignSelf !== undefined) outerStyle.alignSelf = alignSelf;
537
+ if (flexGrow !== undefined) outerStyle.flexGrow = flexGrow;
538
+ if (flexShrink !== undefined) outerStyle.flexShrink = flexShrink;
539
+ if (flexBasis !== undefined) outerStyle.flexBasis = flexBasis;
540
+ if (w !== undefined) outerStyle.width = w;
541
+ if (h !== undefined) outerStyle.height = h;
542
+ if (minWidth !== undefined) outerStyle.minWidth = minWidth;
543
+ if (maxWidth !== undefined) outerStyle.maxWidth = maxWidth;
544
+ if (minHeight !== undefined) outerStyle.minHeight = minHeight;
545
+ if (maxHeight !== undefined) outerStyle.maxHeight = maxHeight;
546
+ if (marginTop !== undefined) outerStyle.marginTop = marginTop;
547
+ if (marginBottom !== undefined) outerStyle.marginBottom = marginBottom;
548
+ if (marginLeft !== undefined) outerStyle.marginLeft = marginLeft;
549
+ if (marginRight !== undefined) outerStyle.marginRight = marginRight;
550
+
551
+ const shouldFillInner =
552
+ w !== undefined ||
553
+ h !== undefined ||
554
+ minWidth !== undefined ||
555
+ maxWidth !== undefined ||
556
+ minHeight !== undefined ||
557
+ maxHeight !== undefined ||
558
+ flexGrow !== undefined ||
559
+ flexShrink !== undefined ||
560
+ flexBasis !== undefined;
561
+
562
+ const animatedStyle: any = shouldFillInner
563
+ ? { ...innerStyle, flex: 1 }
564
+ : { ...innerStyle };
565
+
566
+ // Compose opacity chain: static * action_animation * appearance_transition
567
+ let opacityChain: any = undefined;
568
+ const staticOpacity = animatedStyle.opacity;
569
+ if (staticOpacity !== undefined && staticOpacity !== 1) {
570
+ opacityChain = staticOpacity;
571
+ }
572
+ if (hasActionAnim && hasFadeAnimation) {
573
+ opacityChain = opacityChain !== undefined
574
+ ? Animated.multiply(animOpacity, opacityChain)
575
+ : animOpacity;
576
+ }
577
+ if (hasAppearanceTransitions) {
578
+ opacityChain = opacityChain !== undefined
579
+ ? Animated.multiply(appearance.opacity as Animated.Value, opacityChain)
580
+ : appearance.opacity;
581
+ }
582
+ if (opacityChain !== undefined) {
583
+ animatedStyle.opacity = opacityChain;
584
+ }
509
585
 
510
- if (hasScaleAnimation) {
511
- const existingTransform = animatedStyle.transform || [];
512
- animatedStyle.transform = [...existingTransform, { scale: animScale }];
586
+ // Compose transform array. Order matters: in RN transforms apply right-to-left
587
+ // (the LAST element is applied first to the point, then earlier ones).
588
+ // We want FLIP (bounds) to wrap everything → put it FIRST (so it applies last on top
589
+ // of action_animation/appearance). Static transforms from style come first too.
590
+ const transforms: any[] = animatedStyle.transform ? [...animatedStyle.transform] : [];
591
+ if (hasTransitionChange && bounds.transform.length > 0) {
592
+ for (const t of bounds.transform) {
593
+ transforms.push(t);
594
+ }
595
+ }
596
+ if (hasActionAnim && hasScaleAnimation) {
597
+ transforms.push({ scale: animScale });
598
+ }
599
+ if (hasAppearanceTransitions && appearance.transform.length > 0) {
600
+ for (const t of appearance.transform) {
601
+ transforms.push(t);
513
602
  }
603
+ }
604
+ if (transforms.length > 0) {
605
+ animatedStyle.transform = transforms;
606
+ }
514
607
 
608
+ const innerNeedsOnLayout = hasAppearanceTransitions && !hasTransitionChange;
609
+ const outerNeedsOnLayout = hasTransitionChange;
610
+ const content = (
611
+ <Animated.View
612
+ style={animatedStyle}
613
+ onLayout={innerNeedsOnLayout ? onLayoutMeasure : undefined}
614
+ >
615
+ <Background layers={background as any} style={borderStyle} />
616
+ {children}
617
+ </Animated.View>
618
+ );
619
+
620
+ if (hasActions) {
515
621
  return (
516
622
  <Pressable
517
623
  onPress={handlePress}
518
624
  onPressIn={onPressIn}
519
625
  onPressOut={onPressOut}
520
626
  style={outerStyle}
627
+ onLayout={outerNeedsOnLayout ? onLayoutMeasure : undefined}
628
+ testID={testID}
521
629
  >
522
- <Animated.View style={animatedStyle}>
523
- <Background layers={background as any} style={borderStyle} />
524
- {children}
525
- </Animated.View>
630
+ {content}
526
631
  </Pressable>
527
632
  );
528
633
  }
529
634
 
530
635
  return (
531
- <Pressable onPress={handlePress} style={finalStyle}>
636
+ <View
637
+ style={outerStyle}
638
+ onLayout={outerNeedsOnLayout ? onLayoutMeasure : undefined}
639
+ testID={testID}
640
+ >
641
+ {content}
642
+ </View>
643
+ );
644
+ }
645
+
646
+ if (hasActions) {
647
+ return (
648
+ <Pressable onPress={handlePress} style={finalStyle} testID={testID}>
532
649
  <Background layers={background as any} style={borderStyle} />
533
650
  {children}
534
651
  </Pressable>
@@ -536,7 +653,7 @@ export function Outer<T extends DivBaseData = DivBaseData>({
536
653
  }
537
654
 
538
655
  return (
539
- <View style={finalStyle}>
656
+ <View style={finalStyle} testID={testID}>
540
657
  <Background layers={background as any} style={borderStyle} />
541
658
  {children}
542
659
  </View>
@@ -0,0 +1,23 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ /**
4
+ * Scoped context provided by a single DivState wrapping its currently-rendered children.
5
+ *
6
+ * Allows children that declare `transition_out` (via Outer + useAppearanceTransition) to
7
+ * register a playOut callback so that DivState can await all of them in parallel
8
+ * before swapping to a new state. Analogous to Web's stateCtx.registerChildWithTransitionOut.
9
+ */
10
+ export interface DivStateScopeValue {
11
+ /**
12
+ * Register a child's transition_out player.
13
+ * Returns an unregister callback (call from cleanup).
14
+ */
15
+ registerTransitionOutPlayer(play: () => Promise<void>): () => void;
16
+ }
17
+
18
+ export const DivStateScopeContext = createContext<DivStateScopeValue | null>(null);
19
+
20
+ /** Get the nearest DivState scope, or null if outside any DivState. */
21
+ export function useDivStateScopeOptional(): DivStateScopeValue | null {
22
+ return useContext(DivStateScopeContext);
23
+ }
@@ -0,0 +1,108 @@
1
+ import { createContext, useContext, useCallback, useMemo, useRef, ReactNode, createElement } from 'react';
2
+ import type { PagerData, PagerListener, PagerRegisterData } from '../types/componentContext';
3
+
4
+ /**
5
+ * PagerContext — shared registry for pagers and their indicators.
6
+ *
7
+ * Based on Web Root.svelte: registerPager / listenPager.
8
+ *
9
+ * - Pager registers itself with `registerPager(pagerId)` and gets back an object with
10
+ * `update(data)` (called whenever current item / size changes) and `destroy()`.
11
+ * - Indicator subscribes to a pager via `listenPager(pagerId, cb)`.
12
+ * - Subscribers receive the current pager state immediately if it's already known
13
+ * (so indicator works regardless of mount order).
14
+ */
15
+ export interface PagerContextValue {
16
+ registerPager(pagerId: string | undefined): PagerRegisterData;
17
+ listenPager(pagerId: string | undefined, listener: PagerListener): () => void;
18
+ }
19
+
20
+ export const PagerContext = createContext<PagerContextValue | null>(null);
21
+
22
+ export function usePagerContextOptional(): PagerContextValue | null {
23
+ return useContext(PagerContext);
24
+ }
25
+
26
+ export function usePagerContext(): PagerContextValue {
27
+ const context = useContext(PagerContext);
28
+ if (!context) {
29
+ throw new Error('usePagerContext must be used within PagerContext.Provider');
30
+ }
31
+ return context;
32
+ }
33
+
34
+ export interface PagerProviderProps {
35
+ children: ReactNode;
36
+ }
37
+
38
+ /**
39
+ * Provider that holds pager state in stable refs. Mirrors the Web behaviour
40
+ * where registration / listening is keyed by pagerId (with `undefined` allowed
41
+ * so indicators without an explicit pager_id still work).
42
+ */
43
+ export function PagerProvider({ children }: PagerProviderProps) {
44
+ const pagersRef = useRef<Map<string | undefined, PagerData | null>>(new Map());
45
+ const listenersRef = useRef<Map<string | undefined, PagerListener[]>>(new Map());
46
+
47
+ const notify = useCallback((pagerId: string | undefined, data: PagerData) => {
48
+ const list = listenersRef.current.get(pagerId);
49
+ if (!list) return;
50
+ for (const fn of list) {
51
+ try {
52
+ fn(data);
53
+ } catch (err) {
54
+ // eslint-disable-next-line no-console
55
+ console.error('[DivKit Pager] listener error', err);
56
+ }
57
+ }
58
+ }, []);
59
+
60
+ const registerPager = useCallback((pagerId: string | undefined): PagerRegisterData => {
61
+ return {
62
+ update: (data: PagerData) => {
63
+ pagersRef.current.set(pagerId, data);
64
+ notify(pagerId, data);
65
+ },
66
+ destroy: () => {
67
+ pagersRef.current.set(pagerId, null);
68
+ }
69
+ };
70
+ }, [notify]);
71
+
72
+ const listenPager = useCallback(
73
+ (pagerId: string | undefined, listener: PagerListener): (() => void) => {
74
+ let list = listenersRef.current.get(pagerId);
75
+ if (!list) {
76
+ list = [];
77
+ listenersRef.current.set(pagerId, list);
78
+ }
79
+ list.push(listener);
80
+
81
+ // Replay last known state so indicator gets current pager position immediately
82
+ const current = pagersRef.current.get(pagerId);
83
+ if (current) {
84
+ try {
85
+ listener(current);
86
+ } catch (err) {
87
+ // eslint-disable-next-line no-console
88
+ console.error('[DivKit Pager] listener error', err);
89
+ }
90
+ }
91
+
92
+ return () => {
93
+ const arr = listenersRef.current.get(pagerId);
94
+ if (!arr) return;
95
+ const idx = arr.indexOf(listener);
96
+ if (idx >= 0) arr.splice(idx, 1);
97
+ };
98
+ },
99
+ []
100
+ );
101
+
102
+ const value = useMemo<PagerContextValue>(
103
+ () => ({ registerPager, listenPager }),
104
+ [registerPager, listenPager]
105
+ );
106
+
107
+ return createElement(PagerContext.Provider, { value }, children);
108
+ }
@@ -22,3 +22,11 @@ export {
22
22
  useIsEnabled,
23
23
  type EnabledContextValue
24
24
  } from './EnabledContext';
25
+
26
+ export {
27
+ PagerContext,
28
+ PagerProvider,
29
+ usePagerContext,
30
+ usePagerContextOptional,
31
+ type PagerContextValue
32
+ } from './PagerContext';