panelui-native 0.5.3 → 0.7.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 +9 -5
- package/lib/module/components/accordion/index.js +7 -5
- package/lib/module/components/accordion/index.js.map +1 -1
- package/lib/module/components/alert/index.js +3 -6
- package/lib/module/components/alert/index.js.map +1 -1
- package/lib/module/components/bottom-sheet/index.js +67 -3
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +36 -1
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/empty-state/index.js +4 -5
- package/lib/module/components/empty-state/index.js.map +1 -1
- package/lib/module/components/inline-select/index.js +171 -46
- package/lib/module/components/inline-select/index.js.map +1 -1
- package/lib/module/components/input/index.js +28 -5
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/input-group/index.js +0 -1
- package/lib/module/components/input-group/index.js.map +1 -1
- package/lib/module/components/item/index.js +359 -0
- package/lib/module/components/item/index.js.map +1 -0
- package/lib/module/components/label/index.js +5 -3
- package/lib/module/components/label/index.js.map +1 -1
- package/lib/module/components/message/index.js +318 -0
- package/lib/module/components/message/index.js.map +1 -0
- package/lib/module/components/scroll-fade/index.js +204 -0
- package/lib/module/components/scroll-fade/index.js.map +1 -0
- package/lib/module/components/select/index.js +35 -6
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/shimmer/index.js +167 -0
- package/lib/module/components/shimmer/index.js.map +1 -0
- package/lib/module/components/surface/index.js +65 -0
- package/lib/module/components/surface/index.js.map +1 -0
- package/lib/module/components/switch/index.js +20 -1
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/toast/index.js +7 -8
- package/lib/module/components/toast/index.js.map +1 -1
- package/lib/module/components/typography/index.js +3 -3
- package/lib/module/hooks/index.js +10 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/use-breakpoint.js +39 -0
- package/lib/module/hooks/use-breakpoint.js.map +1 -0
- package/lib/module/hooks/use-copy-to-clipboard.js +60 -0
- package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -0
- package/lib/module/hooks/use-debounced-value.js +24 -0
- package/lib/module/hooks/use-debounced-value.js.map +1 -0
- package/lib/module/hooks/use-disclosure.js +43 -0
- package/lib/module/hooks/use-disclosure.js.map +1 -0
- package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
- package/lib/module/hooks/use-keyboard.js +36 -0
- package/lib/module/hooks/use-keyboard.js.map +1 -0
- package/lib/module/hooks/use-previous.js +25 -0
- package/lib/module/hooks/use-previous.js.map +1 -0
- package/lib/module/icons/index.js +4 -4
- package/lib/module/index.js +12 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js +53 -0
- package/lib/module/native/index.js.map +1 -0
- package/lib/module/primitives/keyboard-avoider.js +40 -0
- package/lib/module/primitives/keyboard-avoider.js.map +1 -0
- package/lib/module/providers/panel-ui-provider.js +24 -4
- package/lib/module/providers/panel-ui-provider.js.map +1 -1
- package/lib/module/theme/use-theme.js +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +7 -5
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- package/lib/typescript/src/components/alert/index.d.ts +3 -6
- package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +21 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +11 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/empty-state/index.d.ts +4 -5
- package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
- package/lib/typescript/src/components/inline-select/index.d.ts +29 -6
- package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input/index.d.ts +13 -0
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input-group/index.d.ts +0 -1
- package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/item/index.d.ts +286 -0
- package/lib/typescript/src/components/item/index.d.ts.map +1 -0
- package/lib/typescript/src/components/label/index.d.ts +5 -3
- package/lib/typescript/src/components/label/index.d.ts.map +1 -1
- package/lib/typescript/src/components/message/index.d.ts +170 -0
- package/lib/typescript/src/components/message/index.d.ts.map +1 -0
- package/lib/typescript/src/components/scroll-fade/index.d.ts +47 -0
- package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts +16 -1
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/shimmer/index.d.ts +60 -0
- package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -0
- package/lib/typescript/src/components/surface/index.d.ts +30 -0
- package/lib/typescript/src/components/surface/index.d.ts.map +1 -0
- package/lib/typescript/src/components/switch/index.d.ts +10 -0
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/index.d.ts +4 -4
- package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/index.d.ts +8 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-breakpoint.d.ts +32 -0
- package/lib/typescript/src/hooks/use-breakpoint.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +28 -0
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-debounced-value.d.ts +13 -0
- package/lib/typescript/src/hooks/use-debounced-value.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-disclosure.d.ts +32 -0
- package/lib/typescript/src/hooks/use-disclosure.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +20 -0
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-keyboard.d.ts +20 -0
- package/lib/typescript/src/hooks/use-keyboard.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-previous.d.ts +15 -0
- package/lib/typescript/src/hooks/use-previous.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +4 -4
- package/lib/typescript/src/index.d.ts +8 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +35 -0
- package/lib/typescript/src/native/index.d.ts.map +1 -0
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts +22 -0
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -0
- package/lib/typescript/src/providers/panel-ui-provider.d.ts +1 -1
- package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
- package/lib/typescript/src/theme/use-theme.d.ts +1 -1
- package/package.json +19 -3
- package/src/components/accordion/index.tsx +7 -5
- package/src/components/alert/index.tsx +3 -6
- package/src/components/bottom-sheet/index.tsx +82 -2
- package/src/components/button/index.tsx +52 -0
- package/src/components/empty-state/index.tsx +4 -5
- package/src/components/inline-select/index.tsx +191 -44
- package/src/components/input/index.tsx +39 -3
- package/src/components/input-group/index.tsx +0 -1
- package/src/components/item/index.tsx +387 -0
- package/src/components/label/index.tsx +5 -3
- package/src/components/message/index.tsx +312 -0
- package/src/components/scroll-fade/index.tsx +280 -0
- package/src/components/select/index.tsx +54 -9
- package/src/components/shimmer/index.tsx +228 -0
- package/src/components/surface/index.tsx +64 -0
- package/src/components/switch/index.tsx +27 -1
- package/src/components/toast/index.tsx +7 -8
- package/src/components/typography/index.tsx +3 -3
- package/src/hooks/index.ts +24 -0
- package/src/hooks/use-breakpoint.ts +52 -0
- package/src/hooks/use-copy-to-clipboard.ts +79 -0
- package/src/hooks/use-debounced-value.ts +23 -0
- package/src/hooks/use-disclosure.ts +57 -0
- package/src/hooks/use-keyboard-avoidance.ts +140 -0
- package/src/hooks/use-keyboard.ts +43 -0
- package/src/hooks/use-previous.ts +24 -0
- package/src/icons/index.tsx +4 -4
- package/src/index.ts +41 -0
- package/src/native/index.ts +79 -0
- package/src/primitives/keyboard-avoider.tsx +49 -0
- package/src/providers/panel-ui-provider.tsx +30 -9
- package/src/theme/use-theme.ts +1 -1
- package/theme.css +23 -3
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Accordion — collapsible sections.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Split into Item / Trigger / Indicator / Content so a section header can hold
|
|
5
|
+
* anything — a badge, a price, a second line — instead of only a string. The
|
|
6
|
+
* chevron rotates rather than swapping glyphs, and the panel expands through a
|
|
7
|
+
* layout transition, both on the UI thread.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* `selectionMode` decides whether opening one section closes the others, which
|
|
10
|
+
* also changes the shape of `value`: a string when single, an array when
|
|
11
|
+
* multiple.
|
|
10
12
|
*/
|
|
11
13
|
import {
|
|
12
14
|
Children,
|
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Alert — a status message with an optional leading status icon.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* The `variant` prop name is kept from PanelUI v0.2 rather than renamed to
|
|
9
|
-
* HeroUI's `status`, so existing code keeps working.
|
|
4
|
+
* The indicator derives its icon and colour from the variant, so a success
|
|
5
|
+
* alert cannot end up with a warning icon. Pass `icon` on the root to override
|
|
6
|
+
* it everywhere, or give `Alert.Indicator` children to override one.
|
|
10
7
|
*/
|
|
11
8
|
import { createContext, forwardRef, useContext, type ReactNode } from 'react';
|
|
12
9
|
import { View, type Text as RNText, type ViewProps } from 'react-native';
|
|
@@ -23,6 +23,7 @@ import Animated, {
|
|
|
23
23
|
withTiming,
|
|
24
24
|
} from 'react-native-reanimated';
|
|
25
25
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
26
|
+
import { getNativeUI } from '../../native';
|
|
26
27
|
import { Portal } from '../../primitives/portal';
|
|
27
28
|
import { cn } from '../../utils/cn';
|
|
28
29
|
|
|
@@ -50,13 +51,40 @@ export interface BottomSheetProps {
|
|
|
50
51
|
open?: boolean;
|
|
51
52
|
onOpenChange?: (open: boolean) => void;
|
|
52
53
|
defaultOpen?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Present the platform's own sheet instead of this one, so it gets the
|
|
56
|
+
* system's detents, scroll interaction and dismiss gesture. Requires the
|
|
57
|
+
* optional `@expo/ui` package; without it this prop does nothing.
|
|
58
|
+
*
|
|
59
|
+
* **Theme tokens do not apply to the sheet chrome** — the platform draws the
|
|
60
|
+
* container, so `BottomSheet.Content`'s `className` and its drag handle are
|
|
61
|
+
* ignored. The content inside is still yours.
|
|
62
|
+
*/
|
|
63
|
+
native?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Heights the native sheet can rest at. Omit to size to the content.
|
|
66
|
+
* `{ fraction }` and `{ height }` are iOS-only; Android snaps them to the
|
|
67
|
+
* nearest of `half` / `full`.
|
|
68
|
+
*/
|
|
69
|
+
snapPoints?: ('half' | 'full' | { fraction: number } | { height: number })[];
|
|
53
70
|
}
|
|
54
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Set by the root so Content knows the platform is drawing the sheet, and with
|
|
74
|
+
* which detents. Null means the styled sheet renders.
|
|
75
|
+
*/
|
|
76
|
+
const NativeSheetContext = createContext<{
|
|
77
|
+
nativeUI: NonNullable<ReturnType<typeof getNativeUI>>;
|
|
78
|
+
snapPoints: BottomSheetProps['snapPoints'];
|
|
79
|
+
} | null>(null);
|
|
80
|
+
|
|
55
81
|
function BottomSheetRoot({
|
|
56
82
|
children,
|
|
57
83
|
open,
|
|
58
84
|
onOpenChange,
|
|
59
85
|
defaultOpen = false,
|
|
86
|
+
native,
|
|
87
|
+
snapPoints,
|
|
60
88
|
}: BottomSheetProps) {
|
|
61
89
|
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
62
90
|
const isControlled = open !== undefined;
|
|
@@ -75,9 +103,17 @@ function BottomSheetRoot({
|
|
|
75
103
|
[resolvedOpen, setOpen]
|
|
76
104
|
);
|
|
77
105
|
|
|
106
|
+
const nativeUI = native ? getNativeUI() : null;
|
|
107
|
+
const nativeSheet = useMemo(
|
|
108
|
+
() => (nativeUI ? { nativeUI, snapPoints } : null),
|
|
109
|
+
[nativeUI, snapPoints]
|
|
110
|
+
);
|
|
111
|
+
|
|
78
112
|
return (
|
|
79
113
|
<BottomSheetContext.Provider value={context}>
|
|
80
|
-
{
|
|
114
|
+
<NativeSheetContext.Provider value={nativeSheet}>
|
|
115
|
+
{children}
|
|
116
|
+
</NativeSheetContext.Provider>
|
|
81
117
|
</BottomSheetContext.Provider>
|
|
82
118
|
);
|
|
83
119
|
}
|
|
@@ -113,7 +149,8 @@ function BottomSheetContent({
|
|
|
113
149
|
}: BottomSheetContentProps) {
|
|
114
150
|
const context = useBottomSheet('BottomSheet.Content');
|
|
115
151
|
const { open, setOpen } = context;
|
|
116
|
-
const
|
|
152
|
+
const nativeSheet = useContext(NativeSheetContext);
|
|
153
|
+
const { height: screenHeight, width: screenWidth } = useWindowDimensions();
|
|
117
154
|
const insets = useSafeAreaInsets();
|
|
118
155
|
const translateY = useSharedValue(0);
|
|
119
156
|
|
|
@@ -142,6 +179,49 @@ function BottomSheetContent({
|
|
|
142
179
|
transform: [{ translateY: translateY.value }],
|
|
143
180
|
}));
|
|
144
181
|
|
|
182
|
+
if (nativeSheet) {
|
|
183
|
+
const { Host, BottomSheet: NativeBottomSheet, RNHostView } = nativeSheet.nativeUI;
|
|
184
|
+
// The platform owns presentation, so this stays mounted and toggles
|
|
185
|
+
// isPresented rather than unmounting on close.
|
|
186
|
+
//
|
|
187
|
+
// RNHostView is not optional: our content is React Native, and the native
|
|
188
|
+
// sheet cannot measure RN views directly. Without it the sheet sizes to
|
|
189
|
+
// nothing and the content spills outside its container.
|
|
190
|
+
return (
|
|
191
|
+
<Host matchContents style={{ position: 'absolute' }}>
|
|
192
|
+
<NativeBottomSheet
|
|
193
|
+
isPresented={open}
|
|
194
|
+
onDismiss={dismissible ? close : () => {}}
|
|
195
|
+
snapPoints={nativeSheet.snapPoints}
|
|
196
|
+
>
|
|
197
|
+
<RNHostView matchContents>
|
|
198
|
+
<BottomSheetContext.Provider value={context}>
|
|
199
|
+
<View
|
|
200
|
+
{...props}
|
|
201
|
+
className={cn(
|
|
202
|
+
// The platform draws the container, but it hands us a bare
|
|
203
|
+
// box — padding and safe-area are still ours.
|
|
204
|
+
'gap-2 px-5 pb-2 pt-1',
|
|
205
|
+
className
|
|
206
|
+
)}
|
|
207
|
+
style={{
|
|
208
|
+
// An explicit width, not `w-full`. Inside the native host
|
|
209
|
+
// there is no parent width for a percentage to resolve
|
|
210
|
+
// against, so `100%` measures against nothing and the
|
|
211
|
+
// content lays out wider than the sheet it sits in.
|
|
212
|
+
width: screenWidth,
|
|
213
|
+
paddingBottom: Math.max(insets.bottom, 16),
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
{children}
|
|
217
|
+
</View>
|
|
218
|
+
</BottomSheetContext.Provider>
|
|
219
|
+
</RNHostView>
|
|
220
|
+
</NativeBottomSheet>
|
|
221
|
+
</Host>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
145
225
|
if (!open) return null;
|
|
146
226
|
|
|
147
227
|
return (
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from '../../primitives/animated-pressable';
|
|
9
9
|
import { Text } from '../../primitives/text';
|
|
10
10
|
import { IconColorProvider } from '../../icons';
|
|
11
|
+
import { getNativeUI } from '../../native';
|
|
11
12
|
import { Spinner } from '../spinner';
|
|
12
13
|
|
|
13
14
|
const buttonVariants = tv({
|
|
@@ -104,8 +105,32 @@ export interface ButtonProps
|
|
|
104
105
|
endContent?: ReactNode;
|
|
105
106
|
/** Extra classes for the label when children is a string. */
|
|
106
107
|
labelClassName?: string;
|
|
108
|
+
/**
|
|
109
|
+
* Render the platform's own button instead of this one. Requires the
|
|
110
|
+
* optional `@expo/ui` package; without it this prop does nothing.
|
|
111
|
+
*
|
|
112
|
+
* **Theme tokens do not apply** — the platform draws the button, so
|
|
113
|
+
* `className`, `size`, `fullWidth`, `startContent`, `endContent` and
|
|
114
|
+
* `loading` are all ignored. `variant` maps onto the nearest platform
|
|
115
|
+
* style: `primary`/`destructive` → filled, `outline` → outlined, everything
|
|
116
|
+
* else → text.
|
|
117
|
+
*/
|
|
118
|
+
native?: boolean;
|
|
107
119
|
}
|
|
108
120
|
|
|
121
|
+
/** PanelUI variants mapped onto the platform button styles. */
|
|
122
|
+
const NATIVE_VARIANT: Record<
|
|
123
|
+
NonNullable<ButtonVariantProps['variant']>,
|
|
124
|
+
'filled' | 'outlined' | 'text'
|
|
125
|
+
> = {
|
|
126
|
+
primary: 'filled',
|
|
127
|
+
destructive: 'filled',
|
|
128
|
+
secondary: 'filled',
|
|
129
|
+
outline: 'outlined',
|
|
130
|
+
ghost: 'text',
|
|
131
|
+
social: 'outlined',
|
|
132
|
+
};
|
|
133
|
+
|
|
109
134
|
export const Button = forwardRef<View, ButtonProps>(
|
|
110
135
|
(
|
|
111
136
|
{
|
|
@@ -119,11 +144,13 @@ export const Button = forwardRef<View, ButtonProps>(
|
|
|
119
144
|
loading = false,
|
|
120
145
|
startContent,
|
|
121
146
|
endContent,
|
|
147
|
+
native,
|
|
122
148
|
...props
|
|
123
149
|
},
|
|
124
150
|
ref
|
|
125
151
|
) => {
|
|
126
152
|
const isDisabled = disabled || loading;
|
|
153
|
+
const nativeUI = native ? getNativeUI() : null;
|
|
127
154
|
const { root, label, spinner } = buttonVariants({
|
|
128
155
|
variant,
|
|
129
156
|
size,
|
|
@@ -146,6 +173,31 @@ export const Button = forwardRef<View, ButtonProps>(
|
|
|
146
173
|
? themedColor
|
|
147
174
|
: undefined;
|
|
148
175
|
|
|
176
|
+
if (nativeUI) {
|
|
177
|
+
const { Host, Button: NativeButton, RNHostView } = nativeUI;
|
|
178
|
+
const isStringLabel = typeof children === 'string';
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<Host matchContents>
|
|
182
|
+
<NativeButton
|
|
183
|
+
label={isStringLabel ? children : undefined}
|
|
184
|
+
variant={NATIVE_VARIANT[variant ?? 'primary']}
|
|
185
|
+
disabled={isDisabled}
|
|
186
|
+
onPress={props.onPress}
|
|
187
|
+
>
|
|
188
|
+
{/* Non-string children are React Native views, and the native
|
|
189
|
+
button cannot measure those directly — they have to be hosted
|
|
190
|
+
or they render outside the button's bounds. */}
|
|
191
|
+
{isStringLabel ? undefined : (
|
|
192
|
+
<RNHostView matchContents>
|
|
193
|
+
<>{children}</>
|
|
194
|
+
</RNHostView>
|
|
195
|
+
)}
|
|
196
|
+
</NativeButton>
|
|
197
|
+
</Host>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
149
201
|
return (
|
|
150
202
|
<IconColorProvider color={contentColor}>
|
|
151
203
|
<AnimatedPressable
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* EmptyState — the placeholder shown when a list or view has no content.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* positioned siblings with static rotate transforms.
|
|
4
|
+
* Centred Header / Media / Title / Description / Content, with an `icon` media
|
|
5
|
+
* variant that stacks two rotated ghost cards behind the icon. On the web that
|
|
6
|
+
* is a pair of CSS pseudo-elements; React Native has none, so it is two
|
|
7
|
+
* absolutely positioned siblings with static rotate transforms.
|
|
9
8
|
*/
|
|
10
9
|
import { forwardRef, type ReactNode } from 'react';
|
|
11
10
|
import { View, type Text as RNText, type ViewProps } from 'react-native';
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InlineSelect — a select that reveals its options next to the trigger instead
|
|
3
|
+
* of taking over the screen with a sheet.
|
|
4
|
+
*
|
|
5
|
+
* Two presentations, because the right one depends on what surrounds it:
|
|
6
|
+
*
|
|
7
|
+
* - Inline (default) expands the list in normal layout flow. Everything below
|
|
8
|
+
* moves down. Fine inside a settings list where that reads as the row
|
|
9
|
+
* growing, wrong anywhere the shift is jarring.
|
|
10
|
+
* - `overlay` floats the list above the page through a portal, anchored to the
|
|
11
|
+
* trigger and flipped above it when there is no room below. Nothing else on
|
|
12
|
+
* the screen moves.
|
|
13
|
+
*/
|
|
1
14
|
import {
|
|
2
15
|
Children,
|
|
3
16
|
createContext,
|
|
@@ -5,10 +18,17 @@ import {
|
|
|
5
18
|
useCallback,
|
|
6
19
|
useContext,
|
|
7
20
|
useMemo,
|
|
21
|
+
useRef,
|
|
8
22
|
useState,
|
|
9
23
|
type ReactNode,
|
|
10
24
|
} from 'react';
|
|
11
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
Pressable,
|
|
27
|
+
ScrollView,
|
|
28
|
+
useWindowDimensions,
|
|
29
|
+
View,
|
|
30
|
+
type LayoutChangeEvent,
|
|
31
|
+
} from 'react-native';
|
|
12
32
|
import Animated, {
|
|
13
33
|
FadeIn,
|
|
14
34
|
FadeOut,
|
|
@@ -19,6 +39,7 @@ import Animated, {
|
|
|
19
39
|
import { tv } from 'tailwind-variants';
|
|
20
40
|
import { useCSSVariable } from 'uniwind';
|
|
21
41
|
import { CheckIcon, ChevronDownIcon } from '../../icons';
|
|
42
|
+
import { Portal } from '../../primitives/portal';
|
|
22
43
|
import { Text } from '../../primitives/text';
|
|
23
44
|
|
|
24
45
|
const inlineSelectVariants = tv({
|
|
@@ -27,7 +48,7 @@ const inlineSelectVariants = tv({
|
|
|
27
48
|
'w-full flex-row items-center justify-between gap-3 rounded-2xl border border-input bg-background px-4 py-3.5',
|
|
28
49
|
triggerLabel: 'flex-1 text-base font-medium text-foreground',
|
|
29
50
|
placeholder: 'flex-1 text-base text-muted-foreground',
|
|
30
|
-
list: '
|
|
51
|
+
list: 'overflow-hidden rounded-2xl border border-border bg-popover p-2 shadow-sm',
|
|
31
52
|
item: 'flex-row items-center gap-2 rounded-xl px-3 py-3',
|
|
32
53
|
itemLabel: 'flex-1 text-base font-medium text-foreground',
|
|
33
54
|
itemIndicator: 'h-5 w-5 items-center justify-center',
|
|
@@ -39,6 +60,13 @@ const inlineSelectVariants = tv({
|
|
|
39
60
|
disabled: {
|
|
40
61
|
true: { trigger: 'opacity-[0.64]' },
|
|
41
62
|
},
|
|
63
|
+
presentation: {
|
|
64
|
+
inline: { list: 'mt-2' },
|
|
65
|
+
overlay: { list: 'shadow-lg' },
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
defaultVariants: {
|
|
69
|
+
presentation: 'inline',
|
|
42
70
|
},
|
|
43
71
|
});
|
|
44
72
|
|
|
@@ -54,7 +82,7 @@ export interface InlineSelectItemProps {
|
|
|
54
82
|
label: string;
|
|
55
83
|
}
|
|
56
84
|
|
|
57
|
-
/** Option row.
|
|
85
|
+
/** Option row. */
|
|
58
86
|
function InlineSelectItem({ value, label }: InlineSelectItemProps) {
|
|
59
87
|
const context = useContext(InlineSelectContext);
|
|
60
88
|
if (!context) {
|
|
@@ -85,29 +113,55 @@ function InlineSelectItem({ value, label }: InlineSelectItemProps) {
|
|
|
85
113
|
);
|
|
86
114
|
}
|
|
87
115
|
|
|
116
|
+
/** Trigger frame in window coordinates, measured when the list opens. */
|
|
117
|
+
interface Anchor {
|
|
118
|
+
x: number;
|
|
119
|
+
y: number;
|
|
120
|
+
width: number;
|
|
121
|
+
height: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
88
124
|
export interface InlineSelectProps {
|
|
89
125
|
className?: string;
|
|
90
126
|
value?: string;
|
|
91
127
|
onValueChange: (value: string) => void;
|
|
92
128
|
placeholder?: string;
|
|
93
129
|
disabled?: boolean;
|
|
130
|
+
/**
|
|
131
|
+
* Float the list above the page instead of expanding it in layout flow, so
|
|
132
|
+
* opening the select does not push the content below it down.
|
|
133
|
+
*/
|
|
134
|
+
overlay?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* Width of the floating list. `trigger` matches the trigger, `content` sizes
|
|
137
|
+
* to the longest option, or pass a pixel value. Ignored without `overlay`.
|
|
138
|
+
*/
|
|
139
|
+
overlayWidth?: 'trigger' | 'content' | number;
|
|
140
|
+
/** Gap between the trigger and the floating list. Ignored without `overlay`. */
|
|
141
|
+
overlayOffset?: number;
|
|
142
|
+
/** Called when the list opens or closes. */
|
|
143
|
+
onOpenChange?: (open: boolean) => void;
|
|
94
144
|
children: ReactNode;
|
|
95
145
|
}
|
|
96
146
|
|
|
97
|
-
/**
|
|
98
|
-
* A select that expands its options inline instead of opening a bottom sheet.
|
|
99
|
-
* Useful inside forms and scroll views where an overlay would be disruptive.
|
|
100
|
-
*/
|
|
101
147
|
function InlineSelectRoot({
|
|
102
148
|
className,
|
|
103
149
|
value,
|
|
104
150
|
onValueChange,
|
|
105
151
|
placeholder = 'Select an option',
|
|
106
152
|
disabled,
|
|
153
|
+
overlay = false,
|
|
154
|
+
overlayWidth = 'trigger',
|
|
155
|
+
overlayOffset = 8,
|
|
156
|
+
onOpenChange,
|
|
107
157
|
children,
|
|
108
158
|
}: InlineSelectProps) {
|
|
109
159
|
const [open, setOpen] = useState(false);
|
|
160
|
+
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
|
161
|
+
const [listHeight, setListHeight] = useState(0);
|
|
162
|
+
const triggerRef = useRef<View>(null);
|
|
110
163
|
const chevron = useSharedValue(0);
|
|
164
|
+
const { height: screenHeight } = useWindowDimensions();
|
|
111
165
|
|
|
112
166
|
const selectedLabel = useMemo(() => {
|
|
113
167
|
let label: string | undefined;
|
|
@@ -119,63 +173,156 @@ function InlineSelectRoot({
|
|
|
119
173
|
return label;
|
|
120
174
|
}, [children, value]);
|
|
121
175
|
|
|
176
|
+
const close = useCallback(() => {
|
|
177
|
+
chevron.value = withTiming(0, { duration: 160 });
|
|
178
|
+
setOpen(false);
|
|
179
|
+
onOpenChange?.(false);
|
|
180
|
+
}, [chevron, onOpenChange]);
|
|
181
|
+
|
|
122
182
|
const toggle = useCallback(() => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return
|
|
183
|
+
if (open) {
|
|
184
|
+
close();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const show = () => {
|
|
189
|
+
chevron.value = withTiming(1, { duration: 160 });
|
|
190
|
+
setOpen(true);
|
|
191
|
+
onOpenChange?.(true);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
if (!overlay) {
|
|
195
|
+
show();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// The list is positioned in window coordinates, so it has to know where
|
|
200
|
+
// the trigger actually landed — not where layout said it would.
|
|
201
|
+
triggerRef.current?.measureInWindow((x, y, width, height) => {
|
|
202
|
+
setAnchor({ x, y, width, height });
|
|
203
|
+
show();
|
|
126
204
|
});
|
|
127
|
-
}, [chevron]);
|
|
205
|
+
}, [chevron, close, onOpenChange, open, overlay]);
|
|
128
206
|
|
|
129
207
|
const context = useMemo<InlineSelectContextValue>(
|
|
130
208
|
() => ({
|
|
131
209
|
value,
|
|
132
210
|
onSelect: (next) => {
|
|
133
211
|
onValueChange(next);
|
|
134
|
-
|
|
135
|
-
setOpen(false);
|
|
212
|
+
close();
|
|
136
213
|
},
|
|
137
214
|
}),
|
|
138
|
-
[value, onValueChange,
|
|
215
|
+
[value, onValueChange, close]
|
|
139
216
|
);
|
|
140
217
|
|
|
141
|
-
const slots = inlineSelectVariants({
|
|
218
|
+
const slots = inlineSelectVariants({
|
|
219
|
+
disabled: !!disabled,
|
|
220
|
+
presentation: overlay ? 'overlay' : 'inline',
|
|
221
|
+
});
|
|
142
222
|
const chevronColor = useCSSVariable('--color-muted-foreground');
|
|
143
223
|
|
|
144
224
|
const chevronStyle = useAnimatedStyle(() => ({
|
|
145
225
|
transform: [{ rotate: `${chevron.value * 180}deg` }],
|
|
146
226
|
}));
|
|
147
227
|
|
|
228
|
+
const trigger = (
|
|
229
|
+
<Pressable
|
|
230
|
+
ref={triggerRef}
|
|
231
|
+
accessibilityRole="button"
|
|
232
|
+
accessibilityState={{ disabled: !!disabled, expanded: open }}
|
|
233
|
+
disabled={disabled}
|
|
234
|
+
onPress={toggle}
|
|
235
|
+
className={slots.trigger()}
|
|
236
|
+
>
|
|
237
|
+
{selectedLabel ? (
|
|
238
|
+
<Text className={slots.triggerLabel()}>{selectedLabel}</Text>
|
|
239
|
+
) : (
|
|
240
|
+
<Text className={slots.placeholder()}>{placeholder}</Text>
|
|
241
|
+
)}
|
|
242
|
+
<Animated.View style={chevronStyle}>
|
|
243
|
+
<ChevronDownIcon
|
|
244
|
+
color={typeof chevronColor === 'string' ? chevronColor : '#737373'}
|
|
245
|
+
/>
|
|
246
|
+
</Animated.View>
|
|
247
|
+
</Pressable>
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
if (!overlay) {
|
|
251
|
+
return (
|
|
252
|
+
<InlineSelectContext.Provider value={context}>
|
|
253
|
+
<View className={className}>
|
|
254
|
+
{trigger}
|
|
255
|
+
{open ? (
|
|
256
|
+
<Animated.View
|
|
257
|
+
entering={FadeIn.duration(140)}
|
|
258
|
+
exiting={FadeOut.duration(120)}
|
|
259
|
+
className={slots.list()}
|
|
260
|
+
>
|
|
261
|
+
{children}
|
|
262
|
+
</Animated.View>
|
|
263
|
+
) : null}
|
|
264
|
+
</View>
|
|
265
|
+
</InlineSelectContext.Provider>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Flip above the trigger when the list would run off the bottom. listHeight
|
|
270
|
+
// is 0 on the first frame, so the list opens downwards and corrects itself
|
|
271
|
+
// once measured — which is invisible inside the 140ms fade.
|
|
272
|
+
const spaceBelow = anchor
|
|
273
|
+
? screenHeight - (anchor.y + anchor.height) - overlayOffset
|
|
274
|
+
: 0;
|
|
275
|
+
const flip = !!anchor && listHeight > 0 && listHeight > spaceBelow;
|
|
276
|
+
|
|
277
|
+
const overlayPosition = anchor
|
|
278
|
+
? {
|
|
279
|
+
position: 'absolute' as const,
|
|
280
|
+
left: anchor.x,
|
|
281
|
+
...(flip
|
|
282
|
+
? { bottom: screenHeight - anchor.y + overlayOffset }
|
|
283
|
+
: { top: anchor.y + anchor.height + overlayOffset }),
|
|
284
|
+
...(overlayWidth === 'trigger'
|
|
285
|
+
? { width: anchor.width }
|
|
286
|
+
: typeof overlayWidth === 'number'
|
|
287
|
+
? { width: overlayWidth }
|
|
288
|
+
: { minWidth: anchor.width }),
|
|
289
|
+
// Never collapse to nothing in a cramped viewport — the list scrolls.
|
|
290
|
+
maxHeight: Math.max((flip ? anchor.y : spaceBelow) - overlayOffset, 160),
|
|
291
|
+
}
|
|
292
|
+
: null;
|
|
293
|
+
|
|
294
|
+
const onListLayout = (event: LayoutChangeEvent) => {
|
|
295
|
+
setListHeight(event.nativeEvent.layout.height);
|
|
296
|
+
};
|
|
297
|
+
|
|
148
298
|
return (
|
|
149
299
|
<InlineSelectContext.Provider value={context}>
|
|
150
|
-
<View className={className}>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
</Animated.View>
|
|
177
|
-
) : null}
|
|
178
|
-
</View>
|
|
300
|
+
<View className={className}>{trigger}</View>
|
|
301
|
+
|
|
302
|
+
{open && overlayPosition ? (
|
|
303
|
+
<Portal>
|
|
304
|
+
{/* Full-screen catcher so a press anywhere else dismisses the list. */}
|
|
305
|
+
<Pressable
|
|
306
|
+
accessibilityRole="button"
|
|
307
|
+
accessibilityLabel="Close"
|
|
308
|
+
onPress={close}
|
|
309
|
+
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
|
|
310
|
+
/>
|
|
311
|
+
<InlineSelectContext.Provider value={context}>
|
|
312
|
+
<Animated.View
|
|
313
|
+
entering={FadeIn.duration(140)}
|
|
314
|
+
exiting={FadeOut.duration(120)}
|
|
315
|
+
onLayout={onListLayout}
|
|
316
|
+
style={overlayPosition}
|
|
317
|
+
className={slots.list()}
|
|
318
|
+
>
|
|
319
|
+
<ScrollView bounces={false} showsVerticalScrollIndicator={false}>
|
|
320
|
+
{children}
|
|
321
|
+
</ScrollView>
|
|
322
|
+
</Animated.View>
|
|
323
|
+
</InlineSelectContext.Provider>
|
|
324
|
+
</Portal>
|
|
325
|
+
) : null}
|
|
179
326
|
</InlineSelectContext.Provider>
|
|
180
327
|
);
|
|
181
328
|
}
|
|
@@ -2,6 +2,7 @@ import { forwardRef, useCallback, useState } from 'react';
|
|
|
2
2
|
import { TextInput, View, type TextInputProps } from 'react-native';
|
|
3
3
|
import { tv } from 'tailwind-variants';
|
|
4
4
|
import { useCSSVariable } from 'uniwind';
|
|
5
|
+
import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
|
|
5
6
|
import { Text } from '../../primitives/text';
|
|
6
7
|
|
|
7
8
|
const inputVariants = tv({
|
|
@@ -34,6 +35,19 @@ export interface InputProps extends TextInputProps {
|
|
|
34
35
|
/** Error message. When set, the field renders in its invalid state. */
|
|
35
36
|
errorMessage?: string;
|
|
36
37
|
disabled?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Lift the field above the software keyboard when it would otherwise be
|
|
40
|
+
* covered. Moves by exactly the overlap, and not at all when the field is
|
|
41
|
+
* already clear.
|
|
42
|
+
*
|
|
43
|
+
* Install `react-native-keyboard-controller` for this to behave on Android.
|
|
44
|
+
*
|
|
45
|
+
* Do not toggle this at runtime — it changes which component renders the
|
|
46
|
+
* container, which would remount the field and drop focus.
|
|
47
|
+
*/
|
|
48
|
+
avoidKeyboard?: boolean;
|
|
49
|
+
/** Gap kept between the field and the keyboard when `avoidKeyboard` is set. */
|
|
50
|
+
keyboardOffset?: number;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
export const Input = forwardRef<TextInput, InputProps>(
|
|
@@ -45,6 +59,8 @@ export const Input = forwardRef<TextInput, InputProps>(
|
|
|
45
59
|
description,
|
|
46
60
|
errorMessage,
|
|
47
61
|
disabled,
|
|
62
|
+
avoidKeyboard = false,
|
|
63
|
+
keyboardOffset = 16,
|
|
48
64
|
onFocus,
|
|
49
65
|
onBlur,
|
|
50
66
|
...props
|
|
@@ -75,8 +91,8 @@ export const Input = forwardRef<TextInput, InputProps>(
|
|
|
75
91
|
[onBlur]
|
|
76
92
|
);
|
|
77
93
|
|
|
78
|
-
|
|
79
|
-
|
|
94
|
+
const body = (
|
|
95
|
+
<>
|
|
80
96
|
{label ? <Text className={slots.label()}>{label}</Text> : null}
|
|
81
97
|
<TextInput
|
|
82
98
|
ref={ref}
|
|
@@ -96,8 +112,28 @@ export const Input = forwardRef<TextInput, InputProps>(
|
|
|
96
112
|
) : description ? (
|
|
97
113
|
<Text className={slots.description()}>{description}</Text>
|
|
98
114
|
) : null}
|
|
99
|
-
|
|
115
|
+
</>
|
|
100
116
|
);
|
|
117
|
+
|
|
118
|
+
const containerClasses = slots.container({ className: containerClassName });
|
|
119
|
+
|
|
120
|
+
/*
|
|
121
|
+
* The keyboard hook is deliberately behind a component boundary rather
|
|
122
|
+
* than an `enabled` flag. Calling it at all has global consequences —
|
|
123
|
+
* without the keyboard controller installed it falls back to Reanimated's
|
|
124
|
+
* useAnimatedKeyboard, which switches Android out of adjustResize for the
|
|
125
|
+
* whole app. A field that never asked to avoid the keyboard must not do
|
|
126
|
+
* that to every other screen.
|
|
127
|
+
*/
|
|
128
|
+
if (avoidKeyboard) {
|
|
129
|
+
return (
|
|
130
|
+
<KeyboardAvoider offset={keyboardOffset} className={containerClasses}>
|
|
131
|
+
{body}
|
|
132
|
+
</KeyboardAvoider>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return <View className={containerClasses}>{body}</View>;
|
|
101
137
|
}
|
|
102
138
|
);
|
|
103
139
|
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* InputGroup — an Input with leading and/or trailing decorators.
|
|
3
3
|
*
|
|
4
|
-
* Adapted from: heroui-inc/heroui-native src/components/input-group/
|
|
5
4
|
* Prefix and Suffix are absolutely positioned and their measured widths become
|
|
6
5
|
* padding on the Input, so text never runs underneath a decorator regardless
|
|
7
6
|
* of how wide that decorator turns out to be.
|