panelui-native 0.84.0 → 0.86.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 +17 -8
- package/lib/module/components/bottom-sheet/index.js +27 -15
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +37 -16
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/flip-card/index.js +338 -0
- package/lib/module/components/flip-card/index.js.map +1 -0
- package/lib/module/components/popover/index.js +3 -2
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/progress-button/index.js +83 -18
- package/lib/module/components/progress-button/index.js.map +1 -1
- package/lib/module/components/select/index.js +3 -2
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/slider/index.js +16 -5
- package/lib/module/components/slider/index.js.map +1 -1
- package/lib/module/components/switch/index.js +28 -7
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +119 -28
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +37 -6
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js +7 -0
- package/lib/module/native/index.js.map +1 -1
- package/lib/module/native/native-host.js +62 -0
- package/lib/module/native/native-host.js.map +1 -0
- package/lib/module/primitives/glass.js +14 -0
- package/lib/module/primitives/glass.js.map +1 -1
- package/lib/module/providers/panel-ui-provider.js +35 -7
- package/lib/module/providers/panel-ui-provider.js.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/flip-card/index.d.ts +116 -0
- package/lib/typescript/src/components/flip-card/index.d.ts.map +1 -0
- package/lib/typescript/src/components/progress-button/index.d.ts +38 -0
- package/lib/typescript/src/components/progress-button/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 +0 -4
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +28 -11
- package/lib/typescript/src/native/index.d.ts.map +1 -1
- package/lib/typescript/src/native/native-host.d.ts +71 -0
- package/lib/typescript/src/native/native-host.d.ts.map +1 -0
- package/lib/typescript/src/primitives/glass.d.ts.map +1 -1
- package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
- package/package.json +11 -1
- package/src/components/bottom-sheet/index.tsx +31 -16
- package/src/components/button/index.tsx +36 -15
- package/src/components/flip-card/index.tsx +419 -0
- package/src/components/popover/index.tsx +3 -3
- package/src/components/progress-button/index.tsx +58 -18
- package/src/components/select/index.tsx +3 -3
- package/src/components/slider/index.tsx +28 -6
- package/src/components/switch/index.tsx +28 -7
- package/src/components/tabs/index.tsx +112 -31
- package/src/hooks/use-keyboard-avoidance.ts +39 -5
- package/src/index.ts +9 -0
- package/src/native/index.ts +35 -10
- package/src/native/native-host.tsx +75 -0
- package/src/primitives/glass.tsx +12 -0
- package/src/providers/panel-ui-provider.tsx +39 -7
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlipCard — two faces of one card, and a turn between them.
|
|
3
|
+
*
|
|
4
|
+
* ```tsx
|
|
5
|
+
* <FlipCard>
|
|
6
|
+
* <FlipCard.Front className="items-center justify-center rounded-2xl bg-card p-6">
|
|
7
|
+
* <Text>Tap me</Text>
|
|
8
|
+
* </FlipCard.Front>
|
|
9
|
+
* <FlipCard.Back className="items-center justify-center rounded-2xl bg-primary p-6">
|
|
10
|
+
* <Text className="text-primary-foreground">The other side</Text>
|
|
11
|
+
* </FlipCard.Back>
|
|
12
|
+
* </FlipCard>
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* For content that is genuinely two-sided — a bank card and its security code,
|
|
16
|
+
* a term and its definition, a photograph and what it is of. Not for
|
|
17
|
+
* progressive disclosure: the back replaces the front rather than extending
|
|
18
|
+
* it, so anything the reader needs to compare across the turn belongs in
|
|
19
|
+
* [Collapsible](../collapsible) or a second card instead.
|
|
20
|
+
*
|
|
21
|
+
* ## One box, and both faces in it
|
|
22
|
+
*
|
|
23
|
+
* The back is laid over the front, so there is one box and the two faces fill
|
|
24
|
+
* it. State the height on the root and both faces take it; leave it off and
|
|
25
|
+
* the front's own content decides, with the back given whatever that came to.
|
|
26
|
+
*
|
|
27
|
+
* A back with more in it than the front overflows rather than growing the
|
|
28
|
+
* card, and the fix is a height on the root rather than more padding on the
|
|
29
|
+
* back — a card whose height changed halfway through the turn would be a card
|
|
30
|
+
* that moved everything under it.
|
|
31
|
+
*
|
|
32
|
+
* ## Both faces are hidden the same way twice
|
|
33
|
+
*
|
|
34
|
+
* `backfaceVisibility` is the mechanism, and it is not reliable on every
|
|
35
|
+
* Android surface — so the face that has turned away is also faded out and
|
|
36
|
+
* dropped behind, on the same shared value, at the halfway point. Two
|
|
37
|
+
* mechanisms for one job, because the failure of the first is a card that
|
|
38
|
+
* shows both faces mirrored through each other and nothing in the tree that
|
|
39
|
+
* says why.
|
|
40
|
+
*
|
|
41
|
+
* ## Under reduce motion
|
|
42
|
+
*
|
|
43
|
+
* The faces swap with no turn at all. Not a shorter turn — none. The rotation
|
|
44
|
+
* is the part that moves, and moving is the part the setting is about; which
|
|
45
|
+
* face is showing is the information, and it is kept.
|
|
46
|
+
*/
|
|
47
|
+
import {
|
|
48
|
+
Children,
|
|
49
|
+
createContext,
|
|
50
|
+
forwardRef,
|
|
51
|
+
isValidElement,
|
|
52
|
+
useCallback,
|
|
53
|
+
useContext,
|
|
54
|
+
useEffect,
|
|
55
|
+
useMemo,
|
|
56
|
+
useState,
|
|
57
|
+
type ReactNode,
|
|
58
|
+
} from 'react';
|
|
59
|
+
import { StyleSheet, View, type ViewProps } from 'react-native';
|
|
60
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
61
|
+
import Animated, {
|
|
62
|
+
cancelAnimation,
|
|
63
|
+
interpolate,
|
|
64
|
+
runOnJS,
|
|
65
|
+
useAnimatedStyle,
|
|
66
|
+
useReducedMotion,
|
|
67
|
+
useSharedValue,
|
|
68
|
+
withSpring,
|
|
69
|
+
type SharedValue,
|
|
70
|
+
} from 'react-native-reanimated';
|
|
71
|
+
import { tv } from 'tailwind-variants';
|
|
72
|
+
import { cn } from '../../utils/cn';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How the card settles on a face.
|
|
76
|
+
*
|
|
77
|
+
* Clamped, and a little heavier than the library's other springs. A card has
|
|
78
|
+
* apparent mass — it is a rectangle the size of your hand — and a turn that
|
|
79
|
+
* overshoots and comes back reads as a playing card being flicked rather than
|
|
80
|
+
* an object being turned over. The overshoot is also the one place the hidden
|
|
81
|
+
* face can appear, since it puts the rotation past 360.
|
|
82
|
+
*/
|
|
83
|
+
const SPRING = {
|
|
84
|
+
damping: 18,
|
|
85
|
+
stiffness: 140,
|
|
86
|
+
mass: 0.9,
|
|
87
|
+
overshootClamping: true,
|
|
88
|
+
} as const;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Distance across the card that counts as a whole turn, as a fraction of its
|
|
92
|
+
* own width or height.
|
|
93
|
+
*
|
|
94
|
+
* Less than the full span: a drag that has crossed two thirds of the card has
|
|
95
|
+
* made its point, and requiring the whole width means the last part of every
|
|
96
|
+
* deliberate flip is spent travelling to an edge the finger has to reach.
|
|
97
|
+
*/
|
|
98
|
+
const DRAG_SPAN = 0.66;
|
|
99
|
+
|
|
100
|
+
/** Velocity, in points per second, that carries a short drag over anyway. */
|
|
101
|
+
const FLICK_VELOCITY = 500;
|
|
102
|
+
|
|
103
|
+
const flipCardVariants = tv({
|
|
104
|
+
slots: {
|
|
105
|
+
root: 'relative',
|
|
106
|
+
face: 'w-full',
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** Which axis the card turns about. */
|
|
111
|
+
export type FlipCardDirection = 'horizontal' | 'vertical';
|
|
112
|
+
|
|
113
|
+
/** Which way round it turns. */
|
|
114
|
+
export type FlipCardRotation = 'normal' | 'reverse';
|
|
115
|
+
|
|
116
|
+
/** What flips the card. */
|
|
117
|
+
export type FlipCardTrigger = 'press' | 'drag' | 'none';
|
|
118
|
+
|
|
119
|
+
interface FlipCardContextValue {
|
|
120
|
+
flipped: boolean;
|
|
121
|
+
direction: FlipCardDirection;
|
|
122
|
+
rotation: FlipCardRotation;
|
|
123
|
+
perspective: number;
|
|
124
|
+
progress: SharedValue<number>;
|
|
125
|
+
flip: () => void;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const FlipCardContext = createContext<FlipCardContextValue | null>(null);
|
|
129
|
+
|
|
130
|
+
function useFlipCardContext(component: string) {
|
|
131
|
+
const context = useContext(FlipCardContext);
|
|
132
|
+
if (!context) throw new Error(`${component} must be used within a <FlipCard>`);
|
|
133
|
+
return context;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The card's state, for a face that needs to know which way round it is.
|
|
138
|
+
*
|
|
139
|
+
* `progress` runs 0 → 1 across the turn and is a shared value, so a face can
|
|
140
|
+
* drive its own animation off the same turn rather than starting a second one
|
|
141
|
+
* beside it.
|
|
142
|
+
*/
|
|
143
|
+
export function useFlipCard() {
|
|
144
|
+
const { flipped, direction, rotation, progress, flip } = useFlipCardContext('useFlipCard');
|
|
145
|
+
return { flipped, direction, rotation, progress, flip };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface FlipCardProps extends Omit<ViewProps, 'children'> {
|
|
149
|
+
/** `FlipCard.Front` and `FlipCard.Back`, in either order. */
|
|
150
|
+
children?: ReactNode;
|
|
151
|
+
/**
|
|
152
|
+
* Which axis the card turns about. `horizontal` turns it left to right about
|
|
153
|
+
* its vertical axis; `vertical` turns it top over bottom.
|
|
154
|
+
*/
|
|
155
|
+
direction?: FlipCardDirection;
|
|
156
|
+
/**
|
|
157
|
+
* Which way round the turn goes. `reverse` sends it the other way, for a
|
|
158
|
+
* pair of cards that should not turn identically.
|
|
159
|
+
*/
|
|
160
|
+
rotation?: FlipCardRotation;
|
|
161
|
+
/**
|
|
162
|
+
* Which face is showing, when the caller holds it. Leave unset to let the
|
|
163
|
+
* card keep its own, and pair with `trigger="none"` for a card flipped only
|
|
164
|
+
* from outside.
|
|
165
|
+
*/
|
|
166
|
+
flipped?: boolean;
|
|
167
|
+
/** Which face an uncontrolled card starts on. */
|
|
168
|
+
defaultFlipped?: boolean;
|
|
169
|
+
/** Fires whenever the card settles on the other face, however it got there. */
|
|
170
|
+
onFlippedChange?: (flipped: boolean) => void;
|
|
171
|
+
/**
|
|
172
|
+
* What turns the card. `press` is a tap anywhere on it; `drag` turns it with
|
|
173
|
+
* the finger and springs to whichever face is nearer on release; `none`
|
|
174
|
+
* leaves it to `flipped`.
|
|
175
|
+
*/
|
|
176
|
+
trigger?: FlipCardTrigger;
|
|
177
|
+
/**
|
|
178
|
+
* How deep the turn looks, in points. Smaller is more dramatic — the near
|
|
179
|
+
* edge swings further out — and below about 400 a full card starts to read
|
|
180
|
+
* as a door rather than a card.
|
|
181
|
+
*/
|
|
182
|
+
perspective?: number;
|
|
183
|
+
/** Classes for the card's own box. The front's size is the card's size. */
|
|
184
|
+
className?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const FlipCardRoot = forwardRef<View, FlipCardProps>(
|
|
188
|
+
(
|
|
189
|
+
{
|
|
190
|
+
children,
|
|
191
|
+
direction = 'horizontal',
|
|
192
|
+
rotation = 'normal',
|
|
193
|
+
flipped: flippedProp,
|
|
194
|
+
defaultFlipped = false,
|
|
195
|
+
onFlippedChange,
|
|
196
|
+
trigger = 'press',
|
|
197
|
+
perspective = 1000,
|
|
198
|
+
className,
|
|
199
|
+
...props
|
|
200
|
+
},
|
|
201
|
+
ref
|
|
202
|
+
) => {
|
|
203
|
+
const [internal, setInternal] = useState(defaultFlipped);
|
|
204
|
+
const isControlled = flippedProp !== undefined;
|
|
205
|
+
const flipped = isControlled ? flippedProp : internal;
|
|
206
|
+
|
|
207
|
+
const progress = useSharedValue(defaultFlipped ? 1 : 0);
|
|
208
|
+
const startProgress = useSharedValue(0);
|
|
209
|
+
const span = useSharedValue(0);
|
|
210
|
+
const reduceMotion = useReducedMotion();
|
|
211
|
+
|
|
212
|
+
const { root, face } = flipCardVariants();
|
|
213
|
+
|
|
214
|
+
/*
|
|
215
|
+
* The settled face is React state and the turn is a shared value, and the
|
|
216
|
+
* two are kept in step from here rather than from the gesture: a drag that
|
|
217
|
+
* is let go past halfway has already decided, and telling React at the
|
|
218
|
+
* moment of release rather than at the end of the spring is what keeps the
|
|
219
|
+
* hidden face out of the accessibility tree while it is still turning.
|
|
220
|
+
*/
|
|
221
|
+
const settle = useCallback(
|
|
222
|
+
(next: boolean) => {
|
|
223
|
+
if (!isControlled) setInternal(next);
|
|
224
|
+
onFlippedChange?.(next);
|
|
225
|
+
},
|
|
226
|
+
[isControlled, onFlippedChange]
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
progress.value = reduceMotion
|
|
231
|
+
? flipped
|
|
232
|
+
? 1
|
|
233
|
+
: 0
|
|
234
|
+
: withSpring(flipped ? 1 : 0, SPRING);
|
|
235
|
+
}, [flipped, progress, reduceMotion]);
|
|
236
|
+
|
|
237
|
+
useEffect(() => () => cancelAnimation(progress), [progress]);
|
|
238
|
+
|
|
239
|
+
const flip = useCallback(() => settle(!flipped), [flipped, settle]);
|
|
240
|
+
|
|
241
|
+
const gesture = useMemo(() => {
|
|
242
|
+
const tap = Gesture.Tap()
|
|
243
|
+
.enabled(trigger === 'press')
|
|
244
|
+
.onEnd((_event, success) => {
|
|
245
|
+
if (success) runOnJS(flip)();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/*
|
|
249
|
+
* The span is read from the shared value rather than from a captured
|
|
250
|
+
* number, so a card that is measured or resized mid-touch does not leave
|
|
251
|
+
* the gesture dividing by the width it had when the finger landed.
|
|
252
|
+
*/
|
|
253
|
+
const pan = Gesture.Pan()
|
|
254
|
+
.enabled(trigger === 'drag')
|
|
255
|
+
.onBegin(() => {
|
|
256
|
+
startProgress.value = progress.value;
|
|
257
|
+
})
|
|
258
|
+
.onUpdate((event) => {
|
|
259
|
+
const travel = direction === 'horizontal' ? event.translationX : event.translationY;
|
|
260
|
+
const reach = Math.max(span.value * DRAG_SPAN, 1);
|
|
261
|
+
const next = startProgress.value + travel / reach;
|
|
262
|
+
progress.value = Math.min(Math.max(next, 0), 1);
|
|
263
|
+
})
|
|
264
|
+
.onEnd((event) => {
|
|
265
|
+
const velocity = direction === 'horizontal' ? event.velocityX : event.velocityY;
|
|
266
|
+
const committed =
|
|
267
|
+
progress.value > 0.5 || (progress.value > 0.1 && velocity > FLICK_VELOCITY);
|
|
268
|
+
runOnJS(settle)(committed);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
return Gesture.Exclusive(pan, tap);
|
|
272
|
+
}, [direction, flip, progress, settle, span, startProgress, trigger]);
|
|
273
|
+
|
|
274
|
+
/*
|
|
275
|
+
* One turn, read two ways. The front runs 0 → 180 and the back 180 → 360,
|
|
276
|
+
* so at any moment exactly one of them has its face toward the reader.
|
|
277
|
+
*
|
|
278
|
+
* `sign` is not an RTL mirror. Turning a physical object over is a physical
|
|
279
|
+
* direction, not a reading direction, and a card that spun the other way
|
|
280
|
+
* in an Arabic layout would be a different card rather than the same one
|
|
281
|
+
* laid out correctly.
|
|
282
|
+
*/
|
|
283
|
+
const sign = rotation === 'reverse' ? -1 : 1;
|
|
284
|
+
const axis = direction === 'horizontal' ? 'rotateY' : 'rotateX';
|
|
285
|
+
|
|
286
|
+
const frontStyle = useAnimatedStyle(() => {
|
|
287
|
+
const angle = interpolate(progress.value, [0, 1], [0, 180 * sign]);
|
|
288
|
+
const showing = progress.value < 0.5;
|
|
289
|
+
return {
|
|
290
|
+
transform: [{ perspective }, { [axis]: `${angle}deg` } as never],
|
|
291
|
+
opacity: showing ? 1 : 0,
|
|
292
|
+
zIndex: showing ? 1 : 0,
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const backStyle = useAnimatedStyle(() => {
|
|
297
|
+
const angle = interpolate(progress.value, [0, 1], [-180 * sign, 0]);
|
|
298
|
+
const showing = progress.value >= 0.5;
|
|
299
|
+
return {
|
|
300
|
+
transform: [{ perspective }, { [axis]: `${angle}deg` } as never],
|
|
301
|
+
opacity: showing ? 1 : 0,
|
|
302
|
+
zIndex: showing ? 1 : 0,
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
let front: ReactNode = null;
|
|
307
|
+
let back: ReactNode = null;
|
|
308
|
+
for (const child of Children.toArray(children)) {
|
|
309
|
+
if (!isValidElement(child)) continue;
|
|
310
|
+
if (child.type === FlipCardFront) front = child;
|
|
311
|
+
else if (child.type === FlipCardBack) back = child;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const context = useMemo<FlipCardContextValue>(
|
|
315
|
+
() => ({ flipped, direction, rotation, perspective, progress, flip }),
|
|
316
|
+
[direction, flip, flipped, perspective, progress, rotation]
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
/*
|
|
320
|
+
* The gesture goes on the card itself rather than on a view around it.
|
|
321
|
+
*
|
|
322
|
+
* A wrapper is an extra box with no width of its own, and a card sized
|
|
323
|
+
* `w-full` inside one is asking for a percentage of nothing — which is how
|
|
324
|
+
* a two-hundred-point card ended up taller than the screen and hanging off
|
|
325
|
+
* the side of it.
|
|
326
|
+
*
|
|
327
|
+
* The accessibility action rides here for the same reason. A drag is not
|
|
328
|
+
* available to a screen reader, and a card that can only be turned by
|
|
329
|
+
* dragging is a card some readers can never see the back of.
|
|
330
|
+
*/
|
|
331
|
+
const card = (
|
|
332
|
+
<View
|
|
333
|
+
ref={ref}
|
|
334
|
+
onLayout={(event) => {
|
|
335
|
+
span.value =
|
|
336
|
+
direction === 'horizontal'
|
|
337
|
+
? event.nativeEvent.layout.width
|
|
338
|
+
: event.nativeEvent.layout.height;
|
|
339
|
+
}}
|
|
340
|
+
accessible={trigger !== 'none'}
|
|
341
|
+
accessibilityRole={trigger === 'none' ? undefined : 'button'}
|
|
342
|
+
accessibilityState={{ expanded: flipped }}
|
|
343
|
+
accessibilityActions={
|
|
344
|
+
trigger === 'none' ? undefined : [{ name: 'activate', label: 'Flip the card over' }]
|
|
345
|
+
}
|
|
346
|
+
onAccessibilityAction={trigger === 'none' ? undefined : flip}
|
|
347
|
+
{...props}
|
|
348
|
+
className={root({ className })}
|
|
349
|
+
>
|
|
350
|
+
{/* The front is in flow and the back is laid over it, so the card is
|
|
351
|
+
the front's size on both axes and the back is handed that box. */}
|
|
352
|
+
<Animated.View
|
|
353
|
+
style={frontStyle}
|
|
354
|
+
className={face({ className: 'grow' })}
|
|
355
|
+
accessibilityElementsHidden={flipped}
|
|
356
|
+
importantForAccessibility={flipped ? 'no-hide-descendants' : 'auto'}
|
|
357
|
+
>
|
|
358
|
+
{front}
|
|
359
|
+
</Animated.View>
|
|
360
|
+
<Animated.View
|
|
361
|
+
style={[StyleSheet.absoluteFill, backStyle]}
|
|
362
|
+
className={face()}
|
|
363
|
+
accessibilityElementsHidden={!flipped}
|
|
364
|
+
importantForAccessibility={flipped ? 'auto' : 'no-hide-descendants'}
|
|
365
|
+
>
|
|
366
|
+
{back}
|
|
367
|
+
</Animated.View>
|
|
368
|
+
</View>
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<FlipCardContext.Provider value={context}>
|
|
373
|
+
{/* A gesture rather than a Pressable, and no press feedback with it.
|
|
374
|
+
The card already answers the touch by turning; dipping it first
|
|
375
|
+
makes the turn look like it started late. */}
|
|
376
|
+
{trigger === 'none' ? card : <GestureDetector gesture={gesture}>{card}</GestureDetector>}
|
|
377
|
+
</FlipCardContext.Provider>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
export interface FlipCardFaceProps extends ViewProps {
|
|
383
|
+
className?: string;
|
|
384
|
+
children?: ReactNode;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The face showing at rest. Its size is the card's size. */
|
|
388
|
+
const FlipCardFront = forwardRef<View, FlipCardFaceProps>(({ className, ...props }, ref) => (
|
|
389
|
+
<View
|
|
390
|
+
ref={ref}
|
|
391
|
+
// The backface is the first of the two mechanisms that hide a face turned
|
|
392
|
+
// away; the opacity on the animated parent is the second.
|
|
393
|
+
style={{ backfaceVisibility: 'hidden' }}
|
|
394
|
+
{...props}
|
|
395
|
+
// `h-full` of a card that states no height is ignored rather than
|
|
396
|
+
// collapsing, so one shape covers both: a card given a height is filled,
|
|
397
|
+
// and a card left to its content still measures it.
|
|
398
|
+
className={cn('h-full w-full', className)}
|
|
399
|
+
/>
|
|
400
|
+
));
|
|
401
|
+
|
|
402
|
+
/** The face revealed by the turn, laid over the front. */
|
|
403
|
+
const FlipCardBack = forwardRef<View, FlipCardFaceProps>(({ className, ...props }, ref) => (
|
|
404
|
+
<View
|
|
405
|
+
ref={ref}
|
|
406
|
+
style={{ backfaceVisibility: 'hidden' }}
|
|
407
|
+
{...props}
|
|
408
|
+
className={cn('h-full w-full', className)}
|
|
409
|
+
/>
|
|
410
|
+
));
|
|
411
|
+
|
|
412
|
+
FlipCardRoot.displayName = 'FlipCard';
|
|
413
|
+
FlipCardFront.displayName = 'FlipCard.Front';
|
|
414
|
+
FlipCardBack.displayName = 'FlipCard.Back';
|
|
415
|
+
|
|
416
|
+
export const FlipCard = Object.assign(FlipCardRoot, {
|
|
417
|
+
Front: FlipCardFront,
|
|
418
|
+
Back: FlipCardBack,
|
|
419
|
+
});
|
|
@@ -63,7 +63,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
63
63
|
import { FocusRestorePortal } from '../../primitives/portal';
|
|
64
64
|
import { Scrim } from '../../primitives/scrim';
|
|
65
65
|
import { useBackHandler } from '../../hooks/use-back-handler';
|
|
66
|
-
import { getSwiftUI } from '../../native';
|
|
66
|
+
import { NativeHost, getSwiftUI } from '../../native';
|
|
67
67
|
import { BottomSheet } from '../bottom-sheet';
|
|
68
68
|
import { Text, type TextProps, textChildren } from '../../primitives/text';
|
|
69
69
|
import { cn } from '../../utils/cn';
|
|
@@ -315,7 +315,7 @@ function NativePopover({
|
|
|
315
315
|
typeof contentProps?.width === 'number' ? contentProps.width : NATIVE_PANEL_WIDTH;
|
|
316
316
|
|
|
317
317
|
return (
|
|
318
|
-
<Host matchContents ignoreSafeArea="keyboard">
|
|
318
|
+
<NativeHost host={Host} matchContents ignoreSafeArea="keyboard">
|
|
319
319
|
<PlatformPopover
|
|
320
320
|
isPresented={open}
|
|
321
321
|
onIsPresentedChange={setOpen}
|
|
@@ -338,7 +338,7 @@ function NativePopover({
|
|
|
338
338
|
</RNHostView>
|
|
339
339
|
</PlatformPopover.Content>
|
|
340
340
|
</PlatformPopover>
|
|
341
|
-
</
|
|
341
|
+
</NativeHost>
|
|
342
342
|
);
|
|
343
343
|
}
|
|
344
344
|
|
|
@@ -116,15 +116,6 @@ const DONE_TINT = {
|
|
|
116
116
|
|
|
117
117
|
const progressButtonVariants = tv({
|
|
118
118
|
slots: {
|
|
119
|
-
/*
|
|
120
|
-
* A pill, not the `rounded-lg` the other buttons take.
|
|
121
|
-
*
|
|
122
|
-
* The fill is clipped by this radius, so the shape of the button is also
|
|
123
|
-
* the shape of the wipe's leading edge as it comes out of the corner. On a
|
|
124
|
-
* small radius that edge emerges square from a rounded box, which reads as
|
|
125
|
-
* a rectangle sliding out from under the button rather than as the button
|
|
126
|
-
* filling up.
|
|
127
|
-
*/
|
|
128
119
|
/*
|
|
129
120
|
* Every variant rests on the same secondary surface, and `variant` decides
|
|
130
121
|
* only what colour comes across it.
|
|
@@ -140,7 +131,7 @@ const progressButtonVariants = tv({
|
|
|
140
131
|
* that survives the wipe, since the fill covers the ground the label was
|
|
141
132
|
* standing on and the second copy takes over.
|
|
142
133
|
*/
|
|
143
|
-
root: 'relative overflow-hidden
|
|
134
|
+
root: 'relative overflow-hidden border border-transparent bg-secondary',
|
|
144
135
|
/*
|
|
145
136
|
* The row inside the button. It is separate from `root` because the fill
|
|
146
137
|
* has to sit over the whole button including its padding — a wipe that
|
|
@@ -179,29 +170,50 @@ const progressButtonVariants = tv({
|
|
|
179
170
|
size: {
|
|
180
171
|
// Matched to Button's boxes, and `min-h-*` for the same reason: the
|
|
181
172
|
// label's glyphs grow with the system text size and the box has to grow
|
|
182
|
-
// with them.
|
|
183
|
-
// Wider than the equivalent Button, because the corner is a half-circle
|
|
184
|
-
// rather than a small radius: the curve eats into the side padding, and
|
|
185
|
-
// at Button's values the first and last glyphs sit against it.
|
|
173
|
+
// with them. The side padding is `shape`'s to decide, below.
|
|
186
174
|
sm: {
|
|
187
175
|
root: 'min-h-9',
|
|
188
|
-
content: 'min-h-9 gap-1.5
|
|
176
|
+
content: 'min-h-9 gap-1.5 py-2',
|
|
189
177
|
label: 'text-[14px]',
|
|
190
178
|
fillLabel: 'text-[14px]',
|
|
191
179
|
},
|
|
192
180
|
md: {
|
|
193
181
|
root: 'min-h-11',
|
|
194
|
-
content: 'min-h-11
|
|
182
|
+
content: 'min-h-11 py-2.5',
|
|
195
183
|
label: 'text-[16px]',
|
|
196
184
|
fillLabel: 'text-[16px]',
|
|
197
185
|
},
|
|
198
186
|
lg: {
|
|
199
187
|
root: 'min-h-12',
|
|
200
|
-
content: 'min-h-12
|
|
188
|
+
content: 'min-h-12 py-2.5',
|
|
201
189
|
label: 'text-[18px]',
|
|
202
190
|
fillLabel: 'text-[18px]',
|
|
203
191
|
},
|
|
204
192
|
},
|
|
193
|
+
/*
|
|
194
|
+
* The corner, and the side padding that follows from it.
|
|
195
|
+
*
|
|
196
|
+
* The fill is clipped by the button's radius, so the shape of the button is
|
|
197
|
+
* also the shape of the wipe's leading edge as it comes out of the corner.
|
|
198
|
+
* A pill sends that edge out as a curve, which reads as the button filling
|
|
199
|
+
* up; a small radius sends it out square, which reads as a rectangle
|
|
200
|
+
* sliding out from under the button. That is why `pill` is the default and
|
|
201
|
+
* stays it.
|
|
202
|
+
*
|
|
203
|
+
* `rounded` is for a progress button standing in a row of ordinary ones —
|
|
204
|
+
* a form's footer, a toolbar, a card's actions — where a lone pill among
|
|
205
|
+
* `rounded-lg` buttons reads as a different kind of control rather than as
|
|
206
|
+
* the one that has to be held. It is `Button`'s box exactly: same radius,
|
|
207
|
+
* same padding, same minimum width, and the heights already agreed.
|
|
208
|
+
*/
|
|
209
|
+
shape: {
|
|
210
|
+
// The radius is only on the root. It has `overflow-hidden`, so it is what
|
|
211
|
+
// clips the fill — and the fill's own leading edge has to stay straight:
|
|
212
|
+
// rounding it would round the wipe's front as well as the button's
|
|
213
|
+
// corner, which is a bar with a domed end travelling across the button.
|
|
214
|
+
pill: { root: 'rounded-full' },
|
|
215
|
+
rounded: { root: 'rounded-lg' },
|
|
216
|
+
},
|
|
205
217
|
fullWidth: {
|
|
206
218
|
true: { root: 'w-full' },
|
|
207
219
|
},
|
|
@@ -209,9 +221,22 @@ const progressButtonVariants = tv({
|
|
|
209
221
|
true: { root: 'opacity-[0.64]' },
|
|
210
222
|
},
|
|
211
223
|
},
|
|
224
|
+
compoundVariants: [
|
|
225
|
+
// A pill is wider than the equivalent Button at every size, because the
|
|
226
|
+
// corner is a half-circle rather than a small radius: the curve eats into
|
|
227
|
+
// the side padding, and at Button's values the first and last glyphs sit
|
|
228
|
+
// against it. `rounded` has Button's corner, so it takes Button's padding.
|
|
229
|
+
{ shape: 'pill', size: 'sm', class: { content: 'px-3.5' } },
|
|
230
|
+
{ shape: 'pill', size: 'md', class: { content: 'px-5' } },
|
|
231
|
+
{ shape: 'pill', size: 'lg', class: { content: 'px-7' } },
|
|
232
|
+
{ shape: 'rounded', size: 'sm', class: { root: 'min-w-9', content: 'px-2.5' } },
|
|
233
|
+
{ shape: 'rounded', size: 'md', class: { root: 'min-w-11', content: 'px-4' } },
|
|
234
|
+
{ shape: 'rounded', size: 'lg', class: { content: 'px-6' } },
|
|
235
|
+
],
|
|
212
236
|
defaultVariants: {
|
|
213
237
|
variant: 'primary',
|
|
214
238
|
size: 'md',
|
|
239
|
+
shape: 'pill',
|
|
215
240
|
},
|
|
216
241
|
});
|
|
217
242
|
|
|
@@ -221,6 +246,8 @@ type ProgressButtonVariantProps = VariantProps<typeof progressButtonVariants>;
|
|
|
221
246
|
export type ProgressButtonVariant = NonNullable<ProgressButtonVariantProps['variant']>;
|
|
222
247
|
/** How big a progress button is. */
|
|
223
248
|
export type ProgressButtonSize = NonNullable<ProgressButtonVariantProps['size']>;
|
|
249
|
+
/** What corner a progress button has. */
|
|
250
|
+
export type ProgressButtonShape = NonNullable<ProgressButtonVariantProps['shape']>;
|
|
224
251
|
|
|
225
252
|
interface ProgressButtonContextValue {
|
|
226
253
|
/** `0` to `1` across the hold. */
|
|
@@ -282,6 +309,18 @@ export interface ProgressButtonProps
|
|
|
282
309
|
* whether an action is worth feeling is the caller's call, not the control's.
|
|
283
310
|
*/
|
|
284
311
|
haptics?: boolean;
|
|
312
|
+
/**
|
|
313
|
+
* The corner. `pill` by default — the fill is clipped by it, so a half-circle
|
|
314
|
+
* sends the wipe's leading edge out as a curve and the button reads as
|
|
315
|
+
* filling up.
|
|
316
|
+
*
|
|
317
|
+
* `rounded` gives it [Button](/docs/components/button)'s box exactly: the
|
|
318
|
+
* same radius, side padding and minimum width, at heights that already
|
|
319
|
+
* matched. Use it where the hold stands in a row of ordinary buttons — a
|
|
320
|
+
* form's footer, a toolbar, a card's actions — and a lone pill would read as
|
|
321
|
+
* a different kind of control rather than as the one that has to be held.
|
|
322
|
+
*/
|
|
323
|
+
shape?: ProgressButtonShape;
|
|
285
324
|
children?: ReactNode;
|
|
286
325
|
}
|
|
287
326
|
|
|
@@ -290,6 +329,7 @@ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function Progre
|
|
|
290
329
|
className,
|
|
291
330
|
variant = 'primary',
|
|
292
331
|
size = 'md',
|
|
332
|
+
shape = 'pill',
|
|
293
333
|
fullWidth,
|
|
294
334
|
holdDuration,
|
|
295
335
|
onComplete,
|
|
@@ -305,7 +345,7 @@ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function Progre
|
|
|
305
345
|
},
|
|
306
346
|
ref
|
|
307
347
|
) {
|
|
308
|
-
const slots = progressButtonVariants({ variant, size, fullWidth, disabled });
|
|
348
|
+
const slots = progressButtonVariants({ variant, size, shape, fullWidth, disabled });
|
|
309
349
|
const duration = resolveHoldDuration(holdDuration);
|
|
310
350
|
const reducedMotion = useReducedMotion();
|
|
311
351
|
|
|
@@ -63,7 +63,7 @@ import Animated, {
|
|
|
63
63
|
import { tv } from 'tailwind-variants';
|
|
64
64
|
import { useCSSVariable } from 'uniwind';
|
|
65
65
|
import { CheckIcon, ChevronDownIcon, SearchIcon } from '../../icons';
|
|
66
|
-
import { getNativeUI } from '../../native';
|
|
66
|
+
import { NativeHost, getNativeUI } from '../../native';
|
|
67
67
|
import { Portal } from '../../primitives/portal';
|
|
68
68
|
import { Text, textChildren } from '../../primitives/text';
|
|
69
69
|
import { useBackHandler } from '../../hooks/use-back-handler';
|
|
@@ -568,7 +568,7 @@ function SelectRoot({
|
|
|
568
568
|
// A picker fills the width of the row it sits in and reports its own
|
|
569
569
|
// height — a menu is a compact button, a wheel a full rotor, and the
|
|
570
570
|
// platform is the only thing that knows which by how much.
|
|
571
|
-
<Host matchContents={{ vertical: true }} ignoreSafeArea="keyboard">
|
|
571
|
+
<NativeHost host={Host} matchContents={{ vertical: true }} ignoreSafeArea="keyboard">
|
|
572
572
|
<Picker
|
|
573
573
|
selectedValue={value ?? firstEnabled?.value ?? ''}
|
|
574
574
|
onValueChange={(next: string) => onValueChange(next)}
|
|
@@ -579,7 +579,7 @@ function SelectRoot({
|
|
|
579
579
|
<Picker.Item key={option.value} label={option.label} value={option.value} />
|
|
580
580
|
))}
|
|
581
581
|
</Picker>
|
|
582
|
-
</
|
|
582
|
+
</NativeHost>
|
|
583
583
|
);
|
|
584
584
|
}
|
|
585
585
|
|
|
@@ -42,7 +42,7 @@ import Animated, {
|
|
|
42
42
|
} from 'react-native-reanimated';
|
|
43
43
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
44
44
|
import { useDirectionSign } from '../../hooks/use-direction';
|
|
45
|
-
import { getNativeUI } from '../../native';
|
|
45
|
+
import { NativeHost, getNativeUI } from '../../native';
|
|
46
46
|
import { Text } from '../../primitives/text';
|
|
47
47
|
import { selectionTick } from '../../utils/haptics';
|
|
48
48
|
|
|
@@ -102,6 +102,15 @@ const sliderVariants = tv({
|
|
|
102
102
|
/** Thumb width per size. Wider than it is tall, so the pill reads as a grip. */
|
|
103
103
|
const THUMB_WIDTH: Record<'sm' | 'md' | 'lg', number> = { sm: 24, md: 28, lg: 32 };
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The box the platform slider is given, in points.
|
|
107
|
+
*
|
|
108
|
+
* Both platforms draw a slider shorter than this and centre it, so the number
|
|
109
|
+
* is the row's height rather than the control's — and at 44 it is also the
|
|
110
|
+
* smallest a thing you drag is allowed to be.
|
|
111
|
+
*/
|
|
112
|
+
const NATIVE_SLIDER_HEIGHT = 44;
|
|
113
|
+
|
|
105
114
|
type SliderVariantProps = VariantProps<typeof sliderVariants>;
|
|
106
115
|
|
|
107
116
|
export interface SliderProps extends Omit<SliderVariantProps, 'disabled'> {
|
|
@@ -564,10 +573,23 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
564
573
|
return (
|
|
565
574
|
<View ref={ref} className={slots.root({ className })}>
|
|
566
575
|
{header}
|
|
567
|
-
{/*
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
576
|
+
{/*
|
|
577
|
+
Neither axis is left to the platform.
|
|
578
|
+
|
|
579
|
+
A slider has no intrinsic width — it fills whatever it is given —
|
|
580
|
+
so ordinary layout was always going to decide that. The height was
|
|
581
|
+
being matched, and matching is not a one-off: the host writes the
|
|
582
|
+
platform's measured size back into the layout on every geometry
|
|
583
|
+
change, so the row's height moved whenever the control was touched
|
|
584
|
+
and everything below it moved with it. The height is stated
|
|
585
|
+
instead.
|
|
586
|
+
*/}
|
|
587
|
+
<NativeHost
|
|
588
|
+
host={Host}
|
|
589
|
+
matchContents={false}
|
|
590
|
+
ignoreSafeArea="keyboard"
|
|
591
|
+
style={{ height: NATIVE_SLIDER_HEIGHT }}
|
|
592
|
+
>
|
|
571
593
|
<NativeSlider
|
|
572
594
|
value={value}
|
|
573
595
|
onValueChange={(next: number) => {
|
|
@@ -578,7 +600,7 @@ export const Slider = forwardRef<View, SliderProps>(
|
|
|
578
600
|
step={step}
|
|
579
601
|
disabled={disabled}
|
|
580
602
|
/>
|
|
581
|
-
</
|
|
603
|
+
</NativeHost>
|
|
582
604
|
</View>
|
|
583
605
|
);
|
|
584
606
|
}
|