panelui-native 0.84.0 → 0.85.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 (49) hide show
  1. package/README.md +17 -8
  2. package/lib/module/components/bottom-sheet/index.js +3 -2
  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/select/index.js +3 -2
  11. package/lib/module/components/select/index.js.map +1 -1
  12. package/lib/module/components/slider/index.js +16 -5
  13. package/lib/module/components/slider/index.js.map +1 -1
  14. package/lib/module/components/switch/index.js +28 -7
  15. package/lib/module/components/switch/index.js.map +1 -1
  16. package/lib/module/index.js +1 -0
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/native/index.js +7 -0
  19. package/lib/module/native/index.js.map +1 -1
  20. package/lib/module/native/native-host.js +62 -0
  21. package/lib/module/native/native-host.js.map +1 -0
  22. package/lib/module/primitives/glass.js +14 -0
  23. package/lib/module/primitives/glass.js.map +1 -1
  24. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  25. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  26. package/lib/typescript/src/components/flip-card/index.d.ts +116 -0
  27. package/lib/typescript/src/components/flip-card/index.d.ts.map +1 -0
  28. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/switch/index.d.ts +0 -4
  30. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  31. package/lib/typescript/src/index.d.ts +1 -0
  32. package/lib/typescript/src/index.d.ts.map +1 -1
  33. package/lib/typescript/src/native/index.d.ts +28 -11
  34. package/lib/typescript/src/native/index.d.ts.map +1 -1
  35. package/lib/typescript/src/native/native-host.d.ts +71 -0
  36. package/lib/typescript/src/native/native-host.d.ts.map +1 -0
  37. package/lib/typescript/src/primitives/glass.d.ts.map +1 -1
  38. package/package.json +11 -1
  39. package/src/components/bottom-sheet/index.tsx +8 -3
  40. package/src/components/button/index.tsx +36 -15
  41. package/src/components/flip-card/index.tsx +419 -0
  42. package/src/components/popover/index.tsx +3 -3
  43. package/src/components/select/index.tsx +3 -3
  44. package/src/components/slider/index.tsx +28 -6
  45. package/src/components/switch/index.tsx +28 -7
  46. package/src/index.ts +9 -0
  47. package/src/native/index.ts +35 -10
  48. package/src/native/native-host.tsx +75 -0
  49. package/src/primitives/glass.tsx +12 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panelui-native",
3
- "version": "0.84.0",
3
+ "version": "0.85.0",
4
4
  "description": "High-performance React Native UI components for Expo. Semantic design tokens, powered by Uniwind (Tailwind v4) and Reanimated.",
5
5
  "main": "./lib/module/index.js",
6
6
  "module": "./lib/module/index.js",
@@ -32,6 +32,16 @@
32
32
  "react-native": "./src/theme/use-theme.ts",
33
33
  "default": "./lib/module/theme/use-theme.js"
34
34
  },
35
+ "./icons": {
36
+ "types": "./lib/typescript/src/icons/index.d.ts",
37
+ "react-native": "./src/icons/index.tsx",
38
+ "default": "./lib/module/icons/index.js"
39
+ },
40
+ "./primitives/text": {
41
+ "types": "./lib/typescript/src/primitives/text.d.ts",
42
+ "react-native": "./src/primitives/text.tsx",
43
+ "default": "./lib/module/primitives/text.js"
44
+ },
35
45
  "./primitives/animated-pressable": {
36
46
  "types": "./lib/typescript/src/primitives/animated-pressable.d.ts",
37
47
  "react-native": "./src/primitives/animated-pressable.tsx",
@@ -38,7 +38,7 @@ import Animated, {
38
38
  } from 'react-native-reanimated';
39
39
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
40
40
  import { tv } from 'tailwind-variants';
41
- import { getComposeModifiers, getNativeUI, getSwiftUIModifiers } from '../../native';
41
+ import { NativeHost, getComposeModifiers, getNativeUI, getSwiftUIModifiers } from '../../native';
42
42
  import { XIcon } from '../../icons';
43
43
  import { ModalPortal } from '../../primitives/portal';
44
44
  import { Scrim } from '../../primitives/scrim';
@@ -700,7 +700,12 @@ function BottomSheetContent({
700
700
  // sheet cannot measure RN views directly. Without it the sheet sizes to
701
701
  // nothing and the content spills outside its container.
702
702
  return (
703
- <Host matchContents ignoreSafeArea="keyboard" style={{ position: 'absolute' }}>
703
+ <NativeHost
704
+ host={Host}
705
+ matchContents
706
+ ignoreSafeArea="keyboard"
707
+ style={{ position: 'absolute' }}
708
+ >
704
709
  <NativeBottomSheet
705
710
  isPresented={nativePresented}
706
711
  onDismiss={onNativeDismiss}
@@ -734,7 +739,7 @@ function BottomSheetContent({
734
739
  </BottomSheetContext.Provider>
735
740
  </RNHostView>
736
741
  </NativeBottomSheet>
737
- </Host>
742
+ </NativeHost>
738
743
  );
739
744
  }
740
745
 
@@ -9,7 +9,7 @@ import {
9
9
  import { Text, textChildren } from '../../primitives/text';
10
10
  import { cn } from '../../utils/cn';
11
11
  import { IconColorProvider } from '../../icons';
12
- import { getNativeUI, getSwiftUIModifiers } from '../../native';
12
+ import { NativeHost, getNativeUI, getSwiftUIModifiers } from '../../native';
13
13
  import { Spinner } from '../spinner';
14
14
 
15
15
  const buttonVariants = tv({
@@ -233,13 +233,15 @@ export interface ButtonProps
233
233
  /**
234
234
  * Height given to the platform button, matching the styled scale above.
235
235
  *
236
- * It goes on the button rather than on the host, and that distinction is the
237
- * whole fix for the jump. A host sized to a number with an unsized control
238
- * inside it hands the platform a box the control never agreed to: SwiftUI and
239
- * Compose both lay out against their own intrinsic size and only settle into
240
- * the box when something forces a second pass — which for a button is the
241
- * first press. Given a definite height of its own, the button has nothing left
242
- * to recompute, and the host follows it with `matchContents`.
236
+ * It goes on the host as well as on the button, and the host is the half that
237
+ * stops the jump. `matchContents` does not mean "size the host once" — it
238
+ * means the host keeps its React Native box equal to whatever SwiftUI most
239
+ * recently laid out, and a button laying itself out again is what a press is.
240
+ * So the box moved under the press, and everything below it moved with it.
241
+ *
242
+ * A height we already know is not a question worth asking the platform, so it
243
+ * is not asked: the host states it, and `matchContents` is narrowed to the
244
+ * axis that is genuinely unknown. See the host in the native branch below.
243
245
  */
244
246
  const NATIVE_HEIGHT: Record<NonNullable<ButtonVariantProps['size']>, number> = {
245
247
  sm: 36,
@@ -402,8 +404,31 @@ export const Button = forwardRef<View, ButtonProps>(
402
404
  */
403
405
  const nativeContent = prominent ? '#ffffff' : contentColor;
404
406
 
407
+ /*
408
+ * The box React Native reserves for the platform button, stated rather
409
+ * than measured.
410
+ *
411
+ * An icon button is a square we chose, so both axes are known and the
412
+ * host is asked to match nothing. A labelled button's width is its
413
+ * text's and known only to the platform, so the height is stated and
414
+ * only the width is matched.
415
+ *
416
+ * Leaving both to `matchContents` is what made the button move on its
417
+ * first press: the host tracks SwiftUI's latest layout, and a pressed
418
+ * button lays out again.
419
+ */
420
+ const hostFrame =
421
+ resolvedSize === 'icon'
422
+ ? { width: NATIVE_ICON_FRAME, height: NATIVE_ICON_FRAME }
423
+ : { height: NATIVE_HEIGHT[resolvedSize ?? 'md'] };
424
+
405
425
  return (
406
- <Host matchContents ignoreSafeArea="keyboard">
426
+ <NativeHost
427
+ host={Host}
428
+ matchContents={resolvedSize === 'icon' ? false : { horizontal: true }}
429
+ ignoreSafeArea="keyboard"
430
+ style={hostFrame}
431
+ >
407
432
  <NativeButton
408
433
  label={isStringLabel ? children : undefined}
409
434
  // The platform only reads a symbol beside a label it drew itself,
@@ -421,11 +446,7 @@ export const Button = forwardRef<View, ButtonProps>(
421
446
  * background around. The frame only has to be large enough to hold
422
447
  * the result.
423
448
  */
424
- style={
425
- resolvedSize === 'icon'
426
- ? { width: NATIVE_ICON_FRAME, height: NATIVE_ICON_FRAME }
427
- : { height: NATIVE_HEIGHT[resolvedSize ?? 'md'] }
428
- }
449
+ style={hostFrame}
429
450
  modifiers={nativeModifiers.length ? nativeModifiers : undefined}
430
451
  onPress={props.onPress}
431
452
  >
@@ -461,7 +482,7 @@ export const Button = forwardRef<View, ButtonProps>(
461
482
  </RNHostView>
462
483
  )}
463
484
  </NativeButton>
464
- </Host>
485
+ </NativeHost>
465
486
  );
466
487
  }
467
488
 
@@ -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
 
@@ -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
  }