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.
- package/README.md +17 -8
- package/lib/module/components/bottom-sheet/index.js +27 -15
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +37 -16
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/flip-card/index.js +338 -0
- package/lib/module/components/flip-card/index.js.map +1 -0
- package/lib/module/components/popover/index.js +3 -2
- package/lib/module/components/popover/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/select/index.js +3 -2
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/slider/index.js +16 -5
- package/lib/module/components/slider/index.js.map +1 -1
- package/lib/module/components/switch/index.js +28 -7
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/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/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js +7 -0
- package/lib/module/native/index.js.map +1 -1
- package/lib/module/native/native-host.js +62 -0
- package/lib/module/native/native-host.js.map +1 -0
- package/lib/module/primitives/glass.js +14 -0
- package/lib/module/primitives/glass.js.map +1 -1
- package/lib/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/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/flip-card/index.d.ts +116 -0
- package/lib/typescript/src/components/flip-card/index.d.ts.map +1 -0
- package/lib/typescript/src/components/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/slider/index.d.ts.map +1 -1
- package/lib/typescript/src/components/switch/index.d.ts +0 -4
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/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/index.d.ts +1 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +28 -11
- package/lib/typescript/src/native/index.d.ts.map +1 -1
- package/lib/typescript/src/native/native-host.d.ts +71 -0
- package/lib/typescript/src/native/native-host.d.ts.map +1 -0
- package/lib/typescript/src/primitives/glass.d.ts.map +1 -1
- package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
- package/package.json +11 -1
- package/src/components/bottom-sheet/index.tsx +31 -16
- package/src/components/button/index.tsx +36 -15
- package/src/components/flip-card/index.tsx +419 -0
- package/src/components/popover/index.tsx +3 -3
- package/src/components/progress-button/index.tsx +58 -18
- package/src/components/select/index.tsx +3 -3
- package/src/components/slider/index.tsx +28 -6
- package/src/components/switch/index.tsx +28 -7
- package/src/components/tabs/index.tsx +112 -31
- package/src/hooks/use-keyboard-avoidance.ts +39 -5
- package/src/index.ts +9 -0
- package/src/native/index.ts +35 -10
- package/src/native/native-host.tsx +75 -0
- package/src/primitives/glass.tsx +12 -0
- 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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
136
|
+
/*
|
|
137
|
+
* The height is stated; only the width is matched.
|
|
138
|
+
*
|
|
139
|
+
* `matchContents` hands an axis to the platform for good — the host
|
|
140
|
+
* writes the measured size back into the layout every time the
|
|
141
|
+
* platform's geometry changes, not once on mount — and the vertical
|
|
142
|
+
* axis is the one that moves everything below it when it does. A
|
|
143
|
+
* toggle's height is the one number here that does not vary, so it is
|
|
144
|
+
* given rather than asked for; its width is the platform's and stays
|
|
145
|
+
* the platform's.
|
|
146
|
+
*/
|
|
147
|
+
<NativeHost
|
|
148
|
+
host={Host}
|
|
149
|
+
matchContents={{ horizontal: true }}
|
|
150
|
+
ignoreSafeArea="keyboard"
|
|
151
|
+
style={{ height: NATIVE_TOGGLE_HEIGHT }}
|
|
152
|
+
>
|
|
132
153
|
<NativeSwitch
|
|
133
154
|
value={value}
|
|
134
155
|
onValueChange={(next: boolean) => onValueChange?.(next)}
|
|
135
156
|
label={label ?? accessibilityLabel}
|
|
136
157
|
disabled={disabled}
|
|
137
158
|
/>
|
|
138
|
-
</
|
|
159
|
+
</NativeHost>
|
|
139
160
|
);
|
|
140
161
|
}
|
|
141
162
|
|
|
@@ -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';
|
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,
|
package/src/native/index.ts
CHANGED
|
@@ -18,25 +18,40 @@
|
|
|
18
18
|
* **Theme tokens do not apply in native mode.** The platform draws the control
|
|
19
19
|
* with its own colours, metrics and typography — that is the entire point, and
|
|
20
20
|
* it means `className` and the variant props are ignored on those components.
|
|
21
|
+
*
|
|
22
|
+
* The one exception is which appearance it draws: `colorScheme` is the single
|
|
23
|
+
* theme signal the toolkit accepts, and `NativeHost` is what passes it. Mount
|
|
24
|
+
* every host through that rather than reaching for `Host` directly, or the
|
|
25
|
+
* control resolves its own appearance from the system and stops tracking the
|
|
26
|
+
* app's theme.
|
|
21
27
|
*/
|
|
22
28
|
import { Platform } from 'react-native';
|
|
23
29
|
import type { ComponentType, ReactNode } from 'react';
|
|
24
30
|
|
|
31
|
+
export { NativeHost, type NativeHostProps } from './native-host';
|
|
32
|
+
|
|
25
33
|
interface NativeUIModule {
|
|
26
34
|
Host: ComponentType<{
|
|
27
35
|
children?: ReactNode;
|
|
28
36
|
/**
|
|
29
|
-
*
|
|
37
|
+
* Which axes the platform is allowed to size.
|
|
30
38
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* the
|
|
34
|
-
*
|
|
35
|
-
*
|
|
39
|
+
* **It is not a one-off measurement.** An axis given to `matchContents` is
|
|
40
|
+
* given for good: the host writes the platform's measured size straight
|
|
41
|
+
* into the layout every time the platform's geometry changes, and dirties
|
|
42
|
+
* the layout when it does. So a control that lays itself out again under a
|
|
43
|
+
* press drags its box with it, and everything below it moves — which is
|
|
44
|
+
* the defect this spent three attempts on, twice reasoning about the first
|
|
45
|
+
* measurement when the problem was every one after it.
|
|
36
46
|
*
|
|
37
|
-
* The
|
|
38
|
-
*
|
|
39
|
-
*
|
|
47
|
+
* The rule that comes out of that: **never hand over an axis whose size
|
|
48
|
+
* you already know.** State it in `style` instead and match only what is
|
|
49
|
+
* genuinely the platform's — a labelled button's width, a toggle's width.
|
|
50
|
+
* An axis left out is never written to, so an explicit size on it is safe.
|
|
51
|
+
*
|
|
52
|
+
* Where nothing can be stated the axis has to stay matched. A picker is
|
|
53
|
+
* the honest example: a menu is a compact button and a wheel is a rotor,
|
|
54
|
+
* and only the platform knows which it drew.
|
|
40
55
|
*/
|
|
41
56
|
matchContents?: boolean | { vertical?: boolean; horizontal?: boolean };
|
|
42
57
|
/**
|
|
@@ -56,6 +71,14 @@ interface NativeUIModule {
|
|
|
56
71
|
* platform's business and stay its business.
|
|
57
72
|
*/
|
|
58
73
|
ignoreSafeArea?: 'all' | 'container' | 'keyboard';
|
|
74
|
+
/**
|
|
75
|
+
* The appearance the platform draws the hosted control in.
|
|
76
|
+
*
|
|
77
|
+
* Passed by `NativeHost` from the app's own theme, because the host would
|
|
78
|
+
* otherwise resolve it from the system — which is a different question,
|
|
79
|
+
* and one whose answer does not change when the theme does.
|
|
80
|
+
*/
|
|
81
|
+
colorScheme?: 'light' | 'dark';
|
|
59
82
|
style?: unknown;
|
|
60
83
|
[key: string]: unknown;
|
|
61
84
|
}>;
|
|
@@ -175,7 +198,9 @@ interface SwiftUIComponents {
|
|
|
175
198
|
Host: ComponentType<{
|
|
176
199
|
children?: ReactNode;
|
|
177
200
|
matchContents?: boolean | { vertical?: boolean; horizontal?: boolean };
|
|
178
|
-
ignoreSafeArea?:
|
|
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
|
+
}
|
package/src/primitives/glass.tsx
CHANGED
|
@@ -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
|
-
|
|
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.
|