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.
Files changed (74) 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/circular-text/circular-text-geometry.js +63 -0
  7. package/lib/module/components/circular-text/circular-text-geometry.js.map +1 -0
  8. package/lib/module/components/circular-text/index.js +146 -0
  9. package/lib/module/components/circular-text/index.js.map +1 -0
  10. package/lib/module/components/flip-card/index.js +338 -0
  11. package/lib/module/components/flip-card/index.js.map +1 -0
  12. package/lib/module/components/marquee/index.js +12 -5
  13. package/lib/module/components/marquee/index.js.map +1 -1
  14. package/lib/module/components/panelside/index.js +15 -417
  15. package/lib/module/components/panelside/index.js.map +1 -1
  16. package/lib/module/components/popover/index.js +120 -7
  17. package/lib/module/components/popover/index.js.map +1 -1
  18. package/lib/module/components/select/index.js +3 -2
  19. package/lib/module/components/select/index.js.map +1 -1
  20. package/lib/module/components/slider/index.js +16 -5
  21. package/lib/module/components/slider/index.js.map +1 -1
  22. package/lib/module/components/switch/index.js +28 -7
  23. package/lib/module/components/switch/index.js.map +1 -1
  24. package/lib/module/icons/index.js +24 -4
  25. package/lib/module/icons/index.js.map +1 -1
  26. package/lib/module/index.js +2 -0
  27. package/lib/module/index.js.map +1 -1
  28. package/lib/module/native/index.js +35 -0
  29. package/lib/module/native/index.js.map +1 -1
  30. package/lib/module/native/native-host.js +62 -0
  31. package/lib/module/native/native-host.js.map +1 -0
  32. package/lib/module/primitives/glass.js +14 -0
  33. package/lib/module/primitives/glass.js.map +1 -1
  34. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  35. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  36. package/lib/typescript/src/components/circular-text/circular-text-geometry.d.ts +39 -0
  37. package/lib/typescript/src/components/circular-text/circular-text-geometry.d.ts.map +1 -0
  38. package/lib/typescript/src/components/circular-text/index.d.ts +69 -0
  39. package/lib/typescript/src/components/circular-text/index.d.ts.map +1 -0
  40. package/lib/typescript/src/components/flip-card/index.d.ts +116 -0
  41. package/lib/typescript/src/components/flip-card/index.d.ts.map +1 -0
  42. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/panelside/index.d.ts +10 -202
  44. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/popover/index.d.ts +25 -1
  46. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/switch/index.d.ts +0 -4
  49. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  50. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  51. package/lib/typescript/src/index.d.ts +3 -1
  52. package/lib/typescript/src/index.d.ts.map +1 -1
  53. package/lib/typescript/src/native/index.d.ts +67 -10
  54. package/lib/typescript/src/native/index.d.ts.map +1 -1
  55. package/lib/typescript/src/native/native-host.d.ts +71 -0
  56. package/lib/typescript/src/native/native-host.d.ts.map +1 -0
  57. package/lib/typescript/src/primitives/glass.d.ts.map +1 -1
  58. package/package.json +11 -1
  59. package/src/components/bottom-sheet/index.tsx +8 -3
  60. package/src/components/button/index.tsx +36 -15
  61. package/src/components/circular-text/circular-text-geometry.ts +80 -0
  62. package/src/components/circular-text/index.tsx +217 -0
  63. package/src/components/flip-card/index.tsx +419 -0
  64. package/src/components/marquee/index.tsx +12 -5
  65. package/src/components/panelside/index.tsx +16 -604
  66. package/src/components/popover/index.tsx +146 -4
  67. package/src/components/select/index.tsx +3 -3
  68. package/src/components/slider/index.tsx +28 -6
  69. package/src/components/switch/index.tsx +28 -7
  70. package/src/icons/index.tsx +23 -2
  71. package/src/index.ts +10 -6
  72. package/src/native/index.ts +89 -9
  73. package/src/native/native-host.tsx +75 -0
  74. 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 <PopoverContext.Provider value={context}>{children}</PopoverContext.Provider>;
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 } = context;
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
- </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
  }
@@ -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
- // A platform toggle has a definite intrinsic size on both platforms,
128
- // so the host is left to follow it. Pinning the host to a number
129
- // instead is what leaves the control laid out against a box it never
130
- // agreed to, and settling into it a frame later.
131
- <Host matchContents ignoreSafeArea="keyboard">
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
- </Host>
159
+ </NativeHost>
139
160
  );
140
161
  }
141
162
 
@@ -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
- return (
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 ? [{ transform: [{ scaleX: rtl ? -1 : 1 }] }, style] : style}
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,
@@ -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
- * Whether the host resizes itself to the platform content.
37
+ * Which axes the platform is allowed to size.
30
38
  *
31
- * This is on for every control here, and it is the whole answer to the
32
- * jump. Sizing the *host* and leaving the control unsized inside it hands
33
- * the platform a box it never agreed to: it lays out against its own
34
- * intrinsic size, and settles into the box on the first thing that forces
35
- * a second pass — which for a button is the first press.
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 per-axis form is for a control with no intrinsic width, like a
38
- * slider or a picker: the width comes from ordinary layout and only the
39
- * height is reported back.
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
+ }