panelui-native 0.83.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/circular-text/circular-text-geometry.js +63 -0
- package/lib/module/components/circular-text/circular-text-geometry.js.map +1 -0
- package/lib/module/components/circular-text/index.js +146 -0
- package/lib/module/components/circular-text/index.js.map +1 -0
- 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/marquee/index.js +12 -5
- package/lib/module/components/marquee/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +15 -417
- package/lib/module/components/panelside/index.js.map +1 -1
- package/lib/module/components/popover/index.js +120 -7
- 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/icons/index.js +24 -4
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js +35 -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/circular-text/circular-text-geometry.d.ts +39 -0
- package/lib/typescript/src/components/circular-text/circular-text-geometry.d.ts.map +1 -0
- package/lib/typescript/src/components/circular-text/index.d.ts +69 -0
- package/lib/typescript/src/components/circular-text/index.d.ts.map +1 -0
- 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/marquee/index.d.ts.map +1 -1
- package/lib/typescript/src/components/panelside/index.d.ts +10 -202
- package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
- package/lib/typescript/src/components/popover/index.d.ts +25 -1
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
- package/lib/typescript/src/components/switch/index.d.ts +0 -4
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +67 -10
- 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/circular-text/circular-text-geometry.ts +80 -0
- package/src/components/circular-text/index.tsx +217 -0
- package/src/components/flip-card/index.tsx +419 -0
- package/src/components/marquee/index.tsx +12 -5
- package/src/components/panelside/index.tsx +16 -604
- package/src/components/popover/index.tsx +146 -4
- 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/icons/index.tsx +23 -2
- package/src/index.ts +10 -6
- package/src/native/index.ts +89 -9
- package/src/native/native-host.tsx +75 -0
- package/src/primitives/glass.tsx +12 -0
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* and jump into place.
|
|
31
31
|
*/
|
|
32
32
|
import {
|
|
33
|
+
Children,
|
|
33
34
|
cloneElement,
|
|
34
35
|
createContext,
|
|
35
36
|
isValidElement,
|
|
@@ -62,6 +63,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
62
63
|
import { FocusRestorePortal } from '../../primitives/portal';
|
|
63
64
|
import { Scrim } from '../../primitives/scrim';
|
|
64
65
|
import { useBackHandler } from '../../hooks/use-back-handler';
|
|
66
|
+
import { NativeHost, getSwiftUI } from '../../native';
|
|
65
67
|
import { BottomSheet } from '../bottom-sheet';
|
|
66
68
|
import { Text, type TextProps, textChildren } from '../../primitives/text';
|
|
67
69
|
import { cn } from '../../utils/cn';
|
|
@@ -116,6 +118,8 @@ interface PopoverContextValue {
|
|
|
116
118
|
setArrowOffset: (offset: number) => void;
|
|
117
119
|
/** Whether Content is presenting as a bottom sheet — Arrow is null then. */
|
|
118
120
|
presentation: PopoverPresentation;
|
|
121
|
+
/** Whether the platform is drawing the panel. Trigger and Content both read it. */
|
|
122
|
+
native: boolean;
|
|
119
123
|
}
|
|
120
124
|
|
|
121
125
|
export type PopoverPresentation = 'popover' | 'bottom-sheet';
|
|
@@ -176,6 +180,30 @@ export interface PopoverProps {
|
|
|
176
180
|
* sheet.
|
|
177
181
|
*/
|
|
178
182
|
presentation?: PopoverPresentation;
|
|
183
|
+
/**
|
|
184
|
+
* Present the platform's own popover instead of this one. Requires the
|
|
185
|
+
* optional `@expo/ui`.
|
|
186
|
+
*
|
|
187
|
+
* **iOS only.** SwiftUI has a popover that anchors to a view and keeps its
|
|
188
|
+
* anchored shape on a phone rather than becoming a sheet; Compose's nearest
|
|
189
|
+
* relative is a dropdown menu, which is a different control with different
|
|
190
|
+
* rules. Android and web keep the styled panel, as does an iOS device
|
|
191
|
+
* without `@expo/ui` installed.
|
|
192
|
+
*
|
|
193
|
+
* **The platform draws the container, so theme tokens do not reach it.** The
|
|
194
|
+
* panel's surface, its corner radius, its shadow and its arrow are the
|
|
195
|
+
* system's; `className` on `Popover.Content` styles what is *inside* it.
|
|
196
|
+
* `align`, `offset`, `alignOffset`, `scrim` and `blur` have no native
|
|
197
|
+
* equivalent and are ignored; `placement` becomes the edge the arrow is
|
|
198
|
+
* asked for.
|
|
199
|
+
*
|
|
200
|
+
* **Give the content a `width`.** The platform sizes its popover to what is
|
|
201
|
+
* hosted in it, and a React Native subtree with no width of its own has
|
|
202
|
+
* nothing to report — the same rule that governs every hosted view.
|
|
203
|
+
* `Popover.Content` defaults to a sensible one under `native`, but a panel
|
|
204
|
+
* whose rows need more room should say so.
|
|
205
|
+
*/
|
|
206
|
+
native?: boolean;
|
|
179
207
|
}
|
|
180
208
|
|
|
181
209
|
function PopoverRoot({
|
|
@@ -184,6 +212,7 @@ function PopoverRoot({
|
|
|
184
212
|
onOpenChange,
|
|
185
213
|
defaultOpen = false,
|
|
186
214
|
presentation = 'popover',
|
|
215
|
+
native = false,
|
|
187
216
|
}: PopoverProps) {
|
|
188
217
|
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
189
218
|
const [trigger, setTrigger] = useState<TriggerRect | null>(null);
|
|
@@ -201,6 +230,16 @@ function PopoverRoot({
|
|
|
201
230
|
[isControlled, onOpenChange]
|
|
202
231
|
);
|
|
203
232
|
|
|
233
|
+
/*
|
|
234
|
+
* The platform's popover, where it is reachable and the caller asked for it.
|
|
235
|
+
*
|
|
236
|
+
* A sheet presentation is left alone: `native` names which popover to draw,
|
|
237
|
+
* and asking for both a sheet and a popover is a contradiction rather than a
|
|
238
|
+
* combination.
|
|
239
|
+
*/
|
|
240
|
+
const swiftUI = native && presentation === 'popover' ? getSwiftUI() : null;
|
|
241
|
+
const nativeActive = swiftUI !== null;
|
|
242
|
+
|
|
204
243
|
const context = useMemo(
|
|
205
244
|
() => ({
|
|
206
245
|
open: resolvedOpen,
|
|
@@ -212,11 +251,95 @@ function PopoverRoot({
|
|
|
212
251
|
arrowOffset,
|
|
213
252
|
setArrowOffset,
|
|
214
253
|
presentation,
|
|
254
|
+
native: nativeActive,
|
|
215
255
|
}),
|
|
216
|
-
[resolvedOpen, setOpen, trigger, placement, arrowOffset, presentation]
|
|
256
|
+
[resolvedOpen, setOpen, trigger, placement, arrowOffset, presentation, nativeActive]
|
|
217
257
|
);
|
|
218
258
|
|
|
219
|
-
return
|
|
259
|
+
return (
|
|
260
|
+
<PopoverContext.Provider value={context}>
|
|
261
|
+
{swiftUI ? <NativePopover swiftUI={swiftUI}>{children}</NativePopover> : children}
|
|
262
|
+
</PopoverContext.Provider>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Default width for a hosted panel, in points. Room for a short menu row. */
|
|
267
|
+
const NATIVE_PANEL_WIDTH = 240;
|
|
268
|
+
|
|
269
|
+
/** `placement` in the platform's vocabulary. */
|
|
270
|
+
const ARROW_EDGE = {
|
|
271
|
+
top: 'top',
|
|
272
|
+
bottom: 'bottom',
|
|
273
|
+
left: 'leading',
|
|
274
|
+
right: 'trailing',
|
|
275
|
+
} as const;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The platform's popover, with our trigger and our content hosted inside it.
|
|
279
|
+
*
|
|
280
|
+
* SwiftUI attaches a popover to a view, so the two halves this component keeps
|
|
281
|
+
* as siblings have to become parent and child: the trigger is what the panel
|
|
282
|
+
* points at, and the platform will not anchor to something it cannot see. So
|
|
283
|
+
* the children are read here and placed into the two slots the platform
|
|
284
|
+
* expects, rather than the caller having to write a different tree under
|
|
285
|
+
* `native` than without it.
|
|
286
|
+
*
|
|
287
|
+
* Both halves are React Native, so both are wrapped in the host view that lets
|
|
288
|
+
* the platform measure them. The trigger is given no press handling of its
|
|
289
|
+
* own — `Popover.Trigger` already toggles the state this reads, and the
|
|
290
|
+
* platform presents from that.
|
|
291
|
+
*/
|
|
292
|
+
function NativePopover({
|
|
293
|
+
swiftUI,
|
|
294
|
+
children,
|
|
295
|
+
}: {
|
|
296
|
+
swiftUI: NonNullable<ReturnType<typeof getSwiftUI>>;
|
|
297
|
+
children: ReactNode;
|
|
298
|
+
}) {
|
|
299
|
+
const { open, setOpen } = usePopover('Popover');
|
|
300
|
+
const { Host, RNHostView, Popover: PlatformPopover } = swiftUI;
|
|
301
|
+
|
|
302
|
+
let trigger: ReactNode = null;
|
|
303
|
+
let content: ReactElement<PopoverContentProps> | null = null;
|
|
304
|
+
|
|
305
|
+
for (const child of Children.toArray(children)) {
|
|
306
|
+
if (!isValidElement(child)) continue;
|
|
307
|
+
if (child.type === PopoverTrigger) trigger = child;
|
|
308
|
+
else if (child.type === PopoverContent) {
|
|
309
|
+
content = child as ReactElement<PopoverContentProps>;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const contentProps = content?.props;
|
|
314
|
+
const width =
|
|
315
|
+
typeof contentProps?.width === 'number' ? contentProps.width : NATIVE_PANEL_WIDTH;
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<NativeHost host={Host} matchContents ignoreSafeArea="keyboard">
|
|
319
|
+
<PlatformPopover
|
|
320
|
+
isPresented={open}
|
|
321
|
+
onIsPresentedChange={setOpen}
|
|
322
|
+
arrowEdge={ARROW_EDGE[contentProps?.placement ?? 'bottom']}
|
|
323
|
+
>
|
|
324
|
+
<PlatformPopover.Trigger>
|
|
325
|
+
<RNHostView matchContents>{trigger}</RNHostView>
|
|
326
|
+
</PlatformPopover.Trigger>
|
|
327
|
+
<PlatformPopover.Content>
|
|
328
|
+
<RNHostView matchContents>
|
|
329
|
+
{/*
|
|
330
|
+
An explicit width, not a class. Inside the host there is no
|
|
331
|
+
parent for a percentage or a flex basis to resolve against, so a
|
|
332
|
+
panel that does not state its width reports none and the platform
|
|
333
|
+
sizes its popover to nothing.
|
|
334
|
+
*/}
|
|
335
|
+
<View style={{ width }} className={cn('gap-1 p-2', contentProps?.className)}>
|
|
336
|
+
{textChildren(contentProps?.children)}
|
|
337
|
+
</View>
|
|
338
|
+
</RNHostView>
|
|
339
|
+
</PlatformPopover.Content>
|
|
340
|
+
</PlatformPopover>
|
|
341
|
+
</NativeHost>
|
|
342
|
+
);
|
|
220
343
|
}
|
|
221
344
|
|
|
222
345
|
export interface PopoverTriggerProps {
|
|
@@ -231,7 +354,7 @@ export interface PopoverTriggerProps {
|
|
|
231
354
|
* and only a wrapper we own is guaranteed to be measurable.
|
|
232
355
|
*/
|
|
233
356
|
function PopoverTrigger({ children }: PopoverTriggerProps) {
|
|
234
|
-
const { open, setOpen, setTrigger } = usePopover('Popover.Trigger');
|
|
357
|
+
const { open, setOpen, setTrigger, native } = usePopover('Popover.Trigger');
|
|
235
358
|
const ref = useRef<View>(null);
|
|
236
359
|
|
|
237
360
|
const measureThenToggle = (...args: unknown[]) => {
|
|
@@ -242,6 +365,17 @@ function PopoverTrigger({ children }: PopoverTriggerProps) {
|
|
|
242
365
|
return;
|
|
243
366
|
}
|
|
244
367
|
|
|
368
|
+
/*
|
|
369
|
+
* Nothing to measure when the platform is drawing the panel: it anchors to
|
|
370
|
+
* this trigger itself, and asking a view hosted inside the native tree for
|
|
371
|
+
* its window coordinates is a callback that may never come back — which
|
|
372
|
+
* would leave the press doing nothing at all.
|
|
373
|
+
*/
|
|
374
|
+
if (native) {
|
|
375
|
+
setOpen(true);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
245
379
|
// Measured on every open rather than on layout: the trigger may have
|
|
246
380
|
// scrolled since it was laid out, and a stale rect anchors the panel to
|
|
247
381
|
// where the trigger used to be.
|
|
@@ -363,7 +497,8 @@ function PopoverContent({
|
|
|
363
497
|
...props
|
|
364
498
|
}: PopoverContentProps) {
|
|
365
499
|
const context = usePopover('Popover.Content');
|
|
366
|
-
const { open, setOpen, trigger, setPlacement, setArrowOffset, presentation } =
|
|
500
|
+
const { open, setOpen, trigger, setPlacement, setArrowOffset, presentation, native } =
|
|
501
|
+
context;
|
|
367
502
|
|
|
368
503
|
// The anchored panel owns the Android back button while it is up. The sheet
|
|
369
504
|
// presentation is left alone — BottomSheet installs its own handler.
|
|
@@ -509,6 +644,13 @@ function PopoverContent({
|
|
|
509
644
|
* the surface it is merged into the sheet's own padding classes and replaces
|
|
510
645
|
* them, so a panel asking for `p-3` silently strips the sheet's `px-5 pt-2`.
|
|
511
646
|
*/
|
|
647
|
+
/*
|
|
648
|
+
* Already drawn. Under `native` the root reads this element's props and
|
|
649
|
+
* hosts its children inside the platform's own popover, so rendering the
|
|
650
|
+
* styled panel here as well would put a second one on the screen.
|
|
651
|
+
*/
|
|
652
|
+
if (native) return null;
|
|
653
|
+
|
|
512
654
|
if (presentation === 'bottom-sheet') {
|
|
513
655
|
return (
|
|
514
656
|
<BottomSheet open={open} onOpenChange={setOpen}>
|
|
@@ -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
|
}
|
|
@@ -8,7 +8,7 @@ import Animated, {
|
|
|
8
8
|
} from 'react-native-reanimated';
|
|
9
9
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
10
10
|
import { useDirectionSign } from '../../hooks/use-direction';
|
|
11
|
-
import { getNativeUI } from '../../native';
|
|
11
|
+
import { NativeHost, getNativeUI } from '../../native';
|
|
12
12
|
import { selectionTick } from '../../utils/haptics';
|
|
13
13
|
import { useFieldLabelledBy } from '../field';
|
|
14
14
|
|
|
@@ -79,6 +79,15 @@ export interface SwitchProps
|
|
|
79
79
|
* Animated switch. Thumb position and active-track opacity are driven on the
|
|
80
80
|
* UI thread; toggling never re-renders beyond the value change itself.
|
|
81
81
|
*/
|
|
82
|
+
/**
|
|
83
|
+
* The box the platform toggle is given, in points.
|
|
84
|
+
*
|
|
85
|
+
* Taller than either platform's switch — 31 on iOS, 32 on Android — so the
|
|
86
|
+
* control is never clipped by the box it is centred in, and short enough that
|
|
87
|
+
* a row built around it is still a row.
|
|
88
|
+
*/
|
|
89
|
+
const NATIVE_TOGGLE_HEIGHT = 32;
|
|
90
|
+
|
|
82
91
|
export const Switch = forwardRef<View, SwitchProps>(
|
|
83
92
|
(
|
|
84
93
|
{
|
|
@@ -124,18 +133,30 @@ export const Switch = forwardRef<View, SwitchProps>(
|
|
|
124
133
|
if (nativeUI) {
|
|
125
134
|
const { Host, Switch: NativeSwitch } = nativeUI;
|
|
126
135
|
return (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
136
|
+
/*
|
|
137
|
+
* The height is stated; only the width is matched.
|
|
138
|
+
*
|
|
139
|
+
* `matchContents` hands an axis to the platform for good — the host
|
|
140
|
+
* writes the measured size back into the layout every time the
|
|
141
|
+
* platform's geometry changes, not once on mount — and the vertical
|
|
142
|
+
* axis is the one that moves everything below it when it does. A
|
|
143
|
+
* toggle's height is the one number here that does not vary, so it is
|
|
144
|
+
* given rather than asked for; its width is the platform's and stays
|
|
145
|
+
* the platform's.
|
|
146
|
+
*/
|
|
147
|
+
<NativeHost
|
|
148
|
+
host={Host}
|
|
149
|
+
matchContents={{ horizontal: true }}
|
|
150
|
+
ignoreSafeArea="keyboard"
|
|
151
|
+
style={{ height: NATIVE_TOGGLE_HEIGHT }}
|
|
152
|
+
>
|
|
132
153
|
<NativeSwitch
|
|
133
154
|
value={value}
|
|
134
155
|
onValueChange={(next: boolean) => onValueChange?.(next)}
|
|
135
156
|
label={label ?? accessibilityLabel}
|
|
136
157
|
disabled={disabled}
|
|
137
158
|
/>
|
|
138
|
-
</
|
|
159
|
+
</NativeHost>
|
|
139
160
|
);
|
|
140
161
|
}
|
|
141
162
|
|
package/src/icons/index.tsx
CHANGED
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
* outline could only ignore. All four are drawn here rather than mapped.
|
|
51
51
|
*/
|
|
52
52
|
import { createContext, useContext, type ReactNode } from 'react';
|
|
53
|
+
import { View } from 'react-native';
|
|
53
54
|
import { HugeiconsIcon, type IconSvgElement } from '@hugeicons/react-native';
|
|
54
55
|
import Svg, { G, Path, type SvgProps } from 'react-native-svg';
|
|
55
56
|
import { useDirection } from '../hooks/use-direction';
|
|
@@ -208,17 +209,37 @@ function icon(glyph: IconSvgElement, defaults: IconDefaults) {
|
|
|
208
209
|
}: ToggleIconProps) {
|
|
209
210
|
const resolved = useResolvedColor(color, defaults.color);
|
|
210
211
|
const rtl = useDirection() === 'rtl';
|
|
211
|
-
|
|
212
|
+
|
|
213
|
+
const drawing = (
|
|
212
214
|
<HugeiconsIcon
|
|
213
215
|
icon={glyph}
|
|
214
216
|
size={size}
|
|
215
217
|
color={resolved}
|
|
216
218
|
strokeWidth={strokeWidth ?? defaults.strokeWidth ?? STROKE}
|
|
217
219
|
fill={defaults.fillable && filled ? resolved : 'none'}
|
|
218
|
-
style={defaults.flip ?
|
|
220
|
+
style={defaults.flip ? undefined : style}
|
|
219
221
|
{...props}
|
|
220
222
|
/>
|
|
221
223
|
);
|
|
224
|
+
|
|
225
|
+
if (!defaults.flip) return drawing;
|
|
226
|
+
|
|
227
|
+
/*
|
|
228
|
+
* The mirror goes on a view around the glyph, not on the glyph.
|
|
229
|
+
*
|
|
230
|
+
* The drawing component takes a `style` prop and drops it: it destructures
|
|
231
|
+
* `style` out of its props and never puts it back, and the interop layer
|
|
232
|
+
* that would otherwise have carried it is a package this library does not
|
|
233
|
+
* use. A transform handed to it is silently discarded, which is a chevron
|
|
234
|
+
* that keeps pointing right in a right-to-left row — and a failure with
|
|
235
|
+
* nothing to see, since every other prop on the same element does arrive.
|
|
236
|
+
*
|
|
237
|
+
* A plain view cannot lose it. It also puts the caller's own `style` back
|
|
238
|
+
* on the outside, where it is applied rather than thrown away.
|
|
239
|
+
*/
|
|
240
|
+
return (
|
|
241
|
+
<View style={[{ transform: [{ scaleX: rtl ? -1 : 1 }] }, style]}>{drawing}</View>
|
|
242
|
+
);
|
|
222
243
|
}
|
|
223
244
|
|
|
224
245
|
return Icon;
|
package/src/index.ts
CHANGED
|
@@ -222,6 +222,15 @@ export {
|
|
|
222
222
|
type FlowRect,
|
|
223
223
|
type FlowPoint,
|
|
224
224
|
} from './components/flow';
|
|
225
|
+
export {
|
|
226
|
+
FlipCard,
|
|
227
|
+
useFlipCard,
|
|
228
|
+
type FlipCardProps,
|
|
229
|
+
type FlipCardFaceProps,
|
|
230
|
+
type FlipCardDirection,
|
|
231
|
+
type FlipCardRotation,
|
|
232
|
+
type FlipCardTrigger,
|
|
233
|
+
} from './components/flip-card';
|
|
225
234
|
export {
|
|
226
235
|
FunnelChart,
|
|
227
236
|
useFunnelChart,
|
|
@@ -421,6 +430,7 @@ export {
|
|
|
421
430
|
type ChipVariant,
|
|
422
431
|
type ChipSize,
|
|
423
432
|
} from './components/chip';
|
|
433
|
+
export { CircularText, type CircularTextProps } from './components/circular-text';
|
|
424
434
|
export {
|
|
425
435
|
Collapsible,
|
|
426
436
|
type CollapsibleProps,
|
|
@@ -557,12 +567,6 @@ export {
|
|
|
557
567
|
type PanelsidePageProps,
|
|
558
568
|
type PanelsideTriggerProps,
|
|
559
569
|
type PanelsideSearchTriggerProps,
|
|
560
|
-
type PanelsideSearchSheetProps,
|
|
561
|
-
type PanelsideSearchTabsProps,
|
|
562
|
-
type PanelsideSearchTabProps,
|
|
563
|
-
type PanelsideSearchResultsProps,
|
|
564
|
-
type PanelsideSearchResultProps,
|
|
565
|
-
type PanelsideSearchFieldProps,
|
|
566
570
|
type PanelsideMode,
|
|
567
571
|
type PanelsideSwipeFrom,
|
|
568
572
|
type PanelsideItemSize,
|
package/src/native/index.ts
CHANGED
|
@@ -18,25 +18,40 @@
|
|
|
18
18
|
* **Theme tokens do not apply in native mode.** The platform draws the control
|
|
19
19
|
* with its own colours, metrics and typography — that is the entire point, and
|
|
20
20
|
* it means `className` and the variant props are ignored on those components.
|
|
21
|
+
*
|
|
22
|
+
* The one exception is which appearance it draws: `colorScheme` is the single
|
|
23
|
+
* theme signal the toolkit accepts, and `NativeHost` is what passes it. Mount
|
|
24
|
+
* every host through that rather than reaching for `Host` directly, or the
|
|
25
|
+
* control resolves its own appearance from the system and stops tracking the
|
|
26
|
+
* app's theme.
|
|
21
27
|
*/
|
|
22
28
|
import { Platform } from 'react-native';
|
|
23
29
|
import type { ComponentType, ReactNode } from 'react';
|
|
24
30
|
|
|
31
|
+
export { NativeHost, type NativeHostProps } from './native-host';
|
|
32
|
+
|
|
25
33
|
interface NativeUIModule {
|
|
26
34
|
Host: ComponentType<{
|
|
27
35
|
children?: ReactNode;
|
|
28
36
|
/**
|
|
29
|
-
*
|
|
37
|
+
* Which axes the platform is allowed to size.
|
|
30
38
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* the
|
|
34
|
-
*
|
|
35
|
-
*
|
|
39
|
+
* **It is not a one-off measurement.** An axis given to `matchContents` is
|
|
40
|
+
* given for good: the host writes the platform's measured size straight
|
|
41
|
+
* into the layout every time the platform's geometry changes, and dirties
|
|
42
|
+
* the layout when it does. So a control that lays itself out again under a
|
|
43
|
+
* press drags its box with it, and everything below it moves — which is
|
|
44
|
+
* the defect this spent three attempts on, twice reasoning about the first
|
|
45
|
+
* measurement when the problem was every one after it.
|
|
36
46
|
*
|
|
37
|
-
* The
|
|
38
|
-
*
|
|
39
|
-
*
|
|
47
|
+
* The rule that comes out of that: **never hand over an axis whose size
|
|
48
|
+
* you already know.** State it in `style` instead and match only what is
|
|
49
|
+
* genuinely the platform's — a labelled button's width, a toggle's width.
|
|
50
|
+
* An axis left out is never written to, so an explicit size on it is safe.
|
|
51
|
+
*
|
|
52
|
+
* Where nothing can be stated the axis has to stay matched. A picker is
|
|
53
|
+
* the honest example: a menu is a compact button and a wheel is a rotor,
|
|
54
|
+
* and only the platform knows which it drew.
|
|
40
55
|
*/
|
|
41
56
|
matchContents?: boolean | { vertical?: boolean; horizontal?: boolean };
|
|
42
57
|
/**
|
|
@@ -56,6 +71,14 @@ interface NativeUIModule {
|
|
|
56
71
|
* platform's business and stay its business.
|
|
57
72
|
*/
|
|
58
73
|
ignoreSafeArea?: 'all' | 'container' | 'keyboard';
|
|
74
|
+
/**
|
|
75
|
+
* The appearance the platform draws the hosted control in.
|
|
76
|
+
*
|
|
77
|
+
* Passed by `NativeHost` from the app's own theme, because the host would
|
|
78
|
+
* otherwise resolve it from the system — which is a different question,
|
|
79
|
+
* and one whose answer does not change when the theme does.
|
|
80
|
+
*/
|
|
81
|
+
colorScheme?: 'light' | 'dark';
|
|
59
82
|
style?: unknown;
|
|
60
83
|
[key: string]: unknown;
|
|
61
84
|
}>;
|
|
@@ -161,6 +184,63 @@ interface SwiftUIModifiers {
|
|
|
161
184
|
presentationBackground: (color: string) => unknown;
|
|
162
185
|
}
|
|
163
186
|
|
|
187
|
+
/**
|
|
188
|
+
* The SwiftUI-only components, for the ones the universal set does not carry.
|
|
189
|
+
*
|
|
190
|
+
* `@expo/ui` exports a portable component for everything both toolkits agree
|
|
191
|
+
* on, and that is what `getNativeUI` reaches. A popover is not on that list:
|
|
192
|
+
* SwiftUI has one that anchors to a view and adapts itself on a compact
|
|
193
|
+
* screen, and Compose's nearest relative is a dropdown menu, which is a
|
|
194
|
+
* different control with different rules. Rather than pretend the two are one
|
|
195
|
+
* thing, the iOS one is reached here and Android keeps the styled panel.
|
|
196
|
+
*/
|
|
197
|
+
interface SwiftUIComponents {
|
|
198
|
+
Host: ComponentType<{
|
|
199
|
+
children?: ReactNode;
|
|
200
|
+
matchContents?: boolean | { vertical?: boolean; horizontal?: boolean };
|
|
201
|
+
ignoreSafeArea?: 'all' | 'container' | 'keyboard';
|
|
202
|
+
/** As on the portable host above — see `NativeHost`. */
|
|
203
|
+
colorScheme?: 'light' | 'dark';
|
|
204
|
+
style?: unknown;
|
|
205
|
+
}>;
|
|
206
|
+
RNHostView: ComponentType<{ children?: ReactNode; matchContents?: boolean }>;
|
|
207
|
+
Popover: ComponentType<{
|
|
208
|
+
children?: ReactNode;
|
|
209
|
+
isPresented?: boolean;
|
|
210
|
+
onIsPresentedChange?: (isPresented: boolean) => void;
|
|
211
|
+
attachmentAnchor?: 'leading' | 'trailing' | 'center' | 'top' | 'bottom';
|
|
212
|
+
arrowEdge?: 'leading' | 'trailing' | 'top' | 'bottom' | 'none';
|
|
213
|
+
}> & {
|
|
214
|
+
Trigger: ComponentType<{ children?: ReactNode }>;
|
|
215
|
+
Content: ComponentType<{ children?: ReactNode }>;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let swiftUIResolved = false;
|
|
220
|
+
let swiftUI: SwiftUIComponents | null = null;
|
|
221
|
+
|
|
222
|
+
export function getSwiftUI(): SwiftUIComponents | null {
|
|
223
|
+
if (swiftUIResolved) return swiftUI;
|
|
224
|
+
swiftUIResolved = true;
|
|
225
|
+
|
|
226
|
+
if (Platform.OS !== 'ios') return null;
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
230
|
+
const module = require('@expo/ui/swift-ui') as Partial<SwiftUIComponents>;
|
|
231
|
+
// A version without the popover is not an error; it is a fallback to the
|
|
232
|
+
// styled panel, which is what every missing native path here does.
|
|
233
|
+
swiftUI =
|
|
234
|
+
module.Host && module.RNHostView && module.Popover
|
|
235
|
+
? (module as SwiftUIComponents)
|
|
236
|
+
: null;
|
|
237
|
+
} catch {
|
|
238
|
+
swiftUI = null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return swiftUI;
|
|
242
|
+
}
|
|
243
|
+
|
|
164
244
|
let modifiersResolved = false;
|
|
165
245
|
let modifiers: SwiftUIModifiers | null = null;
|
|
166
246
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host every native control is mounted in, told which appearance to draw.
|
|
3
|
+
*
|
|
4
|
+
* A hosting controller resolves its colour scheme from the trait environment
|
|
5
|
+
* it is placed in, which is the system appearance — not the theme the app is
|
|
6
|
+
* running. Those are the same thing only by coincidence: an app in a dark
|
|
7
|
+
* theme on a phone set to light gets a light platform control beside dark
|
|
8
|
+
* content, and a theme changed at runtime leaves the control where it was,
|
|
9
|
+
* because nothing in the trait environment moved.
|
|
10
|
+
*
|
|
11
|
+
* So the appearance is passed rather than inferred. `colorScheme` is the one
|
|
12
|
+
* theme signal the platform toolkit accepts, and this is the single place it
|
|
13
|
+
* is given.
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* const { Host, Switch: NativeSwitch } = nativeUI;
|
|
17
|
+
* <NativeHost host={Host} matchContents ignoreSafeArea="keyboard">
|
|
18
|
+
* <NativeSwitch value={on} onValueChange={setOn} />
|
|
19
|
+
* </NativeHost>
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* ## Why the host arrives as a prop
|
|
23
|
+
*
|
|
24
|
+
* There are two of them. The universal `Host` comes from `getNativeUI()` and
|
|
25
|
+
* the SwiftUI-only one from `getSwiftUI()`, and a caller has already resolved
|
|
26
|
+
* whichever it needs before it renders. Taking the component rather than
|
|
27
|
+
* resolving it again keeps this file free of the module bridge, which is what
|
|
28
|
+
* lets the bridge re-export it without the two importing each other.
|
|
29
|
+
*
|
|
30
|
+
* ## Why this is a component rather than a hook at each call site
|
|
31
|
+
*
|
|
32
|
+
* `useThemeMode` subscribes to theme changes, and a hook cannot be called
|
|
33
|
+
* conditionally — so reading it inside `Button` would put a subscription on
|
|
34
|
+
* every button in a list for a branch almost none of them take. Here the
|
|
35
|
+
* subscription exists only where a native host is actually mounted.
|
|
36
|
+
*/
|
|
37
|
+
import type { ComponentType, ReactNode } from 'react';
|
|
38
|
+
import { useThemeMode } from '../theme/use-theme';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What this renders the host with. A type alias rather than an interface, and
|
|
42
|
+
* that is load-bearing: only an alias gets an implicit index signature, which
|
|
43
|
+
* is what makes it assignable to the bridge's own `Host` prop type.
|
|
44
|
+
*/
|
|
45
|
+
type NativeHostRenderProps = {
|
|
46
|
+
children?: ReactNode;
|
|
47
|
+
colorScheme?: 'light' | 'dark';
|
|
48
|
+
matchContents?: boolean | { vertical?: boolean; horizontal?: boolean };
|
|
49
|
+
ignoreSafeArea?: 'all' | 'container' | 'keyboard';
|
|
50
|
+
style?: unknown;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export interface NativeHostProps {
|
|
54
|
+
/**
|
|
55
|
+
* The host component to render — `Host` from `getNativeUI()` for a portable
|
|
56
|
+
* control, or from `getSwiftUI()` for an iOS-only one.
|
|
57
|
+
*/
|
|
58
|
+
host: ComponentType<NativeHostRenderProps>;
|
|
59
|
+
children?: ReactNode;
|
|
60
|
+
/** Whether the host resizes itself to the platform content. */
|
|
61
|
+
matchContents?: boolean | { vertical?: boolean; horizontal?: boolean };
|
|
62
|
+
/** Which safe areas the host lets the platform inset its content for. */
|
|
63
|
+
ignoreSafeArea?: 'all' | 'container' | 'keyboard';
|
|
64
|
+
style?: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function NativeHost({ host: Host, children, ...props }: NativeHostProps) {
|
|
68
|
+
const { mode } = useThemeMode();
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Host colorScheme={mode} {...props}>
|
|
72
|
+
{children}
|
|
73
|
+
</Host>
|
|
74
|
+
);
|
|
75
|
+
}
|