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.
- package/lib/module/components/bottom-sheet/index.js +24 -13
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/progress-button/index.js +83 -18
- package/lib/module/components/progress-button/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +119 -28
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +37 -6
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
- package/lib/module/providers/panel-ui-provider.js +35 -7
- package/lib/module/providers/panel-ui-provider.js.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/progress-button/index.d.ts +38 -0
- package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
- package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/bottom-sheet/index.tsx +23 -13
- package/src/components/progress-button/index.tsx +58 -18
- package/src/components/tabs/index.tsx +112 -31
- package/src/hooks/use-keyboard-avoidance.ts +39 -5
- package/src/providers/panel-ui-provider.tsx +39 -7
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
644
|
-
|
|
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,
|
|
807
|
+
const { variant, value, layouts } = useTabs('Tabs.List');
|
|
760
808
|
const { list } = tabsVariants({ variant });
|
|
761
809
|
const scroller = useRef<ScrollView>(null);
|
|
762
810
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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
|
|
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 ||
|
|
828
|
+
if (!scrollable || target === null) return;
|
|
772
829
|
scroller.current?.scrollTo({
|
|
773
|
-
|
|
774
|
-
//
|
|
775
|
-
|
|
776
|
-
animated:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
921
|
+
registerLayout(value, { x, width });
|
|
841
922
|
},
|
|
842
|
-
[
|
|
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
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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.
|