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.
- package/README.md +17 -8
- package/lib/module/components/bottom-sheet/index.js +3 -2
- 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/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/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/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/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/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/package.json +11 -1
- package/src/components/bottom-sheet/index.tsx +8 -3
- 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/select/index.tsx +3 -3
- package/src/components/slider/index.tsx +28 -6
- package/src/components/switch/index.tsx +28 -7
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "panelui-native",
|
|
3
|
-
"version": "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
|
-
<
|
|
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
|
-
</
|
|
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
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* the box
|
|
241
|
-
*
|
|
242
|
-
*
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
</
|
|
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
|
-
</
|
|
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
|
}
|