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.
Files changed (66) hide show
  1. package/README.md +17 -8
  2. package/lib/module/components/bottom-sheet/index.js +27 -15
  3. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  4. package/lib/module/components/button/index.js +37 -16
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/flip-card/index.js +338 -0
  7. package/lib/module/components/flip-card/index.js.map +1 -0
  8. package/lib/module/components/popover/index.js +3 -2
  9. package/lib/module/components/popover/index.js.map +1 -1
  10. package/lib/module/components/progress-button/index.js +83 -18
  11. package/lib/module/components/progress-button/index.js.map +1 -1
  12. package/lib/module/components/select/index.js +3 -2
  13. package/lib/module/components/select/index.js.map +1 -1
  14. package/lib/module/components/slider/index.js +16 -5
  15. package/lib/module/components/slider/index.js.map +1 -1
  16. package/lib/module/components/switch/index.js +28 -7
  17. package/lib/module/components/switch/index.js.map +1 -1
  18. package/lib/module/components/tabs/index.js +119 -28
  19. package/lib/module/components/tabs/index.js.map +1 -1
  20. package/lib/module/hooks/use-keyboard-avoidance.js +37 -6
  21. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  22. package/lib/module/index.js +1 -0
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/native/index.js +7 -0
  25. package/lib/module/native/index.js.map +1 -1
  26. package/lib/module/native/native-host.js +62 -0
  27. package/lib/module/native/native-host.js.map +1 -0
  28. package/lib/module/primitives/glass.js +14 -0
  29. package/lib/module/primitives/glass.js.map +1 -1
  30. package/lib/module/providers/panel-ui-provider.js +35 -7
  31. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  32. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/flip-card/index.d.ts +116 -0
  35. package/lib/typescript/src/components/flip-card/index.d.ts.map +1 -0
  36. package/lib/typescript/src/components/progress-button/index.d.ts +38 -0
  37. package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -1
  38. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/switch/index.d.ts +0 -4
  40. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  42. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  43. package/lib/typescript/src/index.d.ts +1 -0
  44. package/lib/typescript/src/index.d.ts.map +1 -1
  45. package/lib/typescript/src/native/index.d.ts +28 -11
  46. package/lib/typescript/src/native/index.d.ts.map +1 -1
  47. package/lib/typescript/src/native/native-host.d.ts +71 -0
  48. package/lib/typescript/src/native/native-host.d.ts.map +1 -0
  49. package/lib/typescript/src/primitives/glass.d.ts.map +1 -1
  50. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  51. package/package.json +11 -1
  52. package/src/components/bottom-sheet/index.tsx +31 -16
  53. package/src/components/button/index.tsx +36 -15
  54. package/src/components/flip-card/index.tsx +419 -0
  55. package/src/components/popover/index.tsx +3 -3
  56. package/src/components/progress-button/index.tsx +58 -18
  57. package/src/components/select/index.tsx +3 -3
  58. package/src/components/slider/index.tsx +28 -6
  59. package/src/components/switch/index.tsx +28 -7
  60. package/src/components/tabs/index.tsx +112 -31
  61. package/src/hooks/use-keyboard-avoidance.ts +39 -5
  62. package/src/index.ts +9 -0
  63. package/src/native/index.ts +35 -10
  64. package/src/native/native-host.tsx +75 -0
  65. package/src/primitives/glass.tsx +12 -0
  66. 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
- </Host>
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 rounded-full border border-transparent bg-secondary',
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 px-3.5 py-2',
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 px-5 py-2.5',
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 px-7 py-2.5',
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
- </Host>
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
- {/* A slider has no intrinsic width — it fills whatever it is given —
568
- so the width comes from ordinary layout and only the height is
569
- matched to the platform's content. */}
570
- <Host matchContents={{ vertical: true }} ignoreSafeArea="keyboard">
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
- </Host>
603
+ </NativeHost>
582
604
  </View>
583
605
  );
584
606
  }