panelui-native 0.84.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +17 -8
  2. package/lib/module/components/bottom-sheet/index.js +27 -15
  3. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  4. package/lib/module/components/button/index.js +37 -16
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/flip-card/index.js +338 -0
  7. package/lib/module/components/flip-card/index.js.map +1 -0
  8. package/lib/module/components/popover/index.js +3 -2
  9. package/lib/module/components/popover/index.js.map +1 -1
  10. package/lib/module/components/progress-button/index.js +83 -18
  11. package/lib/module/components/progress-button/index.js.map +1 -1
  12. package/lib/module/components/select/index.js +3 -2
  13. package/lib/module/components/select/index.js.map +1 -1
  14. package/lib/module/components/slider/index.js +16 -5
  15. package/lib/module/components/slider/index.js.map +1 -1
  16. package/lib/module/components/switch/index.js +28 -7
  17. package/lib/module/components/switch/index.js.map +1 -1
  18. package/lib/module/components/tabs/index.js +119 -28
  19. package/lib/module/components/tabs/index.js.map +1 -1
  20. package/lib/module/hooks/use-keyboard-avoidance.js +37 -6
  21. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  22. package/lib/module/index.js +1 -0
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/native/index.js +7 -0
  25. package/lib/module/native/index.js.map +1 -1
  26. package/lib/module/native/native-host.js +62 -0
  27. package/lib/module/native/native-host.js.map +1 -0
  28. package/lib/module/primitives/glass.js +14 -0
  29. package/lib/module/primitives/glass.js.map +1 -1
  30. package/lib/module/providers/panel-ui-provider.js +35 -7
  31. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  32. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  34. package/lib/typescript/src/components/flip-card/index.d.ts +116 -0
  35. package/lib/typescript/src/components/flip-card/index.d.ts.map +1 -0
  36. package/lib/typescript/src/components/progress-button/index.d.ts +38 -0
  37. package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -1
  38. package/lib/typescript/src/components/slider/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/switch/index.d.ts +0 -4
  40. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  42. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  43. package/lib/typescript/src/index.d.ts +1 -0
  44. package/lib/typescript/src/index.d.ts.map +1 -1
  45. package/lib/typescript/src/native/index.d.ts +28 -11
  46. package/lib/typescript/src/native/index.d.ts.map +1 -1
  47. package/lib/typescript/src/native/native-host.d.ts +71 -0
  48. package/lib/typescript/src/native/native-host.d.ts.map +1 -0
  49. package/lib/typescript/src/primitives/glass.d.ts.map +1 -1
  50. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  51. package/package.json +11 -1
  52. package/src/components/bottom-sheet/index.tsx +31 -16
  53. package/src/components/button/index.tsx +36 -15
  54. package/src/components/flip-card/index.tsx +419 -0
  55. package/src/components/popover/index.tsx +3 -3
  56. package/src/components/progress-button/index.tsx +58 -18
  57. package/src/components/select/index.tsx +3 -3
  58. package/src/components/slider/index.tsx +28 -6
  59. package/src/components/switch/index.tsx +28 -7
  60. package/src/components/tabs/index.tsx +112 -31
  61. package/src/hooks/use-keyboard-avoidance.ts +39 -5
  62. package/src/index.ts +9 -0
  63. package/src/native/index.ts +35 -10
  64. package/src/native/native-host.tsx +75 -0
  65. package/src/primitives/glass.tsx +12 -0
  66. package/src/providers/panel-ui-provider.tsx +39 -7
@@ -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
 
@@ -225,8 +225,6 @@ interface TabsContextValue {
225
225
  registerLayout: (value: string, layout: TabLayout) => void;
226
226
  layouts: Record<string, TabLayout>;
227
227
  variant: TabsVariant;
228
- scrollable: boolean;
229
- setScrollable: (scrollable: boolean) => void;
230
228
  keepMounted: TabsKeepMounted;
231
229
  /**
232
230
  * Whether the panels are in a strip rather than stacked in place.
@@ -241,6 +239,23 @@ interface TabsContextValue {
241
239
 
242
240
  const TabsContext = createContext<TabsContextValue | null>(null);
243
241
 
242
+ /**
243
+ * Whether the row the trigger is in scrolls, published by that row.
244
+ *
245
+ * It is a `Tabs.List` prop and it decides a trigger's width — intrinsic in a
246
+ * scroller, an equal share in a fixed row — so the two have to agree in the
247
+ * commit they are laid out in. Routed through the root it arrived one commit
248
+ * late: every trigger was measured once at its equal-share position, the
249
+ * indicator snapped to that geometry because it was the first measurement it
250
+ * had, and the second pass moved everything. With enough tabs to need a
251
+ * scroller in the first place, the gap between the two geometries is most of
252
+ * the row.
253
+ *
254
+ * Separate from the root's context so it can be provided by the list, and
255
+ * defaulted so a trigger outside one still resolves.
256
+ */
257
+ const TabsListContext = createContext(false);
258
+
244
259
  function useTabs(component: string): TabsContextValue {
245
260
  const context = useContext(TabsContext);
246
261
  if (!context) {
@@ -371,9 +386,6 @@ function TabsRoot({
371
386
  }: TabsProps) {
372
387
  const [internalValue, setInternalValue] = useState(defaultValue);
373
388
  const [layouts, setLayouts] = useState<Record<string, TabLayout>>({});
374
- // Published by the List rather than the root, because it is the List that
375
- // decides whether it scrolls — but the Triggers below it need to know.
376
- const [scrollable, setScrollable] = useState(false);
377
389
  const isControlled = value !== undefined;
378
390
  const resolvedValue = isControlled ? value : internalValue;
379
391
  const animationDisabled = animation === 'disable-all';
@@ -415,8 +427,6 @@ function TabsRoot({
415
427
  registerLayout,
416
428
  layouts,
417
429
  variant,
418
- scrollable,
419
- setScrollable,
420
430
  keepMounted,
421
431
  pager: paged,
422
432
  animationDisabled,
@@ -427,7 +437,6 @@ function TabsRoot({
427
437
  registerLayout,
428
438
  layouts,
429
439
  variant,
430
- scrollable,
431
440
  keepMounted,
432
441
  paged,
433
442
  animationDisabled,
@@ -481,9 +490,23 @@ function TabsPager({
481
490
 
482
491
  const order = useMemo(() => panels.map((panel) => panel.props.value), [panels]);
483
492
  const count = panels.length;
484
- // An unknown value shows the first panel rather than none of them: a tab set
485
- // with nothing in it is a harder thing to debug than one showing the wrong tab.
486
- const active = Math.max(0, order.indexOf(value));
493
+ /*
494
+ * The index of the value, or the last one that resolved.
495
+ *
496
+ * A value that is not among the panels is a moment rather than a state: a
497
+ * controlled parent part-way through an update, panels rebuilt from a `map`
498
+ * whose keys changed. Falling back to zero for that moment springs the strip
499
+ * to the first panel and back, which is a visible flicker for something that
500
+ * was never wrong. Holding the last index shows the panel that was already
501
+ * there until the new one arrives.
502
+ *
503
+ * Zero remains the answer when nothing has ever resolved — a tab set with
504
+ * nothing in it is a harder thing to debug than one showing the wrong tab.
505
+ */
506
+ const resolved = order.indexOf(value);
507
+ const lastResolved = useRef(0);
508
+ if (resolved >= 0) lastResolved.current = resolved;
509
+ const active = resolved >= 0 ? resolved : Math.min(lastResolved.current, count - 1);
487
510
 
488
511
  const [width, setWidth] = useState(0);
489
512
  const position = useSharedValue(active);
@@ -640,9 +663,22 @@ function TabsPager({
640
663
  [sign, commit, position, start, dragging, widthValue, countValue]
641
664
  );
642
665
 
643
- const strip = useAnimatedStyle(() => ({
644
- transform: [{ translateX: -position.value * widthValue.value * sign }],
645
- }));
666
+ /*
667
+ * The width comes in as the React value, not through `widthValue`.
668
+ *
669
+ * `widthValue` is mirrored from state in an effect, so it is a commit behind
670
+ * — and the commit it is behind by is the one where the strip first appears.
671
+ * For that frame the transform evaluated to `-position × 0`, which is panel
672
+ * zero on screen whichever tab is active: the tab set opened on the first
673
+ * panel and jumped to the right one. Reading `width` here re-creates the
674
+ * style when it changes, so the first frame of the strip is already in the
675
+ * right place. `widthValue` is still what the gesture reads, because a
676
+ * worklet cannot see React state.
677
+ */
678
+ const strip = useAnimatedStyle(
679
+ () => ({ transform: [{ translateX: -position.value * width * sign }] }),
680
+ [width, sign]
681
+ );
646
682
 
647
683
  const onLayout = useCallback((event: LayoutChangeEvent) => {
648
684
  const measured = event.nativeEvent.layout;
@@ -755,27 +791,54 @@ export interface TabsListProps extends ViewProps {
755
791
  children: ReactNode;
756
792
  }
757
793
 
794
+ /**
795
+ * Where the active tab should sit in the scroller, or null when it cannot be
796
+ * known yet.
797
+ *
798
+ * A little in from the edge rather than flush against it, so the tab does not
799
+ * read as the last one in the row.
800
+ */
801
+ function scrollTarget(layout: TabLayout | undefined): number | null {
802
+ if (!layout) return null;
803
+ return Math.max(layout.x - 24, 0);
804
+ }
805
+
758
806
  function TabsList({ className, scrollable = false, children, ...props }: TabsListProps) {
759
- const { variant, setScrollable, value, layouts } = useTabs('Tabs.List');
807
+ const { variant, value, layouts } = useTabs('Tabs.List');
760
808
  const { list } = tabsVariants({ variant });
761
809
  const scroller = useRef<ScrollView>(null);
762
810
 
763
- useEffect(() => {
764
- setScrollable(scrollable);
765
- }, [scrollable, setScrollable]);
811
+ /*
812
+ * Where the row should be, kept as a ref rather than only applied once.
813
+ *
814
+ * A horizontal scroller does not always keep its offset when its content is
815
+ * laid out again, and the row is laid out again on every switch — so the
816
+ * scroller can be left at zero, showing the first tab, with nothing in this
817
+ * component's state disagreeing. Re-applying the target from
818
+ * `onContentSizeChange` puts it back where the selection says it should be.
819
+ */
820
+ const target = scrollTarget(layouts[value]);
821
+ const targetRef = useRef<number | null>(target);
822
+ targetRef.current = target;
823
+ const settled = useRef(false);
766
824
 
767
- // Bring the active tab into view when it changes from elsewhere — a
825
+ // Bring the active tab into view when the selection changes — a press, a
768
826
  // controlled switch, or a swipe on the panel below.
769
- const activeLayout = layouts[value];
770
827
  useEffect(() => {
771
- if (!scrollable || !activeLayout) return;
828
+ if (!scrollable || target === null) return;
772
829
  scroller.current?.scrollTo({
773
- // Land the tab a little in from the edge rather than flush against it,
774
- // so it does not read as the last one in the row.
775
- x: Math.max(activeLayout.x - 24, 0),
776
- animated: true,
830
+ x: target,
831
+ // The first measurement has nowhere to travel from: animating it is a
832
+ // row that visibly slides into place as the screen appears.
833
+ animated: settled.current,
777
834
  });
778
- }, [scrollable, activeLayout?.x, activeLayout]);
835
+ settled.current = true;
836
+ }, [scrollable, value, target]);
837
+
838
+ const restore = useCallback(() => {
839
+ if (!scrollable || targetRef.current === null) return;
840
+ scroller.current?.scrollTo({ x: targetRef.current, animated: false });
841
+ }, [scrollable]);
779
842
 
780
843
  const row = (
781
844
  <View {...props} accessibilityRole="tablist" className={cn(list(), className)}>
@@ -786,7 +849,14 @@ function TabsList({ className, scrollable = false, children, ...props }: TabsLis
786
849
  </View>
787
850
  );
788
851
 
789
- if (!scrollable) return row;
852
+ /*
853
+ * The triggers are told whether they are in a scroller here rather than
854
+ * through the root, so their width and the row they are measured in belong to
855
+ * one commit. See {@link TabsListContext}.
856
+ */
857
+ const scoped = <TabsListContext.Provider value={scrollable}>{row}</TabsListContext.Provider>;
858
+
859
+ if (!scrollable) return scoped;
790
860
 
791
861
  return (
792
862
  <ScrollView
@@ -796,8 +866,9 @@ function TabsList({ className, scrollable = false, children, ...props }: TabsLis
796
866
  // The row measures itself, and the indicator is positioned against it,
797
867
  // so the scroller must not stretch it to the viewport width.
798
868
  contentContainerStyle={{ flexGrow: 0 }}
869
+ onContentSizeChange={restore}
799
870
  >
800
- {row}
871
+ {scoped}
801
872
  </ScrollView>
802
873
  );
803
874
  }
@@ -826,20 +897,30 @@ function TabsTrigger({
826
897
  children,
827
898
  }: TabsTriggerProps) {
828
899
  const context = useTabs('Tabs.Trigger');
900
+ const scrollable = useContext(TabsListContext);
829
901
  const active = context.value === value;
830
902
  const slots = tabsVariants({
831
903
  variant: context.variant,
832
904
  active,
833
905
  disabled,
834
- scrollable: context.scrollable,
906
+ scrollable,
835
907
  });
836
908
 
909
+ /*
910
+ * Bound to `registerLayout` alone, which is stable for the life of the tab
911
+ * set — not to the whole context, which is rebuilt every time any trigger
912
+ * registers. Depending on the context made this a new function on every
913
+ * measurement, so a layout burst handed every trigger a new `onLayout` for
914
+ * every *other* trigger's measurement: quadratic prop updates in exactly the
915
+ * case that has enough tabs to be slow.
916
+ */
917
+ const { registerLayout } = context;
837
918
  const handleLayout = useCallback(
838
919
  (event: LayoutChangeEvent) => {
839
920
  const { x, width } = event.nativeEvent.layout;
840
- context.registerLayout(value, { x, width });
921
+ registerLayout(value, { x, width });
841
922
  },
842
- [context, value]
923
+ [registerLayout, value]
843
924
  );
844
925
 
845
926
  if (context.variant === 'expanding') {
@@ -46,6 +46,7 @@
46
46
  */
47
47
  import { useCallback, useEffect, useState } from 'react';
48
48
  import {
49
+ TurboModuleRegistry,
49
50
  useWindowDimensions,
50
51
  type LayoutChangeEvent,
51
52
  type View,
@@ -117,12 +118,44 @@ export interface UseKeyboardAvoidanceResult {
117
118
  type KeyboardHeightHook = () => SharedValue<number>;
118
119
 
119
120
  /**
120
- * Resolved once, at module load, so the hook below always calls the same
121
- * underlying hook — swapping between them per render would break the rules of
122
- * hooks.
121
+ * Whether the controller's native module is actually in this client.
122
+ *
123
+ * Resolving the package is not the same as being able to use it: in Expo Go the
124
+ * JavaScript is in `node_modules` and requires cleanly, and every call into it
125
+ * throws from a proxy that reports the package as unlinked. A `try`/`catch`
126
+ * around the require never sees that, because it happens later.
127
+ */
128
+ function nativeControllerPresent(): boolean {
129
+ try {
130
+ return TurboModuleRegistry.get('KeyboardController') !== null;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Resolved on the first render rather than at module load, and then never
138
+ * again.
139
+ *
140
+ * The resolution asks the native module registry a question, and this module is
141
+ * reachable from the package's root entry — so at module scope that question
142
+ * was being asked while a consuming app was still evaluating its imports,
143
+ * before the runtime had finished standing up. By the first render it is up.
144
+ *
145
+ * Caching it is what keeps the rules of hooks: `useKeyboardHeight` always calls
146
+ * the same underlying hook, because the implementation is chosen once and
147
+ * cannot change afterwards.
123
148
  */
124
- const useKeyboardHeight: KeyboardHeightHook = (() => {
149
+ let keyboardHeightImpl: KeyboardHeightHook | undefined;
150
+
151
+ function useKeyboardHeight(): SharedValue<number> {
152
+ if (!keyboardHeightImpl) keyboardHeightImpl = resolveKeyboardHeight();
153
+ return keyboardHeightImpl();
154
+ }
155
+
156
+ const resolveKeyboardHeight = (): KeyboardHeightHook => {
125
157
  try {
158
+ if (!nativeControllerPresent()) return () => useAnimatedKeyboard().height;
126
159
  // eslint-disable-next-line @typescript-eslint/no-require-imports
127
160
  const controller = require('react-native-keyboard-controller');
128
161
  if (typeof controller?.useReanimatedKeyboardAnimation === 'function') {
@@ -139,11 +172,12 @@ const useKeyboardHeight: KeyboardHeightHook = (() => {
139
172
  }
140
173
 
141
174
  return () => useAnimatedKeyboard().height;
142
- })();
175
+ };
143
176
 
144
177
  /** True when the keyboard controller is driving this, rather than the fallback. */
145
178
  export function hasKeyboardController(): boolean {
146
179
  try {
180
+ if (!nativeControllerPresent()) return false;
147
181
  // eslint-disable-next-line @typescript-eslint/no-require-imports
148
182
  return typeof require('react-native-keyboard-controller')
149
183
  ?.useReanimatedKeyboardAnimation === 'function';
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,
@@ -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
  }>;
@@ -175,7 +198,9 @@ interface SwiftUIComponents {
175
198
  Host: ComponentType<{
176
199
  children?: ReactNode;
177
200
  matchContents?: boolean | { vertical?: boolean; horizontal?: boolean };
178
- ignoreSafeArea?: unknown;
201
+ ignoreSafeArea?: 'all' | 'container' | 'keyboard';
202
+ /** As on the portable host above — see `NativeHost`. */
203
+ colorScheme?: 'light' | 'dark';
179
204
  style?: unknown;
180
205
  }>;
181
206
  RNHostView: ComponentType<{ children?: ReactNode; matchContents?: boolean }>;
@@ -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
+ }
@@ -44,6 +44,7 @@
44
44
  */
45
45
  import type { ComponentType, ReactNode } from 'react';
46
46
  import { Platform, StyleSheet, View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native';
47
+ import { useThemeMode } from '../theme/use-theme';
47
48
  import { cn } from '../utils/cn';
48
49
  import { useReduceTransparency } from './scrim';
49
50
 
@@ -144,6 +145,16 @@ export function Glass({
144
145
  ...props
145
146
  }: GlassProps) {
146
147
  const reduceTransparency = useReduceTransparency();
148
+ /*
149
+ * Which appearance the material is drawn in, from the app's theme rather
150
+ * than the phone's.
151
+ *
152
+ * The material's own default follows the system, so an app running a dark
153
+ * theme on a phone set to light draws light glass over dark content — and a
154
+ * theme changed at runtime leaves it where it was, because the system
155
+ * appearance never moved.
156
+ */
157
+ const { mode } = useThemeMode();
147
158
  // Not knowing yet counts as "do not draw it": the material arriving a frame
148
159
  // late is invisible, and one flashing at somebody who opted out is not.
149
160
  const material = GlassView !== null && reduceTransparency === false;
@@ -159,6 +170,7 @@ export function Glass({
159
170
  <GlassView
160
171
  glassEffectStyle={variant}
161
172
  tintColor={tint}
173
+ colorScheme={mode}
162
174
  pointerEvents="none"
163
175
  style={[StyleSheet.absoluteFill, shape]}
164
176
  />
@@ -1,5 +1,5 @@
1
1
  import { Fragment, type ComponentType, type ReactNode } from 'react';
2
- import { Platform, StyleSheet, View } from 'react-native';
2
+ import { Platform, StyleSheet, TurboModuleRegistry, View } from 'react-native';
3
3
  import { GestureHandlerRootView } from 'react-native-gesture-handler';
4
4
  import {
5
5
  PortalHost,
@@ -15,16 +15,44 @@ import { cn } from '../utils/cn';
15
15
  * avoidance simply does nothing. Mount it here when the package is installed
16
16
  * so `avoidKeyboard` works without any extra setup, and fall back to a
17
17
  * pass-through when it is not.
18
+ *
19
+ * Installed is not the same question as usable, and the difference is what a
20
+ * `try`/`catch` around the `require` cannot see. In a client that loads no
21
+ * native modules of its own — Expo Go — the JavaScript is in `node_modules`
22
+ * and resolves, so the require succeeds and hands back a provider whose native
23
+ * side is absent. The throw then lands when that provider mounts, outside the
24
+ * `try`, and takes the app down before anything has painted.
25
+ *
26
+ * `TurboModuleRegistry.get` answers the real question and returns null rather
27
+ * than throwing, so the pass-through is reached instead.
28
+ *
29
+ * Resolved on the first render rather than when this module is evaluated. This
30
+ * file is the first thing a consuming app imports, and asking the native module
31
+ * registry a question before the runtime has finished standing up is a question
32
+ * asked too early — by the first render it is up, and the answer cannot change
33
+ * afterwards. The result is cached so the component type is stable: swapping it
34
+ * between renders would unmount and rebuild everything below it.
18
35
  */
19
- const KeyboardProvider: ComponentType<{ children?: ReactNode }> = (() => {
36
+ let keyboardProvider: ComponentType<{ children?: ReactNode }> | undefined;
37
+
38
+ function resolveKeyboardProvider(): ComponentType<{ children?: ReactNode }> {
39
+ if (keyboardProvider) return keyboardProvider;
40
+
41
+ let resolved: ComponentType<{ children?: ReactNode }> = Fragment;
20
42
  try {
21
- // eslint-disable-next-line @typescript-eslint/no-require-imports
22
- const controller = require('react-native-keyboard-controller');
23
- return controller?.KeyboardProvider ?? Fragment;
43
+ if (TurboModuleRegistry.get('KeyboardController') !== null) {
44
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
45
+ const controller = require('react-native-keyboard-controller');
46
+ resolved = controller?.KeyboardProvider ?? Fragment;
47
+ }
24
48
  } catch {
25
- return Fragment;
49
+ // Not installed, or installed without its native half. Either way the
50
+ // pass-through is the answer.
26
51
  }
27
- })();
52
+
53
+ keyboardProvider = resolved;
54
+ return resolved;
55
+ }
28
56
 
29
57
  export interface PanelUIProviderProps {
30
58
  children: ReactNode;
@@ -57,6 +85,10 @@ export function PanelUIProvider({
57
85
  className,
58
86
  background = true,
59
87
  }: PanelUIProviderProps) {
88
+ // Resolved once, on the first render of the first provider in the app, and
89
+ // cached from there. See {@link resolveKeyboardProvider}.
90
+ const KeyboardProvider = resolveKeyboardProvider();
91
+
60
92
  return (
61
93
  <GestureHandlerRootView style={styles.root}>
62
94
  {/* Outermost of ours, so every field below it can avoid the keyboard.