panelui-native 0.85.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.
@@ -116,15 +116,6 @@ const DONE_TINT = {
116
116
 
117
117
  const progressButtonVariants = tv({
118
118
  slots: {
119
- /*
120
- * A pill, not the `rounded-lg` the other buttons take.
121
- *
122
- * The fill is clipped by this radius, so the shape of the button is also
123
- * the shape of the wipe's leading edge as it comes out of the corner. On a
124
- * small radius that edge emerges square from a rounded box, which reads as
125
- * a rectangle sliding out from under the button rather than as the button
126
- * filling up.
127
- */
128
119
  /*
129
120
  * Every variant rests on the same secondary surface, and `variant` decides
130
121
  * only what colour comes across it.
@@ -140,7 +131,7 @@ const progressButtonVariants = tv({
140
131
  * that survives the wipe, since the fill covers the ground the label was
141
132
  * standing on and the second copy takes over.
142
133
  */
143
- root: 'relative overflow-hidden rounded-full border border-transparent bg-secondary',
134
+ root: 'relative overflow-hidden border border-transparent bg-secondary',
144
135
  /*
145
136
  * The row inside the button. It is separate from `root` because the fill
146
137
  * has to sit over the whole button including its padding — a wipe that
@@ -179,29 +170,50 @@ const progressButtonVariants = tv({
179
170
  size: {
180
171
  // Matched to Button's boxes, and `min-h-*` for the same reason: the
181
172
  // label's glyphs grow with the system text size and the box has to grow
182
- // with them.
183
- // Wider than the equivalent Button, because the corner is a half-circle
184
- // rather than a small radius: the curve eats into the side padding, and
185
- // at Button's values the first and last glyphs sit against it.
173
+ // with them. The side padding is `shape`'s to decide, below.
186
174
  sm: {
187
175
  root: 'min-h-9',
188
- content: 'min-h-9 gap-1.5 px-3.5 py-2',
176
+ content: 'min-h-9 gap-1.5 py-2',
189
177
  label: 'text-[14px]',
190
178
  fillLabel: 'text-[14px]',
191
179
  },
192
180
  md: {
193
181
  root: 'min-h-11',
194
- content: 'min-h-11 px-5 py-2.5',
182
+ content: 'min-h-11 py-2.5',
195
183
  label: 'text-[16px]',
196
184
  fillLabel: 'text-[16px]',
197
185
  },
198
186
  lg: {
199
187
  root: 'min-h-12',
200
- content: 'min-h-12 px-7 py-2.5',
188
+ content: 'min-h-12 py-2.5',
201
189
  label: 'text-[18px]',
202
190
  fillLabel: 'text-[18px]',
203
191
  },
204
192
  },
193
+ /*
194
+ * The corner, and the side padding that follows from it.
195
+ *
196
+ * The fill is clipped by the button's radius, so the shape of the button is
197
+ * also the shape of the wipe's leading edge as it comes out of the corner.
198
+ * A pill sends that edge out as a curve, which reads as the button filling
199
+ * up; a small radius sends it out square, which reads as a rectangle
200
+ * sliding out from under the button. That is why `pill` is the default and
201
+ * stays it.
202
+ *
203
+ * `rounded` is for a progress button standing in a row of ordinary ones —
204
+ * a form's footer, a toolbar, a card's actions — where a lone pill among
205
+ * `rounded-lg` buttons reads as a different kind of control rather than as
206
+ * the one that has to be held. It is `Button`'s box exactly: same radius,
207
+ * same padding, same minimum width, and the heights already agreed.
208
+ */
209
+ shape: {
210
+ // The radius is only on the root. It has `overflow-hidden`, so it is what
211
+ // clips the fill — and the fill's own leading edge has to stay straight:
212
+ // rounding it would round the wipe's front as well as the button's
213
+ // corner, which is a bar with a domed end travelling across the button.
214
+ pill: { root: 'rounded-full' },
215
+ rounded: { root: 'rounded-lg' },
216
+ },
205
217
  fullWidth: {
206
218
  true: { root: 'w-full' },
207
219
  },
@@ -209,9 +221,22 @@ const progressButtonVariants = tv({
209
221
  true: { root: 'opacity-[0.64]' },
210
222
  },
211
223
  },
224
+ compoundVariants: [
225
+ // A pill is wider than the equivalent Button at every size, because the
226
+ // corner is a half-circle rather than a small radius: the curve eats into
227
+ // the side padding, and at Button's values the first and last glyphs sit
228
+ // against it. `rounded` has Button's corner, so it takes Button's padding.
229
+ { shape: 'pill', size: 'sm', class: { content: 'px-3.5' } },
230
+ { shape: 'pill', size: 'md', class: { content: 'px-5' } },
231
+ { shape: 'pill', size: 'lg', class: { content: 'px-7' } },
232
+ { shape: 'rounded', size: 'sm', class: { root: 'min-w-9', content: 'px-2.5' } },
233
+ { shape: 'rounded', size: 'md', class: { root: 'min-w-11', content: 'px-4' } },
234
+ { shape: 'rounded', size: 'lg', class: { content: 'px-6' } },
235
+ ],
212
236
  defaultVariants: {
213
237
  variant: 'primary',
214
238
  size: 'md',
239
+ shape: 'pill',
215
240
  },
216
241
  });
217
242
 
@@ -221,6 +246,8 @@ type ProgressButtonVariantProps = VariantProps<typeof progressButtonVariants>;
221
246
  export type ProgressButtonVariant = NonNullable<ProgressButtonVariantProps['variant']>;
222
247
  /** How big a progress button is. */
223
248
  export type ProgressButtonSize = NonNullable<ProgressButtonVariantProps['size']>;
249
+ /** What corner a progress button has. */
250
+ export type ProgressButtonShape = NonNullable<ProgressButtonVariantProps['shape']>;
224
251
 
225
252
  interface ProgressButtonContextValue {
226
253
  /** `0` to `1` across the hold. */
@@ -282,6 +309,18 @@ export interface ProgressButtonProps
282
309
  * whether an action is worth feeling is the caller's call, not the control's.
283
310
  */
284
311
  haptics?: boolean;
312
+ /**
313
+ * The corner. `pill` by default — the fill is clipped by it, so a half-circle
314
+ * sends the wipe's leading edge out as a curve and the button reads as
315
+ * filling up.
316
+ *
317
+ * `rounded` gives it [Button](/docs/components/button)'s box exactly: the
318
+ * same radius, side padding and minimum width, at heights that already
319
+ * matched. Use it where the hold stands in a row of ordinary buttons — a
320
+ * form's footer, a toolbar, a card's actions — and a lone pill would read as
321
+ * a different kind of control rather than as the one that has to be held.
322
+ */
323
+ shape?: ProgressButtonShape;
285
324
  children?: ReactNode;
286
325
  }
287
326
 
@@ -290,6 +329,7 @@ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function Progre
290
329
  className,
291
330
  variant = 'primary',
292
331
  size = 'md',
332
+ shape = 'pill',
293
333
  fullWidth,
294
334
  holdDuration,
295
335
  onComplete,
@@ -305,7 +345,7 @@ const ProgressButtonRoot = forwardRef<View, ProgressButtonProps>(function Progre
305
345
  },
306
346
  ref
307
347
  ) {
308
- const slots = progressButtonVariants({ variant, size, fullWidth, disabled });
348
+ const slots = progressButtonVariants({ variant, size, shape, fullWidth, disabled });
309
349
  const duration = resolveHoldDuration(holdDuration);
310
350
  const reducedMotion = useReducedMotion();
311
351
 
@@ -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';
@@ -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.