panelui-native 0.12.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.
- package/README.md +39 -23
- package/lib/module/components/button/index.js +12 -9
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/chip/index.js +313 -0
- package/lib/module/components/chip/index.js.map +1 -0
- package/lib/module/components/frame/index.js +41 -39
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/heatmap-chart/index.js +772 -0
- package/lib/module/components/heatmap-chart/index.js.map +1 -0
- package/lib/module/components/input/index.js +4 -0
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/scroll-canvas/index.js +175 -0
- package/lib/module/components/scroll-canvas/index.js.map +1 -0
- package/lib/module/components/scroll-text/index.js +213 -0
- package/lib/module/components/scroll-text/index.js.map +1 -0
- package/lib/module/components/select/index.js +20 -24
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/slider/index.js +2 -5
- package/lib/module/components/slider/index.js.map +1 -1
- package/lib/module/components/switch/index.js +15 -17
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/thinking-orb/index.js +741 -0
- package/lib/module/components/thinking-orb/index.js.map +1 -0
- package/lib/module/components/toggle-button/index.js +12 -3
- package/lib/module/components/toggle-button/index.js.map +1 -1
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +99 -59
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
- package/lib/module/hooks/use-reveal-progress.js +75 -0
- package/lib/module/hooks/use-reveal-progress.js.map +1 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js.map +1 -1
- package/lib/module/primitives/keyboard-avoider.js +21 -2
- package/lib/module/primitives/keyboard-avoider.js.map +1 -1
- package/lib/module/primitives/scroll-progress.js +114 -0
- package/lib/module/primitives/scroll-progress.js.map +1 -0
- package/lib/typescript/src/components/button/index.d.ts +3 -4
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/chip/index.d.ts +304 -0
- package/lib/typescript/src/components/chip/index.d.ts.map +1 -0
- package/lib/typescript/src/components/frame/index.d.ts +15 -13
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts +260 -0
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/input/index.d.ts +19 -4
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/scroll-canvas/index.d.ts +66 -0
- package/lib/typescript/src/components/scroll-canvas/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-text/index.d.ts +46 -0
- package/lib/typescript/src/components/scroll-text/index.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/thinking-orb/index.d.ts +28 -0
- package/lib/typescript/src/components/thinking-orb/index.d.ts.map +1 -0
- package/lib/typescript/src/components/toggle-button/index.d.ts +12 -0
- package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/index.d.ts +2 -1
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +16 -3
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-reveal-progress.d.ts +56 -0
- package/lib/typescript/src/hooks/use-reveal-progress.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +6 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +15 -7
- package/lib/typescript/src/native/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts +30 -3
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
- package/lib/typescript/src/primitives/scroll-progress.d.ts +61 -0
- package/lib/typescript/src/primitives/scroll-progress.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/button/index.tsx +13 -11
- package/src/components/chip/index.tsx +333 -0
- package/src/components/frame/index.tsx +39 -50
- package/src/components/heatmap-chart/index.tsx +1024 -0
- package/src/components/input/index.tsx +23 -4
- package/src/components/scroll-canvas/index.tsx +220 -0
- package/src/components/scroll-text/index.tsx +277 -0
- package/src/components/select/index.tsx +4 -9
- package/src/components/slider/index.tsx +4 -7
- package/src/components/switch/index.tsx +5 -8
- package/src/components/thinking-orb/index.tsx +792 -0
- package/src/components/toggle-button/index.tsx +24 -2
- package/src/hooks/index.ts +6 -0
- package/src/hooks/use-keyboard-avoidance.ts +111 -65
- package/src/hooks/use-reveal-progress.ts +106 -0
- package/src/index.ts +46 -0
- package/src/native/index.ts +15 -7
- package/src/primitives/keyboard-avoider.tsx +41 -4
- 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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
{/*
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
<Host
|
|
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
|
-
|
|
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)}
|