panelui-native 0.13.0 → 0.14.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 (91) hide show
  1. package/README.md +39 -23
  2. package/lib/module/components/button/index.js +12 -9
  3. package/lib/module/components/button/index.js.map +1 -1
  4. package/lib/module/components/chip/index.js +313 -0
  5. package/lib/module/components/chip/index.js.map +1 -0
  6. package/lib/module/components/frame/index.js +41 -39
  7. package/lib/module/components/frame/index.js.map +1 -1
  8. package/lib/module/components/heatmap-chart/index.js +772 -0
  9. package/lib/module/components/heatmap-chart/index.js.map +1 -0
  10. package/lib/module/components/input/index.js +4 -0
  11. package/lib/module/components/input/index.js.map +1 -1
  12. package/lib/module/components/scroll-canvas/index.js +175 -0
  13. package/lib/module/components/scroll-canvas/index.js.map +1 -0
  14. package/lib/module/components/scroll-text/index.js +213 -0
  15. package/lib/module/components/scroll-text/index.js.map +1 -0
  16. package/lib/module/components/select/index.js +20 -24
  17. package/lib/module/components/select/index.js.map +1 -1
  18. package/lib/module/components/slider/index.js +2 -5
  19. package/lib/module/components/slider/index.js.map +1 -1
  20. package/lib/module/components/switch/index.js +15 -17
  21. package/lib/module/components/switch/index.js.map +1 -1
  22. package/lib/module/components/thinking-orb/index.js +741 -0
  23. package/lib/module/components/thinking-orb/index.js.map +1 -0
  24. package/lib/module/components/toggle-button/index.js +12 -3
  25. package/lib/module/components/toggle-button/index.js.map +1 -1
  26. package/lib/module/hooks/index.js +1 -0
  27. package/lib/module/hooks/index.js.map +1 -1
  28. package/lib/module/hooks/use-keyboard-avoidance.js +99 -59
  29. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  30. package/lib/module/hooks/use-reveal-progress.js +75 -0
  31. package/lib/module/hooks/use-reveal-progress.js.map +1 -0
  32. package/lib/module/index.js +6 -0
  33. package/lib/module/index.js.map +1 -1
  34. package/lib/module/native/index.js.map +1 -1
  35. package/lib/module/primitives/keyboard-avoider.js +21 -2
  36. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  37. package/lib/module/primitives/scroll-progress.js +114 -0
  38. package/lib/module/primitives/scroll-progress.js.map +1 -0
  39. package/lib/typescript/src/components/button/index.d.ts +3 -4
  40. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/chip/index.d.ts +304 -0
  42. package/lib/typescript/src/components/chip/index.d.ts.map +1 -0
  43. package/lib/typescript/src/components/frame/index.d.ts +15 -13
  44. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/heatmap-chart/index.d.ts +260 -0
  46. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -0
  47. package/lib/typescript/src/components/input/index.d.ts +19 -4
  48. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/scroll-canvas/index.d.ts +66 -0
  50. package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -0
  51. package/lib/typescript/src/components/scroll-text/index.d.ts +46 -0
  52. package/lib/typescript/src/components/scroll-text/index.d.ts.map +1 -0
  53. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  55. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  56. package/lib/typescript/src/components/thinking-orb/index.d.ts +28 -0
  57. package/lib/typescript/src/components/thinking-orb/index.d.ts.map +1 -0
  58. package/lib/typescript/src/components/toggle-button/index.d.ts +12 -0
  59. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -1
  60. package/lib/typescript/src/hooks/index.d.ts +2 -1
  61. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  62. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +16 -3
  63. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  64. package/lib/typescript/src/hooks/use-reveal-progress.d.ts +56 -0
  65. package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -0
  66. package/lib/typescript/src/index.d.ts +6 -0
  67. package/lib/typescript/src/index.d.ts.map +1 -1
  68. package/lib/typescript/src/native/index.d.ts +15 -7
  69. package/lib/typescript/src/native/index.d.ts.map +1 -1
  70. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +30 -3
  71. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  72. package/lib/typescript/src/primitives/scroll-progress.d.ts +61 -0
  73. package/lib/typescript/src/primitives/scroll-progress.d.ts.map +1 -0
  74. package/package.json +1 -1
  75. package/src/components/button/index.tsx +13 -11
  76. package/src/components/frame/index.tsx +39 -50
  77. package/src/components/heatmap-chart/index.tsx +1024 -0
  78. package/src/components/input/index.tsx +23 -4
  79. package/src/components/scroll-canvas/index.tsx +220 -0
  80. package/src/components/scroll-text/index.tsx +277 -0
  81. package/src/components/select/index.tsx +4 -9
  82. package/src/components/slider/index.tsx +4 -7
  83. package/src/components/switch/index.tsx +5 -8
  84. package/src/components/thinking-orb/index.tsx +792 -0
  85. package/src/hooks/index.ts +6 -0
  86. package/src/hooks/use-keyboard-avoidance.ts +111 -65
  87. package/src/hooks/use-reveal-progress.ts +106 -0
  88. package/src/index.ts +38 -0
  89. package/src/native/index.ts +15 -7
  90. package/src/primitives/keyboard-avoider.tsx +41 -4
  91. package/src/primitives/scroll-progress.tsx +167 -0
@@ -29,6 +29,7 @@ import Animated, {
29
29
  } from 'react-native-reanimated';
30
30
  import { tv, type VariantProps } from 'tailwind-variants';
31
31
  import { useCSSVariable } from 'uniwind';
32
+ import type { KeyboardAvoidanceMode } from '../../hooks/use-keyboard-avoidance';
32
33
  import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
33
34
  import { Text } from '../../primitives/text';
34
35
  import { Label } from '../label';
@@ -85,9 +86,11 @@ export interface InputProps
85
86
  isRequired?: boolean;
86
87
  disabled?: boolean;
87
88
  /**
88
- * Lift the field above the software keyboard when it would otherwise be
89
- * covered. Moves by exactly the overlap, and not at all when the field is
90
- * already clear — or when the keyboard belongs to a different field.
89
+ * Keep the field clear of the software keyboard. Moves by exactly the
90
+ * overlap, and not at all when the field is already clear — or when the
91
+ * keyboard belongs to a different field. The overlap is re-read every frame
92
+ * while the field is focused, so the field keeps its place in the page as it
93
+ * scrolls under and back out of the keyboard.
91
94
  *
92
95
  * Install `react-native-keyboard-controller` for this to behave on Android.
93
96
  *
@@ -95,8 +98,20 @@ export interface InputProps
95
98
  * container, which would remount the field and drop focus.
96
99
  */
97
100
  avoidKeyboard?: boolean;
98
- /** Gap kept between the field and the keyboard when `avoidKeyboard` is set. */
101
+ /**
102
+ * How the field gets clear. `lift` moves it up by its overlap and follows
103
+ * the scroll — right for a field in the flow of a page. `dock` makes it
104
+ * travel with the keyboard, for a composer already pinned near the bottom
105
+ * edge; pair it with `keyboardBottomInset`.
106
+ */
107
+ keyboardMode?: KeyboardAvoidanceMode;
108
+ /** Gap kept between the field and the keyboard. `keyboardMode="lift"` only. */
99
109
  keyboardOffset?: number;
110
+ /**
111
+ * How far above the bottom edge the field already sits — usually the safe
112
+ * area inset. `keyboardMode="dock"` only.
113
+ */
114
+ keyboardBottomInset?: number;
100
115
  }
101
116
 
102
117
  const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
@@ -114,7 +129,9 @@ export const Input = forwardRef<TextInput, InputProps>(
114
129
  variant,
115
130
  size,
116
131
  avoidKeyboard = false,
132
+ keyboardMode = 'lift',
117
133
  keyboardOffset = 16,
134
+ keyboardBottomInset = 0,
118
135
  onFocus,
119
136
  onBlur,
120
137
  style,
@@ -231,7 +248,9 @@ export const Input = forwardRef<TextInput, InputProps>(
231
248
  // anywhere is tapped, and since they all aim at the same gap above
232
249
  // the keyboard, they arrive stacked on top of one another.
233
250
  active={focused}
251
+ mode={keyboardMode}
234
252
  offset={keyboardOffset}
253
+ bottomInset={keyboardBottomInset}
235
254
  className={containerClasses}
236
255
  >
237
256
  {body}
@@ -0,0 +1,220 @@
1
+ /**
2
+ * ScrollCanvas — an image frame whose contents move as you scroll past it.
3
+ *
4
+ * The counterpart to `ScrollText`: same scrubbed reveal, applied to a picture
5
+ * instead of a sentence. The frame stays where the layout puts it and the image
6
+ * moves *inside* it, which is what separates a parallax from a thing sliding
7
+ * about the page.
8
+ *
9
+ * ```tsx
10
+ * <ScrollProgress>
11
+ * <ScrollView>
12
+ * <ScrollCanvas source={{ uri }} effect="parallax" />
13
+ * </ScrollView>
14
+ * </ScrollProgress>
15
+ * ```
16
+ *
17
+ * Four effects, and they are four different jobs:
18
+ *
19
+ * - **`parallax`** — the image drifts against the scroll, so the frame reads as
20
+ * a window onto something further away.
21
+ * - **`zoom`** — it settles from slightly oversized to its natural size.
22
+ * - **`reveal`** — a wipe uncovers it from the bottom edge up.
23
+ * - **`sequence`** — the scroll position picks a frame out of a series, so the
24
+ * reader scrubs an animation with their thumb.
25
+ *
26
+ * "Canvas" here is the frame the pictures move behind, not a drawing surface —
27
+ * nothing is rasterised, and there is no `<canvas>` anywhere near it.
28
+ */
29
+ import { Image, View, type ImageSourcePropType, type ViewProps } from 'react-native';
30
+ import Animated, {
31
+ interpolate,
32
+ useAnimatedStyle,
33
+ useReducedMotion,
34
+ type SharedValue,
35
+ } from 'react-native-reanimated';
36
+ import { useRevealProgress } from '../../hooks/use-reveal-progress';
37
+ import { cn } from '../../utils/cn';
38
+
39
+ const AnimatedImage = Animated.createAnimatedComponent(Image);
40
+
41
+ /**
42
+ * How much taller than the frame a parallaxing image is drawn.
43
+ *
44
+ * The image has to be oversized or drifting it exposes an edge. This is the
45
+ * budget the drift is spent out of, so the two are set together — raising
46
+ * `distance` past what this affords is what puts a bare strip on screen.
47
+ */
48
+ const PARALLAX_OVERSCAN = 1.25;
49
+
50
+ /** How far a parallaxing image drifts, as a fraction of the frame's height. */
51
+ const PARALLAX_TRAVEL = 0.1;
52
+
53
+ export type ScrollCanvasEffect = 'parallax' | 'zoom' | 'reveal' | 'sequence';
54
+
55
+ export interface ScrollCanvasProps extends Omit<ViewProps, 'children'> {
56
+ className?: string;
57
+ /** The image. Ignored by `sequence`, which reads `sources` instead. */
58
+ source?: ImageSourcePropType;
59
+ /**
60
+ * The frames a `sequence` scrubs through, in order. Keep it to a couple of
61
+ * dozen — every frame is a decoded bitmap held in memory for the whole time
62
+ * the canvas is mounted.
63
+ */
64
+ sources?: ImageSourcePropType[];
65
+ /** Which of the four scroll effects to apply. */
66
+ effect?: ScrollCanvasEffect;
67
+ /** Width ÷ height of the frame. */
68
+ aspectRatio?: number;
69
+ /** Multiplier on how far the effect travels. */
70
+ distance?: number;
71
+ /**
72
+ * Where down the viewport the frame's top sits when the effect starts, as a
73
+ * fraction of the viewport height.
74
+ */
75
+ start?: number;
76
+ /** Where its bottom sits when the effect completes. Smaller is a longer scrub. */
77
+ end?: number;
78
+ /** Drive the effect from a value of your own rather than from scroll. */
79
+ progress?: SharedValue<number>;
80
+ /** Set false to render the image plainly. */
81
+ enabled?: boolean;
82
+ /** Extra classes for the image itself, rather than the frame around it. */
83
+ imageClassName?: string;
84
+ }
85
+
86
+ export function ScrollCanvas({
87
+ className,
88
+ imageClassName,
89
+ source,
90
+ sources,
91
+ effect = 'parallax',
92
+ aspectRatio = 16 / 10,
93
+ distance = 1,
94
+ start = 1,
95
+ end = 0.3,
96
+ progress: external,
97
+ enabled = true,
98
+ style,
99
+ ...props
100
+ }: ScrollCanvasProps) {
101
+ const reducedMotion = useReducedMotion();
102
+ const active = enabled && !reducedMotion;
103
+
104
+ const { ref, progress } = useRevealProgress({
105
+ start,
106
+ end,
107
+ progress: external,
108
+ enabled: active,
109
+ });
110
+
111
+ const frames = sources ?? (source ? [source] : []);
112
+
113
+ const imageStyle = useAnimatedStyle(() => {
114
+ const p = progress.value;
115
+
116
+ if (effect === 'zoom') {
117
+ return { transform: [{ scale: interpolate(p, [0, 1], [1 + 0.12 * distance, 1]) }] };
118
+ }
119
+ if (effect === 'parallax') {
120
+ // Centred at half travel, so the drift is symmetric about the frame and
121
+ // the overscan is spent evenly at both ends instead of all at one.
122
+ const travel = PARALLAX_TRAVEL * distance * 100;
123
+ // A percentage of the image's own height, so the drift stays in
124
+ // proportion to the overscan whatever size the frame ends up.
125
+ return {
126
+ transform: [{ translateY: `${interpolate(p, [0, 1], [travel, -travel])}%` }],
127
+ };
128
+ }
129
+ return {};
130
+ });
131
+
132
+ // A wipe rather than a fade: the frame keeps its shape while it fills, so the
133
+ // layout never moves. Implemented as a cover that retreats, because animating
134
+ // a height would relayout the image inside it every frame.
135
+ const coverStyle = useAnimatedStyle(() => ({
136
+ transform: [{ translateY: `${-progress.value * 100}%` }],
137
+ }));
138
+
139
+ return (
140
+ <View
141
+ {...props}
142
+ ref={ref}
143
+ style={[{ aspectRatio }, style]}
144
+ className={cn('w-full overflow-hidden rounded-2xl bg-muted', className)}
145
+ >
146
+ {effect === 'sequence' ? (
147
+ frames.map((frame, index) => (
148
+ <SequenceFrame
149
+ key={index}
150
+ source={frame}
151
+ index={index}
152
+ count={frames.length}
153
+ progress={progress}
154
+ className={imageClassName}
155
+ />
156
+ ))
157
+ ) : (
158
+ <AnimatedImage
159
+ source={frames[0]!}
160
+ resizeMode="cover"
161
+ className={cn('h-full w-full', imageClassName)}
162
+ style={[
163
+ // Only the parallax is oversized. Growing the others would crop
164
+ // them for no reason — nothing is drifting to expose an edge.
165
+ effect === 'parallax'
166
+ ? { height: `${PARALLAX_OVERSCAN * 100}%`, top: `${(1 - PARALLAX_OVERSCAN) * 50}%` }
167
+ : null,
168
+ imageStyle,
169
+ ]}
170
+ />
171
+ )}
172
+
173
+ {effect === 'reveal' ? (
174
+ <Animated.View
175
+ pointerEvents="none"
176
+ className="absolute inset-0 bg-background"
177
+ style={coverStyle}
178
+ />
179
+ ) : null}
180
+ </View>
181
+ );
182
+ }
183
+
184
+ ScrollCanvas.displayName = 'ScrollCanvas';
185
+
186
+ /**
187
+ * One frame of a sequence.
188
+ *
189
+ * Every frame is mounted and its opacity switched, rather than one image whose
190
+ * source is swapped. A swap crosses back into JS for each step and then waits
191
+ * on a decode, so a fast scrub shows the previous frame or nothing at all;
192
+ * mounted frames scrub on the UI thread with no decode left to do.
193
+ */
194
+ function SequenceFrame({
195
+ source,
196
+ index,
197
+ count,
198
+ progress,
199
+ className,
200
+ }: {
201
+ source: ImageSourcePropType;
202
+ index: number;
203
+ count: number;
204
+ progress: SharedValue<number>;
205
+ className?: string;
206
+ }) {
207
+ const style = useAnimatedStyle(() => {
208
+ // The frame that owns this slice of the scroll is the visible one. Hard
209
+ // steps, not a crossfade — two frames of an animation overlaid at half
210
+ // opacity is a double exposure, not a tween.
211
+ const current = Math.min(count - 1, Math.floor(progress.value * count));
212
+ return { opacity: current === index ? 1 : 0 };
213
+ });
214
+
215
+ return (
216
+ <Animated.View className="absolute inset-0" style={style}>
217
+ <Image source={source} resizeMode="cover" className={cn('h-full w-full', className)} />
218
+ </Animated.View>
219
+ );
220
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * ScrollText — a line of text that resolves word by word as you scroll past it.
3
+ *
4
+ * The reader's thumb becomes the playhead. Words arrive left to right as the
5
+ * block crosses the viewport, and going back up unresolves them — the effect is
6
+ * scrubbed, not triggered, so it is reversible and lands exactly on both ends.
7
+ *
8
+ * ```tsx
9
+ * <ScrollProgress>
10
+ * <ScrollView>
11
+ * <ScrollText size="3xl" weight="semibold">
12
+ * Every control ships with its accessibility wiring already done.
13
+ * </ScrollText>
14
+ * </ScrollView>
15
+ * </ScrollProgress>
16
+ * ```
17
+ *
18
+ * ## Two layouts, and why
19
+ *
20
+ * `color`, `fade` and `highlight` render the words as nested `Text` inside one
21
+ * parent, which is the only way React Native will break them into real lines —
22
+ * hyphenation, justification, the lot. Nested text cannot be transformed,
23
+ * though: a `translateY` on it is ignored.
24
+ *
25
+ * So `rise` lays the words out as a wrapping row of separate views instead.
26
+ * That buys transforms and costs real line-breaking — words wrap on their own
27
+ * boundaries and the spacing is a margin rather than a space. Worth knowing
28
+ * before picking the effect for a paragraph rather than a heading.
29
+ */
30
+ import { useMemo } from 'react';
31
+ import { Text as RNText, View, type TextStyle } from 'react-native';
32
+ import Animated, {
33
+ interpolate,
34
+ interpolateColor,
35
+ useAnimatedStyle,
36
+ useReducedMotion,
37
+ type SharedValue,
38
+ } from 'react-native-reanimated';
39
+ import { useCSSVariable } from 'uniwind';
40
+ import { useRevealProgress } from '../../hooks/use-reveal-progress';
41
+ import { Text, type TextProps } from '../../primitives/text';
42
+
43
+ const AnimatedText = Animated.createAnimatedComponent(RNText);
44
+
45
+ /** How far a word rises from, in pixels, for the `rise` effect. */
46
+ const RISE_DISTANCE = 18;
47
+
48
+ export type ScrollTextEffect = 'color' | 'fade' | 'rise' | 'highlight';
49
+ export type ScrollTextSplit = 'word' | 'character';
50
+
51
+ /** Effects that need a transform, and so cannot use nested text. */
52
+ const NEEDS_ROW: ScrollTextEffect[] = ['rise'];
53
+
54
+ export interface ScrollTextProps extends Omit<TextProps, 'children'> {
55
+ className?: string;
56
+ /** The sentence. Split into words or characters, then revealed across them. */
57
+ children?: string;
58
+ /**
59
+ * `color` crossfades each word between two colours, `fade` brings it up from
60
+ * transparent, `rise` lifts it into place, `highlight` sweeps a background
61
+ * behind it.
62
+ */
63
+ effect?: ScrollTextEffect;
64
+ /** Reveal a word at a time, or a character at a time. */
65
+ by?: ScrollTextSplit;
66
+ /** Colour before a word is reached. Defaults to the muted foreground token. */
67
+ from?: string;
68
+ /** Colour once it has been. Defaults to the foreground token. */
69
+ to?: string;
70
+ /**
71
+ * Where down the viewport the block's top sits when the reveal starts, as a
72
+ * fraction of the viewport height.
73
+ */
74
+ start?: number;
75
+ /** Where its bottom sits when the reveal completes. Smaller is a longer scrub. */
76
+ end?: number;
77
+ /**
78
+ * How much of the whole reveal a single word takes, `0` to `1`. Small values
79
+ * make a hard edge travelling along the line; large ones make the whole
80
+ * sentence brighten together.
81
+ */
82
+ stagger?: number;
83
+ /**
84
+ * Drive the reveal from a value of your own rather than from scroll — a
85
+ * progress bar, a gesture, a timeline.
86
+ */
87
+ progress?: SharedValue<number>;
88
+ /** Set false to render the text resolved, with no effect at all. */
89
+ enabled?: boolean;
90
+ }
91
+
92
+ export function ScrollText({
93
+ className,
94
+ children = '',
95
+ effect = 'color',
96
+ by = 'word',
97
+ from,
98
+ to,
99
+ start = 0.9,
100
+ end = 0.5,
101
+ stagger = 0.35,
102
+ progress: external,
103
+ enabled = true,
104
+ ...textProps
105
+ }: ScrollTextProps) {
106
+ const reducedMotion = useReducedMotion();
107
+ const active = enabled && !reducedMotion;
108
+
109
+ const { ref, progress } = useRevealProgress({
110
+ start,
111
+ end,
112
+ progress: external,
113
+ enabled: active,
114
+ });
115
+
116
+ const mutedToken = useCSSVariable('--color-muted-foreground');
117
+ const foregroundToken = useCSSVariable('--color-foreground');
118
+ const accentToken = useCSSVariable('--color-accent');
119
+
120
+ const dim = from ?? (typeof mutedToken === 'string' ? mutedToken : '#a1a1aa');
121
+ const lit = to ?? (typeof foregroundToken === 'string' ? foregroundToken : '#0a0a0a');
122
+ const highlight = typeof accentToken === 'string' ? accentToken : 'rgba(0,0,0,0.08)';
123
+
124
+ const tokens = useMemo(() => splitText(children, by), [children, by]);
125
+
126
+ // Every token gets the same slice of the reveal, offset along the line. The
127
+ // slice is a fraction of the whole rather than a fixed duration, so a long
128
+ // sentence and a short one both finish exactly when the block has passed.
129
+ const span = Math.max(stagger, 0.01);
130
+ const stride = tokens.length > 1 ? (1 - span) / (tokens.length - 1) : 0;
131
+
132
+ const asRow = NEEDS_ROW.includes(effect);
133
+
134
+ if (asRow) {
135
+ return (
136
+ <View ref={ref} className="flex-row flex-wrap items-baseline">
137
+ {tokens.map((token, index) => (
138
+ <RisingToken
139
+ key={`${index}-${token}`}
140
+ text={token}
141
+ progress={progress}
142
+ startAt={index * stride}
143
+ span={span}
144
+ last={index === tokens.length - 1}
145
+ textProps={textProps}
146
+ className={className}
147
+ />
148
+ ))}
149
+ </View>
150
+ );
151
+ }
152
+
153
+ return (
154
+ <View ref={ref}>
155
+ <Text {...textProps} className={className}>
156
+ {tokens.map((token, index) => (
157
+ <InkedToken
158
+ key={`${index}-${token}`}
159
+ text={token}
160
+ progress={progress}
161
+ startAt={index * stride}
162
+ span={span}
163
+ effect={effect}
164
+ dim={dim}
165
+ lit={lit}
166
+ highlight={highlight}
167
+ last={index === tokens.length - 1}
168
+ />
169
+ ))}
170
+ </Text>
171
+ </View>
172
+ );
173
+ }
174
+
175
+ ScrollText.displayName = 'ScrollText';
176
+
177
+ /**
178
+ * One word or character inside the parent's text flow, inked by the reveal.
179
+ *
180
+ * Nested text, so React Native breaks the sentence into real lines. That rules
181
+ * out transforms — colour, opacity and background are what nested text will
182
+ * animate, which is exactly the three effects routed here.
183
+ */
184
+ function InkedToken({
185
+ text,
186
+ progress,
187
+ startAt,
188
+ span,
189
+ effect,
190
+ dim,
191
+ lit,
192
+ highlight,
193
+ last,
194
+ }: {
195
+ text: string;
196
+ progress: SharedValue<number>;
197
+ startAt: number;
198
+ span: number;
199
+ effect: ScrollTextEffect;
200
+ dim: string;
201
+ lit: string;
202
+ highlight: string;
203
+ last: boolean;
204
+ }) {
205
+ const style = useAnimatedStyle(() => {
206
+ const local = Math.min(1, Math.max(0, (progress.value - startAt) / span));
207
+
208
+ if (effect === 'fade') {
209
+ // Not all the way to transparent: a word that vanishes takes the line's
210
+ // shape with it, and the paragraph reflows as you scroll.
211
+ return { opacity: interpolate(local, [0, 1], [0.18, 1]), color: lit } as TextStyle;
212
+ }
213
+ if (effect === 'highlight') {
214
+ return {
215
+ color: interpolateColor(local, [0, 1], [dim, lit]),
216
+ backgroundColor: interpolateColor(local, [0, 1], ['transparent', highlight]),
217
+ } as TextStyle;
218
+ }
219
+ return { color: interpolateColor(local, [0, 1], [dim, lit]) } as TextStyle;
220
+ });
221
+
222
+ return <AnimatedText style={style}>{last ? text : `${text} `}</AnimatedText>;
223
+ }
224
+
225
+ /**
226
+ * One word as its own box, lifted into place.
227
+ *
228
+ * A transform needs a view, and a view in a text flow is a word that no longer
229
+ * takes part in line-breaking — hence the wrapping row above, and hence the
230
+ * margin below, since a trailing space inside a box is not a space between
231
+ * boxes.
232
+ */
233
+ function RisingToken({
234
+ text,
235
+ progress,
236
+ startAt,
237
+ span,
238
+ last,
239
+ textProps,
240
+ className,
241
+ }: {
242
+ text: string;
243
+ progress: SharedValue<number>;
244
+ startAt: number;
245
+ span: number;
246
+ last: boolean;
247
+ textProps: Omit<TextProps, 'children' | 'className'>;
248
+ className?: string;
249
+ }) {
250
+ const style = useAnimatedStyle(() => {
251
+ const local = Math.min(1, Math.max(0, (progress.value - startAt) / span));
252
+ return {
253
+ opacity: local,
254
+ transform: [{ translateY: interpolate(local, [0, 1], [RISE_DISTANCE, 0]) }],
255
+ };
256
+ });
257
+
258
+ return (
259
+ <Animated.View style={[style, last ? undefined : { marginRight: 6 }]}>
260
+ <Text {...textProps} className={className}>
261
+ {text}
262
+ </Text>
263
+ </Animated.View>
264
+ );
265
+ }
266
+
267
+ /**
268
+ * Splits on whitespace, keeping punctuation attached to the word it belongs to.
269
+ * Splitting a sentence into bare words and re-joining with single spaces loses
270
+ * the double space after a full stop and every non-breaking space in it.
271
+ */
272
+ function splitText(text: string, by: ScrollTextSplit): string[] {
273
+ const trimmed = text.trim();
274
+ if (!trimmed) return [];
275
+ if (by === 'character') return Array.from(trimmed);
276
+ return trimmed.split(/\s+/);
277
+ }
@@ -87,14 +87,6 @@ const selectVariants = tv({
87
87
 
88
88
  export type SelectPresentation = 'sheet' | 'inline' | 'overlay';
89
89
 
90
- /**
91
- * Height given to the native host per appearance — a menu is a compact button,
92
- * a wheel is a full rotor. The host is told its size rather than asked to
93
- * measure the platform's content, which arrives a frame late and leaves the
94
- * control collapsed until something forces a second pass.
95
- */
96
- const NATIVE_HEIGHT = { menu: 44, wheel: 216 } as const;
97
-
98
90
  interface SelectContextValue {
99
91
  value: string | undefined;
100
92
  onSelect: (value: string) => void;
@@ -279,7 +271,10 @@ function SelectRoot({
279
271
  // The native picker has no empty state, so an unset value shows the first
280
272
  // option rather than the placeholder.
281
273
  return (
282
- <Host style={{ height: NATIVE_HEIGHT[nativeAppearance] }}>
274
+ // A picker fills the width of the row it sits in and reports its own
275
+ // height — a menu is a compact button, a wheel a full rotor, and the
276
+ // platform is the only thing that knows which by how much.
277
+ <Host matchContents={{ vertical: true }}>
283
278
  <Picker
284
279
  selectedValue={value ?? options[0]?.value ?? ''}
285
280
  onValueChange={(next: string) => onValueChange(next)}
@@ -93,9 +93,6 @@ const sliderVariants = tv({
93
93
  /** Thumb width per size. Wider than it is tall, so the pill reads as a grip. */
94
94
  const THUMB_WIDTH: Record<'sm' | 'md' | 'lg', number> = { sm: 24, md: 28, lg: 32 };
95
95
 
96
- /** Row height reserved for the platform's own slider. */
97
- const NATIVE_HEIGHT = 32;
98
-
99
96
  type SliderVariantProps = VariantProps<typeof sliderVariants>;
100
97
 
101
98
  export interface SliderProps extends Omit<SliderVariantProps, 'disabled'> {
@@ -308,10 +305,10 @@ export const Slider = forwardRef<View, SliderProps>(
308
305
  return (
309
306
  <View ref={ref} className={slots.root({ className })}>
310
307
  {header}
311
- {/* Told its height rather than left to measure the platform's own
312
- content, which arrives a frame late — a host that has to work its
313
- size out renders at nothing and then jumps. */}
314
- <Host style={{ height: NATIVE_HEIGHT }}>
308
+ {/* A slider has no intrinsic width — it fills whatever it is given —
309
+ so the width comes from ordinary layout and only the height is
310
+ matched to the platform's content. */}
311
+ <Host matchContents={{ vertical: true }}>
315
312
  <NativeSlider
316
313
  value={value}
317
314
  onValueChange={(next: number) => {
@@ -33,13 +33,6 @@ const switchVariants = tv({
33
33
 
34
34
  const TRAVEL: Record<'sm' | 'md', number> = { sm: 16, md: 20 };
35
35
 
36
- /**
37
- * Row height given to the native host. It is told its size rather than asked
38
- * to measure the platform's own content, which arrives a frame late and leaves
39
- * the control collapsed until something forces a second pass.
40
- */
41
- const NATIVE_HEIGHT = 32;
42
-
43
36
  export interface SwitchProps extends VariantProps<typeof switchVariants> {
44
37
  className?: string;
45
38
  value: boolean;
@@ -84,7 +77,11 @@ export const Switch = forwardRef<View, SwitchProps>(
84
77
  if (nativeUI) {
85
78
  const { Host, Switch: NativeSwitch } = nativeUI;
86
79
  return (
87
- <Host style={{ height: NATIVE_HEIGHT }}>
80
+ // A platform toggle has a definite intrinsic size on both platforms,
81
+ // so the host is left to follow it. Pinning the host to a number
82
+ // instead is what leaves the control laid out against a box it never
83
+ // agreed to, and settling into it a frame later.
84
+ <Host matchContents>
88
85
  <NativeSwitch
89
86
  value={value}
90
87
  onValueChange={(next: boolean) => onValueChange?.(next)}