react-native-terra-ui 0.11.16 → 0.12.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 (107) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/lib/module/components/accordion/Accordion.js +109 -0
  3. package/lib/module/components/accordion/Accordion.js.map +1 -0
  4. package/lib/module/components/accordion/context.js +34 -0
  5. package/lib/module/components/accordion/context.js.map +1 -0
  6. package/lib/module/components/accordion/hooks/use-accordion-core.js +46 -0
  7. package/lib/module/components/accordion/hooks/use-accordion-core.js.map +1 -0
  8. package/lib/module/components/accordion/index.js +5 -0
  9. package/lib/module/components/accordion/index.js.map +1 -0
  10. package/lib/module/components/accordion/parts/AccordionContent.js +98 -0
  11. package/lib/module/components/accordion/parts/AccordionContent.js.map +1 -0
  12. package/lib/module/components/accordion/parts/AccordionIndicator.js +60 -0
  13. package/lib/module/components/accordion/parts/AccordionIndicator.js.map +1 -0
  14. package/lib/module/components/accordion/parts/AccordionItem.js +53 -0
  15. package/lib/module/components/accordion/parts/AccordionItem.js.map +1 -0
  16. package/lib/module/components/accordion/parts/AccordionTrigger.js +85 -0
  17. package/lib/module/components/accordion/parts/AccordionTrigger.js.map +1 -0
  18. package/lib/module/components/accordion/types.js +4 -0
  19. package/lib/module/components/accordion/types.js.map +1 -0
  20. package/lib/module/components/accordion/utils.js +15 -0
  21. package/lib/module/components/accordion/utils.js.map +1 -0
  22. package/lib/module/components/badge/Badge.js +219 -64
  23. package/lib/module/components/badge/Badge.js.map +1 -1
  24. package/lib/module/components/badge/WithBadge.js +94 -0
  25. package/lib/module/components/badge/WithBadge.js.map +1 -0
  26. package/lib/module/components/badge/index.js +1 -0
  27. package/lib/module/components/badge/index.js.map +1 -1
  28. package/lib/module/components/badge/utils.js +3 -3
  29. package/lib/module/components/header/parts/HeaderBar.js +34 -7
  30. package/lib/module/components/header/parts/HeaderBar.js.map +1 -1
  31. package/lib/module/components/index.js +1 -0
  32. package/lib/module/components/index.js.map +1 -1
  33. package/lib/module/components/page-indicator/hooks/use-page-indicator-window.js +60 -0
  34. package/lib/module/components/page-indicator/hooks/use-page-indicator-window.js.map +1 -0
  35. package/lib/module/components/page-indicator/utils.js +60 -0
  36. package/lib/module/components/page-indicator/utils.js.map +1 -1
  37. package/lib/module/components/page-indicator/variants/DotIndicator/index.js +13 -2
  38. package/lib/module/components/page-indicator/variants/DotIndicator/index.js.map +1 -1
  39. package/lib/module/components/page-indicator/variants/PillIndicator/index.js +29 -7
  40. package/lib/module/components/page-indicator/variants/PillIndicator/index.js.map +1 -1
  41. package/lib/typescript/src/components/accordion/Accordion.d.ts +33 -0
  42. package/lib/typescript/src/components/accordion/Accordion.d.ts.map +1 -0
  43. package/lib/typescript/src/components/accordion/context.d.ts +25 -0
  44. package/lib/typescript/src/components/accordion/context.d.ts.map +1 -0
  45. package/lib/typescript/src/components/accordion/hooks/use-accordion-core.d.ts +11 -0
  46. package/lib/typescript/src/components/accordion/hooks/use-accordion-core.d.ts.map +1 -0
  47. package/lib/typescript/src/components/accordion/index.d.ts +8 -0
  48. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -0
  49. package/lib/typescript/src/components/accordion/parts/AccordionContent.d.ts +8 -0
  50. package/lib/typescript/src/components/accordion/parts/AccordionContent.d.ts.map +1 -0
  51. package/lib/typescript/src/components/accordion/parts/AccordionIndicator.d.ts +16 -0
  52. package/lib/typescript/src/components/accordion/parts/AccordionIndicator.d.ts.map +1 -0
  53. package/lib/typescript/src/components/accordion/parts/AccordionItem.d.ts +16 -0
  54. package/lib/typescript/src/components/accordion/parts/AccordionItem.d.ts.map +1 -0
  55. package/lib/typescript/src/components/accordion/parts/AccordionTrigger.d.ts +11 -0
  56. package/lib/typescript/src/components/accordion/parts/AccordionTrigger.d.ts.map +1 -0
  57. package/lib/typescript/src/components/accordion/types.d.ts +65 -0
  58. package/lib/typescript/src/components/accordion/types.d.ts.map +1 -0
  59. package/lib/typescript/src/components/accordion/utils.d.ts +8 -0
  60. package/lib/typescript/src/components/accordion/utils.d.ts.map +1 -0
  61. package/lib/typescript/src/components/badge/Badge.d.ts +13 -9
  62. package/lib/typescript/src/components/badge/Badge.d.ts.map +1 -1
  63. package/lib/typescript/src/components/badge/WithBadge.d.ts +18 -0
  64. package/lib/typescript/src/components/badge/WithBadge.d.ts.map +1 -0
  65. package/lib/typescript/src/components/badge/index.d.ts +2 -1
  66. package/lib/typescript/src/components/badge/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/badge/types.d.ts +35 -12
  68. package/lib/typescript/src/components/badge/types.d.ts.map +1 -1
  69. package/lib/typescript/src/components/badge/utils.d.ts +3 -3
  70. package/lib/typescript/src/components/badge/utils.d.ts.map +1 -1
  71. package/lib/typescript/src/components/header/parts/HeaderBar.d.ts.map +1 -1
  72. package/lib/typescript/src/components/index.d.ts +1 -0
  73. package/lib/typescript/src/components/index.d.ts.map +1 -1
  74. package/lib/typescript/src/components/page-indicator/hooks/use-page-indicator-window.d.ts +34 -0
  75. package/lib/typescript/src/components/page-indicator/hooks/use-page-indicator-window.d.ts.map +1 -0
  76. package/lib/typescript/src/components/page-indicator/utils.d.ts +41 -0
  77. package/lib/typescript/src/components/page-indicator/utils.d.ts.map +1 -1
  78. package/lib/typescript/src/components/page-indicator/variants/DotIndicator/index.d.ts.map +1 -1
  79. package/lib/typescript/src/components/page-indicator/variants/PillIndicator/index.d.ts +1 -1
  80. package/lib/typescript/src/components/page-indicator/variants/PillIndicator/index.d.ts.map +1 -1
  81. package/package.json +1 -1
  82. package/src/components/accordion/Accordion.tsx +116 -0
  83. package/src/components/accordion/context.ts +45 -0
  84. package/src/components/accordion/hooks/use-accordion-core.ts +51 -0
  85. package/src/components/accordion/index.ts +14 -0
  86. package/src/components/accordion/parts/AccordionContent.tsx +96 -0
  87. package/src/components/accordion/parts/AccordionIndicator.tsx +67 -0
  88. package/src/components/accordion/parts/AccordionItem.tsx +55 -0
  89. package/src/components/accordion/parts/AccordionTrigger.tsx +88 -0
  90. package/src/components/accordion/types.ts +71 -0
  91. package/src/components/accordion/utils.ts +12 -0
  92. package/src/components/badge/Badge.tsx +177 -59
  93. package/src/components/badge/WithBadge.tsx +79 -0
  94. package/src/components/badge/index.ts +2 -1
  95. package/src/components/badge/types.ts +36 -12
  96. package/src/components/badge/utils.ts +3 -3
  97. package/src/components/header/parts/HeaderBar.tsx +41 -8
  98. package/src/components/index.ts +1 -0
  99. package/src/components/page-indicator/hooks/use-page-indicator-window.ts +84 -0
  100. package/src/components/page-indicator/utils.ts +70 -0
  101. package/src/components/page-indicator/variants/DotIndicator/index.tsx +12 -1
  102. package/src/components/page-indicator/variants/PillIndicator/index.tsx +32 -2
  103. package/lib/module/components/badge/parts/BadgeIndicator.js +0 -212
  104. package/lib/module/components/badge/parts/BadgeIndicator.js.map +0 -1
  105. package/lib/typescript/src/components/badge/parts/BadgeIndicator.d.ts +0 -19
  106. package/lib/typescript/src/components/badge/parts/BadgeIndicator.d.ts.map +0 -1
  107. package/src/components/badge/parts/BadgeIndicator.tsx +0 -178
@@ -0,0 +1,79 @@
1
+ import { type ComponentRef, forwardRef } from "react";
2
+ import { View } from "react-native";
3
+
4
+ import { StyleSheet } from "react-native-unistyles";
5
+
6
+ import { Badge } from "./Badge";
7
+ import type { WithBadgeProps } from "./types";
8
+ import { getAnchorPosition } from "./utils";
9
+
10
+ /**
11
+ * Pins a `Badge` to one corner of the element it wraps.
12
+ *
13
+ * ```tsx
14
+ * <WithBadge placement="top-right" color="danger" label={3}>
15
+ * <Toolbar.Button icon="bell" accessibilityLabel="Notifications" />
16
+ * </WithBadge>
17
+ * ```
18
+ *
19
+ * The child renders untouched — `WithBadge` contributes the positioning context
20
+ * and the overlay, nothing else. A round child usually wants a negative
21
+ * `offset`, since the corner being anchored to is the bounding box's, not the
22
+ * circle's. For a badge that sits inline beside text, use `Badge` on its own.
23
+ */
24
+ const WithBadge = forwardRef<ComponentRef<typeof View>, WithBadgeProps>(function WithBadge(
25
+ {
26
+ children,
27
+ placement = "top-right",
28
+ label,
29
+ color = "danger",
30
+ size = "md",
31
+ offset = 0,
32
+ isVisible = true,
33
+ fullWidth = false,
34
+ accessibilityLabel,
35
+ style,
36
+ ...rest
37
+ },
38
+ ref
39
+ ) {
40
+ styles.useVariants({ fullWidth });
41
+
42
+ return (
43
+ // `style` last so an explicit `alignSelf` beats `fullWidth`.
44
+ <View ref={ref} style={[styles.wrapper, style]} {...rest}>
45
+ {children}
46
+ {isVisible ? (
47
+ <View pointerEvents="box-none" style={[styles.anchor, getAnchorPosition(placement, size, offset)]}>
48
+ <Badge label={label} color={color} size={size} ring accessibilityLabel={accessibilityLabel} />
49
+ </View>
50
+ ) : null}
51
+ </View>
52
+ );
53
+ });
54
+
55
+ export { WithBadge };
56
+
57
+ // ─── Styles ───────────────────────────────────────────────────────────────────
58
+ //
59
+ // The badge's insets are computed in `getAnchorPosition`, not declared here:
60
+ // `offset` is an open-ended number, so it could never be a variant condition,
61
+ // and the position involves no theme tokens at all. That leaves this stylesheet
62
+ // with only what genuinely varies declaratively.
63
+ const styles = StyleSheet.create(() => ({
64
+ wrapper: {
65
+ position: "relative",
66
+ variants: {
67
+ // `alignSelf: 'flex-start'` hugs the child, so the indicator anchors to
68
+ // the child's bounds rather than the surrounding container's. A child
69
+ // that was stretching needs `fullWidth` to go on doing so.
70
+ fullWidth: {
71
+ true: { alignSelf: "stretch" },
72
+ false: { alignSelf: "flex-start" },
73
+ },
74
+ },
75
+ },
76
+ anchor: {
77
+ position: "absolute",
78
+ },
79
+ }));
@@ -1,2 +1,3 @@
1
1
  export { Badge } from "./Badge";
2
- export type { BadgeColor, BadgePlacement, BadgeProps, BadgeSize } from "./types";
2
+ export type { BadgeColor, BadgePlacement, BadgeProps, BadgeSize, WithBadgeProps } from "./types";
3
+ export { WithBadge } from "./WithBadge";
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
2
  import type { ViewProps } from "react-native";
3
3
 
4
- /** Corner of the wrapped child the indicator is anchored to. */
4
+ /** Corner of the wrapped child a `WithBadge` anchors its badge to. */
5
5
  export type BadgePlacement = "top-right" | "top-left" | "bottom-right" | "bottom-left";
6
6
 
7
7
  /** Scale of the dot, pill, and label. */
@@ -9,7 +9,7 @@ export type BadgeSize = "sm" | "md" | "lg";
9
9
 
10
10
  /**
11
11
  * Semantic color scheme, or a raw CSS color literal (`#fff`, `rgb(...)`, `hsl(...)`).
12
- * A literal paints the indicator with that exact value and derives a readable
12
+ * A literal paints the badge with that exact value and derives a readable
13
13
  * foreground for the label.
14
14
  */
15
15
  export type BadgeColor =
@@ -21,37 +21,61 @@ export type BadgeColor =
21
21
  // literals last so scheme-name autocomplete still surfaces
22
22
  | (string & {});
23
23
 
24
- export interface BadgeProps extends ViewProps {
25
- /** The element being badged. Rendered untouched; `Badge` only adds the overlay. */
24
+ export interface BadgeProps extends Omit<ViewProps, "children"> {
25
+ /**
26
+ * Text in the badge. Omit it (or pass an empty string) for a dot.
27
+ * `0` renders as a label; a caller hiding a zero count leaves the badge out.
28
+ */
29
+ label?: string | number;
30
+ /** Semantic scheme or raw color literal. Defaults to `'danger'`. */
31
+ color?: BadgeColor;
32
+ /** Scale of the badge. Defaults to `'md'`. */
33
+ size?: BadgeSize;
34
+ /**
35
+ * Draws a `surface.canvas` ring around the badge, so it stays legible over
36
+ * whatever it overlaps. `WithBadge` turns it on. A badge inline beside text
37
+ * overlaps nothing, so it defaults to `false`: on a card the ring would only
38
+ * read as an outline.
39
+ */
40
+ ring?: boolean;
41
+ /**
42
+ * Announced in place of the label. Defaults to the label's own text; an
43
+ * unlabelled dot is hidden from assistive technology.
44
+ */
45
+ accessibilityLabel?: string;
46
+ }
47
+
48
+ export interface WithBadgeProps extends ViewProps {
49
+ /** The element being badged. Rendered untouched; `WithBadge` only adds the overlay. */
26
50
  children: ReactNode;
27
51
  /**
28
- * Corner the indicator sits on. Defaults to `'top-right'`.
52
+ * Corner the badge sits on. Defaults to `'top-right'`.
29
53
  *
30
- * The indicator overhangs the child, so an ancestor that clips — a `Surface`
54
+ * The badge overhangs the child, so an ancestor that clips — a `Surface`
31
55
  * with a radius, or a `Screen` list row — will cut it off. Pad that container
32
- * by the overhang, or lift the `Badge` outside it.
56
+ * by the overhang, or lift the `WithBadge` outside it.
33
57
  */
34
58
  placement?: BadgePlacement;
35
59
  /**
36
- * Text in the indicator. Omit it (or pass an empty string) for a dot.
60
+ * Text in the badge. Omit it (or pass an empty string) for a dot.
37
61
  * `0` renders as a label — use `isVisible` to suppress a zero count.
38
62
  */
39
63
  label?: string | number;
40
64
  /** Semantic scheme or raw color literal. Defaults to `'danger'`. */
41
65
  color?: BadgeColor;
42
- /** Scale of the indicator. Defaults to `'md'`. */
66
+ /** Scale of the badge. Defaults to `'md'`. */
43
67
  size?: BadgeSize;
44
68
  /**
45
- * Nudges the indicator along both axes from the corner `placement` picked.
69
+ * Nudges the badge along both axes from the corner `placement` picked.
46
70
  * Positive moves it away from the child's centre, negative pulls it in.
47
71
  * A number applies to both axes; `{ x, y }` sets them separately.
48
72
  *
49
73
  * The corner it anchors to is the child's **bounding box**, so a round child
50
- * needs a negative offset to bring the indicator onto the visible edge — a
74
+ * needs a negative offset to bring the badge onto the visible edge — a
51
75
  * circle only meets its box at 45°, about `0.21 × width` away. Defaults to `0`.
52
76
  */
53
77
  offset?: number | { x?: number; y?: number };
54
- /** Renders the indicator. The child renders either way. Defaults to `true`. */
78
+ /** Renders the badge. The child renders either way. Defaults to `true`. */
55
79
  isVisible?: boolean;
56
80
  /**
57
81
  * Stretches the wrapper along its parent's cross axis, so a child that
@@ -10,7 +10,7 @@ export interface CustomBadgeColors {
10
10
  fg: string;
11
11
  }
12
12
 
13
- /** The indicator's paint for a raw literal: the literal itself, plus a readable label. */
13
+ /** The badge's paint for a raw literal: the literal itself, plus a readable label. */
14
14
  export function getCustomBadgeColors(literal: string): CustomBadgeColors {
15
15
  return { bg: literal, fg: readableOn(literal) };
16
16
  }
@@ -25,7 +25,7 @@ export function hasLabel(label: string | number | undefined): label is string |
25
25
  }
26
26
 
27
27
  /**
28
- * Outer diameter of the dot per size. The indicator's own stylesheet repeats
28
+ * Outer diameter of the dot per size. The badge's own stylesheet repeats
29
29
  * these as literals — the unistyles Babel plugin reads that object statically,
30
30
  * so it cannot reference this table — and `anchorOffsetsMatchDotRadius` in the
31
31
  * test suite is what keeps the two in step.
@@ -44,7 +44,7 @@ export interface AnchorPosition {
44
44
  }
45
45
 
46
46
  /**
47
- * Absolute insets for the indicator, in pure arithmetic rather than one
47
+ * Absolute insets for a `WithBadge`'s badge, in pure arithmetic rather than one
48
48
  * compound variant per (placement, size) cell: `offset` is an open-ended number
49
49
  * and could never be a variant condition, and nothing here touches the theme.
50
50
  *
@@ -1,5 +1,5 @@
1
1
  import { type ComponentProps, type ReactNode, useState } from "react";
2
- import { Text, View } from "react-native";
2
+ import { type LayoutChangeEvent, Text, View } from "react-native";
3
3
 
4
4
  import Animated from "react-native-reanimated";
5
5
  import { ScrollToTopGuard } from "react-native-screens/experimental";
@@ -38,6 +38,13 @@ export function HeaderBar({
38
38
  }: HeaderBarProps) {
39
39
  const { theme } = useUnistyles();
40
40
  const [windowInsets, setWindowInsets] = useState({ left: 0, right: 0 });
41
+ // Toolbar widths, so a centered title can stay clear of both sides.
42
+ const [leadingWidth, setLeadingWidth] = useState(0);
43
+ const [trailingWidth, setTrailingWidth] = useState(0);
44
+ const onLeadingLayout = (event: LayoutChangeEvent) => setLeadingWidth(event.nativeEvent.layout.width);
45
+ const onTrailingLayout = (event: LayoutChangeEvent) => setTrailingWidth(event.nativeEvent.layout.width);
46
+ const hasLeading = leading != null || dismissAction === "back";
47
+ const hasTrailing = trailing != null || dismissAction === "close";
41
48
  styles.useVariants({ titleAlignment });
42
49
  const dismiss = (
43
50
  <HeaderDismissButton dismissAction={dismissAction} navigation={navigation} onDismiss={onDismiss} />
@@ -62,8 +69,8 @@ export function HeaderBar({
62
69
  <View style={[styles.bar, styles.windowInsets(windowInsets.left, windowInsets.right)]}>
63
70
  <HeaderSurface color={color} animatedStyle={surfaceStyle} />
64
71
  <HeaderWindowInsets onInsetsChange={setWindowInsets} />
65
- {(leading != null || dismissAction === "back") && (
66
- <Toolbar>
72
+ {hasLeading && (
73
+ <Toolbar onLayout={onLeadingLayout}>
67
74
  {dismissAction === "back" && dismiss}
68
75
  {leading}
69
76
  </Toolbar>
@@ -71,7 +78,13 @@ export function HeaderBar({
71
78
  <Animated.View
72
79
  style={[
73
80
  styles.titleContainer,
74
- styles.titleInsets(windowInsets.left, windowInsets.right, titleAlignment),
81
+ styles.titleInsets(
82
+ windowInsets.left,
83
+ windowInsets.right,
84
+ hasLeading ? leadingWidth : 0,
85
+ hasTrailing ? trailingWidth : 0,
86
+ titleAlignment
87
+ ),
75
88
  titleStyle,
76
89
  ]}
77
90
  pointerEvents={isCustomTitle(title) ? "box-none" : "none"}
@@ -80,6 +93,7 @@ export function HeaderBar({
80
93
  ? String(title).trim().length > 0 && (
81
94
  <Text
82
95
  style={styles.title}
96
+ numberOfLines={1}
83
97
  maxFontSizeMultiplier={theme.typography.variants["title-md"].maxFontSizeMultiplier}
84
98
  >
85
99
  {title}
@@ -87,8 +101,8 @@ export function HeaderBar({
87
101
  )
88
102
  : title}
89
103
  </Animated.View>
90
- {(trailing != null || dismissAction === "close") && (
91
- <Toolbar style={styles.trailing}>
104
+ {hasTrailing && (
105
+ <Toolbar style={styles.trailing} onLayout={onTrailingLayout}>
92
106
  {dismissAction === "close" && dismiss}
93
107
  {trailing}
94
108
  </Toolbar>
@@ -103,8 +117,27 @@ const styles = StyleSheet.create((theme, runtime) => ({
103
117
  paddingLeft: Math.max(resolveScreenPadding(theme, runtime.breakpoint).x, left),
104
118
  paddingRight: Math.max(resolveScreenPadding(theme, runtime.breakpoint).x, right),
105
119
  }),
106
- titleInsets: (left: number, right: number, alignment: "center" | "left") =>
107
- alignment === "center" ? { left, right } : {},
120
+ // A centered title spans the region between the window controls and is inset
121
+ // by the same amount from both of its edges, so it stays centered in that
122
+ // region. The inset is whichever side reaches further in — bar padding plus
123
+ // that side's toolbar — so a long title truncates before either toolbar
124
+ // instead of running underneath it. A left-aligned title is in the flex row
125
+ // already and needs none of this.
126
+ titleInsets: (
127
+ left: number,
128
+ right: number,
129
+ leadingWidth: number,
130
+ trailingWidth: number,
131
+ alignment: "center" | "left"
132
+ ) => {
133
+ if (alignment !== "center") return {};
134
+ const padding = resolveScreenPadding(theme, runtime.breakpoint).x;
135
+ const gap = theme.spacing["3"];
136
+ const reachLeft = Math.max(padding, left) + (leadingWidth > 0 ? leadingWidth + gap : 0) - left;
137
+ const reachRight = Math.max(padding, right) + (trailingWidth > 0 ? trailingWidth + gap : 0) - right;
138
+ const inset = Math.max(reachLeft, reachRight);
139
+ return { left: left + inset, right: right + inset };
140
+ },
108
141
  bar: {
109
142
  height: theme.layout.header.height,
110
143
  flexDirection: "row",
@@ -1,3 +1,4 @@
1
+ export * from "./accordion";
1
2
  export * from "./avatar";
2
3
  export * from "./badge";
3
4
  export * from "./box";
@@ -0,0 +1,84 @@
1
+ import { useMemo, useState } from "react";
2
+
3
+ import { runOnJS, type SharedValue, useAnimatedReaction, useSharedValue } from "react-native-reanimated";
4
+
5
+ import { isPageIndicatorSharedValue, pageIndicatorMountedRange } from "../utils";
6
+
7
+ export interface PageIndicatorWindow {
8
+ /** First dot index to mount, inclusive. */
9
+ start: number;
10
+ /** Last dot index to mount, inclusive. */
11
+ end: number;
12
+ }
13
+
14
+ /**
15
+ * The slice of dots a variant should mount, kept in step with the active page.
16
+ *
17
+ * Both variants clip to a window of `maxVisible` slots and scroll a track
18
+ * behind it, so all but a handful of dots are drawn outside the viewport. This
19
+ * is what lets them skip building those: the range covers the visible window
20
+ * plus a generous overscan, and moves only when the active page leaves it.
21
+ *
22
+ * **Driven by `progress`, not by `current`.** `current` may be a shared value
23
+ * for scroll-linked motion, in which case React never re-renders on a page
24
+ * change and a range derived from props would sit still while the track slid
25
+ * out from under it. `progress` is the one place both spellings of `current`
26
+ * have already been resolved into a single number.
27
+ *
28
+ * The reaction only reaches JS when the page moves *outside* the mounted range
29
+ * — not on every frame, and not on every page. A one-page step normally costs
30
+ * no render at all, because the neighbour was already mounted; a long jump
31
+ * costs one render per window it crosses rather than one per page.
32
+ *
33
+ * Known limit: during a jump of many pages the track slides faster than the
34
+ * range is re-rendered, so dots in the middle of that flight may not be mounted
35
+ * as they pass. They are identical inactive dots moving too fast to read, and
36
+ * the alternative — mounting every page between the two — is the cost this
37
+ * exists to avoid.
38
+ */
39
+ export function usePageIndicatorWindow(
40
+ count: number,
41
+ current: number | SharedValue<number> | undefined,
42
+ progress: SharedValue<number>,
43
+ slot: number,
44
+ dotSize: number,
45
+ maxVisible: number,
46
+ activeMainSize = dotSize
47
+ ): PageIndicatorWindow {
48
+ const virtualized = count > maxVisible;
49
+
50
+ /*
51
+ Seeded from `current` so an indicator mounted straight onto a late page
52
+ draws that page's dots on its first frame rather than the first page's.
53
+ Zero for a shared value, which cannot be read during render — the reaction
54
+ below corrects it on the first frame that matters.
55
+ */
56
+ const [anchor, setAnchor] = useState(() =>
57
+ current === undefined || isPageIndicatorSharedValue(current) ? 0 : Math.round(current)
58
+ );
59
+
60
+ const window = useMemo(
61
+ () => pageIndicatorMountedRange(count, anchor, slot, dotSize, maxVisible, activeMainSize),
62
+ [count, anchor, slot, dotSize, maxVisible, activeMainSize]
63
+ );
64
+
65
+ /*
66
+ The live range, on the UI thread, so the reaction can decide for itself
67
+ whether a page change matters. Reading React state from a worklet is not an
68
+ option, and crossing to JS to ask would defeat the point of asking.
69
+ */
70
+ const mounted = useSharedValue(window);
71
+ mounted.value = window;
72
+
73
+ useAnimatedReaction(
74
+ () => Math.round(progress.value),
75
+ (page) => {
76
+ if (!virtualized) return;
77
+ const { start, end } = mounted.value;
78
+ if (page < start || page > end) runOnJS(setAnchor)(page);
79
+ },
80
+ [virtualized]
81
+ );
82
+
83
+ return window;
84
+ }
@@ -73,6 +73,17 @@ export function pageIndicatorViewportClipStyle(
73
73
  };
74
74
  }
75
75
 
76
+ /**
77
+ * Holds the room of dots a variant chose not to mount.
78
+ *
79
+ * The track keeps its full width and keeps scrolling by the same amount, so
80
+ * skipped dots still have to occupy their slots — otherwise the mounted ones
81
+ * slide up the track and the window shows the wrong pages.
82
+ */
83
+ export function pageIndicatorSpacerStyle(vertical: boolean, mainSize: number): ViewStyle {
84
+ return vertical ? { height: mainSize } : { width: mainSize };
85
+ }
86
+
76
87
  function scaleCurveValue(curve: readonly number[], distance: number, tailScale: number): number {
77
88
  "worklet";
78
89
  if (distance < curve.length) return curve[distance] ?? tailScale;
@@ -178,3 +189,62 @@ export function pageIndicatorWindowScale(
178
189
 
179
190
  return pageIndicatorWindowSlotScale(positionInWindow, activeSlot, maxVisible);
180
191
  }
192
+
193
+ /**
194
+ * Dots kept mounted beyond each edge of the visible window.
195
+ *
196
+ * One full window's worth. The window itself is `maxVisible` slots wide, so
197
+ * this leaves a whole screenful of slack on either side — enough that ordinary
198
+ * navigation (a swipe, a few pages at a time) never outruns the mounted range
199
+ * between the page change and React re-rendering with a new one.
200
+ */
201
+ export const PAGE_INDICATOR_WINDOW_OVERSCAN = 5;
202
+
203
+ /**
204
+ * Which dot indices are worth mounting, inclusive, for an active page of
205
+ * `anchor`.
206
+ *
207
+ * An indicator draws `maxVisible` slots and clips the rest, and every dot more
208
+ * than one page from the active one is drawn identically — same size, same
209
+ * scale, same opacity, same colour. Mounting one view per page therefore costs
210
+ * an animated style per page to render something nobody can see: at 500 pages
211
+ * that is 500 worklets for five visible dots, and it lands on every page
212
+ * change. Bounding what is mounted is what stops the indicator's cost scaling
213
+ * with the length of the thing it indicates.
214
+ *
215
+ * Derived from `computePageIndicatorWindowTranslate`, the same function that
216
+ * positions the track, so the mounted range and the visible window cannot
217
+ * disagree about where the window is — including at the ends, where the
218
+ * translate clamps and the window stops following the active page.
219
+ *
220
+ * Below the overflow threshold every dot is visible, so the range is the whole
221
+ * track and nothing changes.
222
+ */
223
+ export function pageIndicatorMountedRange(
224
+ count: number,
225
+ anchor: number,
226
+ slot: number,
227
+ dotSize: number,
228
+ maxVisible: number,
229
+ activeMainSize = dotSize,
230
+ overscan = PAGE_INDICATOR_WINDOW_OVERSCAN
231
+ ): { start: number; end: number } {
232
+ const last = Math.max(0, count - 1);
233
+ if (count <= maxVisible) return { start: 0, end: last };
234
+
235
+ const translate = computePageIndicatorWindowTranslate(
236
+ count,
237
+ anchor,
238
+ slot,
239
+ dotSize,
240
+ maxVisible,
241
+ activeMainSize
242
+ );
243
+ // The same expression `pageIndicatorWindowScale` reads the window from.
244
+ const visibleStart = -translate / slot;
245
+
246
+ return {
247
+ start: Math.max(0, Math.floor(visibleStart) - overscan),
248
+ end: Math.min(last, Math.ceil(visibleStart) + maxVisible - 1 + overscan),
249
+ };
250
+ }
@@ -6,6 +6,7 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated";
6
6
 
7
7
  import type { ColorToken } from "#theme/types";
8
8
  import { usePageIndicatorCore } from "../../hooks/use-page-indicator-core";
9
+ import { usePageIndicatorWindow } from "../../hooks/use-page-indicator-window";
9
10
  import { PageIndicatorRoot } from "../../PageIndicatorRoot";
10
11
  import {
11
12
  computePageIndicatorWindowTranslate,
@@ -71,7 +72,17 @@ export function DotIndicator({
71
72
  const fullTrackMainSize = pageIndicatorTrackMainSize(count, slot, dotSize);
72
73
  const overflows = count > maxVisible;
73
74
 
74
- const indices = useMemo(() => Array.from({ length: count }, (_, index) => index), [count]);
75
+ /*
76
+ No spacers here, unlike the pill variant: an `InactiveDot` is absolutely
77
+ positioned from its own index, so a dot that is not mounted leaves a gap
78
+ exactly where it would have been and the rest do not move.
79
+ */
80
+ const { start, end } = usePageIndicatorWindow(count, current, progress, slot, dotSize, maxVisible);
81
+
82
+ const indices = useMemo(
83
+ () => Array.from({ length: end - start + 1 }, (_, offset) => start + offset),
84
+ [start, end]
85
+ );
75
86
 
76
87
  const trackStyle = useAnimatedStyle(() => {
77
88
  const translate = computePageIndicatorWindowTranslate(count, progress.value, slot, dotSize, maxVisible);
@@ -1,17 +1,19 @@
1
1
  import { useMemo } from "react";
2
- import type { StyleProp, ViewStyle } from "react-native";
2
+ import { type StyleProp, View, type ViewStyle } from "react-native";
3
3
 
4
4
  import type { SharedValue } from "react-native-reanimated";
5
5
  import Animated, { useAnimatedStyle } from "react-native-reanimated";
6
6
 
7
7
  import type { ColorToken } from "#theme/types";
8
8
  import { usePageIndicatorCore } from "../../hooks/use-page-indicator-core";
9
+ import { usePageIndicatorWindow } from "../../hooks/use-page-indicator-window";
9
10
  import { PageIndicatorRoot } from "../../PageIndicatorRoot";
10
11
  import {
11
12
  computePageIndicatorWindowTranslate,
12
13
  DEFAULT_PAGE_INDICATOR_MAX_VISIBLE,
13
14
  PAGE_INDICATOR_SINGLE_PAGE_DURATION,
14
15
  pageIndicatorAxisTranslateStyle,
16
+ pageIndicatorSpacerStyle,
15
17
  pageIndicatorTrackMainSize,
16
18
  pageIndicatorViewportMainSize,
17
19
  } from "../../utils";
@@ -62,7 +64,33 @@ export function PillIndicator({
62
64
  const fullTrackMainSize = pageIndicatorTrackMainSize(count, slot, dotSize, activeWidth);
63
65
  const overflows = count > maxVisible;
64
66
 
65
- const indices = useMemo(() => Array.from({ length: count }, (_, index) => index), [count]);
67
+ const { start, end } = usePageIndicatorWindow(
68
+ count,
69
+ current,
70
+ progress,
71
+ slot,
72
+ dotSize,
73
+ maxVisible,
74
+ activeWidth
75
+ );
76
+
77
+ const indices = useMemo(
78
+ () => Array.from({ length: end - start + 1 }, (_, offset) => start + offset),
79
+ [start, end]
80
+ );
81
+
82
+ /*
83
+ The track is still `count` slots wide and still scrolls the same way, so the
84
+ dots that are not mounted have to leave their room behind. Each unmounted
85
+ dot occupies exactly one slot — it is inactive, and only the active dot is
86
+ ever wider than `dotSize` — so the space before the window is `start` slots
87
+ and the space after it is whatever is left.
88
+
89
+ Less one gap apiece, because the row's own `gap` supplies the space between
90
+ a spacer and the first dot beside it.
91
+ */
92
+ const leadMainSize = start * slot - gap;
93
+ const tailMainSize = (count - 1 - end) * slot - gap;
66
94
 
67
95
  const trackStyle = useAnimatedStyle(() => {
68
96
  const displayProgress = pillIndicatorJumpDisplayProgress(
@@ -107,6 +135,7 @@ export function PillIndicator({
107
135
  overflows ? trackStyle : null,
108
136
  ]}
109
137
  >
138
+ {leadMainSize > 0 && <View style={pageIndicatorSpacerStyle(vertical, leadMainSize)} />}
110
139
  {indices.map((index) => (
111
140
  <PillIndicatorDot
112
141
  key={index}
@@ -121,6 +150,7 @@ export function PillIndicator({
121
150
  maxVisible={maxVisible}
122
151
  />
123
152
  ))}
153
+ {tailMainSize > 0 && <View style={pageIndicatorSpacerStyle(vertical, tailMainSize)} />}
124
154
  </Animated.View>
125
155
  </PageIndicatorRoot>
126
156
  );