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.
Files changed (156) hide show
  1. package/README.md +9 -5
  2. package/lib/module/components/accordion/index.js +7 -5
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/alert/index.js +3 -6
  5. package/lib/module/components/alert/index.js.map +1 -1
  6. package/lib/module/components/bottom-sheet/index.js +67 -3
  7. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  8. package/lib/module/components/button/index.js +36 -1
  9. package/lib/module/components/button/index.js.map +1 -1
  10. package/lib/module/components/empty-state/index.js +4 -5
  11. package/lib/module/components/empty-state/index.js.map +1 -1
  12. package/lib/module/components/inline-select/index.js +171 -46
  13. package/lib/module/components/inline-select/index.js.map +1 -1
  14. package/lib/module/components/input/index.js +28 -5
  15. package/lib/module/components/input/index.js.map +1 -1
  16. package/lib/module/components/input-group/index.js +0 -1
  17. package/lib/module/components/input-group/index.js.map +1 -1
  18. package/lib/module/components/item/index.js +359 -0
  19. package/lib/module/components/item/index.js.map +1 -0
  20. package/lib/module/components/label/index.js +5 -3
  21. package/lib/module/components/label/index.js.map +1 -1
  22. package/lib/module/components/message/index.js +318 -0
  23. package/lib/module/components/message/index.js.map +1 -0
  24. package/lib/module/components/scroll-fade/index.js +204 -0
  25. package/lib/module/components/scroll-fade/index.js.map +1 -0
  26. package/lib/module/components/select/index.js +35 -6
  27. package/lib/module/components/select/index.js.map +1 -1
  28. package/lib/module/components/shimmer/index.js +167 -0
  29. package/lib/module/components/shimmer/index.js.map +1 -0
  30. package/lib/module/components/surface/index.js +65 -0
  31. package/lib/module/components/surface/index.js.map +1 -0
  32. package/lib/module/components/switch/index.js +20 -1
  33. package/lib/module/components/switch/index.js.map +1 -1
  34. package/lib/module/components/toast/index.js +7 -8
  35. package/lib/module/components/toast/index.js.map +1 -1
  36. package/lib/module/components/typography/index.js +3 -3
  37. package/lib/module/hooks/index.js +10 -0
  38. package/lib/module/hooks/index.js.map +1 -0
  39. package/lib/module/hooks/use-breakpoint.js +39 -0
  40. package/lib/module/hooks/use-breakpoint.js.map +1 -0
  41. package/lib/module/hooks/use-copy-to-clipboard.js +60 -0
  42. package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -0
  43. package/lib/module/hooks/use-debounced-value.js +24 -0
  44. package/lib/module/hooks/use-debounced-value.js.map +1 -0
  45. package/lib/module/hooks/use-disclosure.js +43 -0
  46. package/lib/module/hooks/use-disclosure.js.map +1 -0
  47. package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
  48. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
  49. package/lib/module/hooks/use-keyboard.js +36 -0
  50. package/lib/module/hooks/use-keyboard.js.map +1 -0
  51. package/lib/module/hooks/use-previous.js +25 -0
  52. package/lib/module/hooks/use-previous.js.map +1 -0
  53. package/lib/module/icons/index.js +4 -4
  54. package/lib/module/index.js +12 -0
  55. package/lib/module/index.js.map +1 -1
  56. package/lib/module/native/index.js +53 -0
  57. package/lib/module/native/index.js.map +1 -0
  58. package/lib/module/primitives/keyboard-avoider.js +40 -0
  59. package/lib/module/primitives/keyboard-avoider.js.map +1 -0
  60. package/lib/module/providers/panel-ui-provider.js +24 -4
  61. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  62. package/lib/module/theme/use-theme.js +1 -1
  63. package/lib/typescript/src/components/accordion/index.d.ts +7 -5
  64. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  65. package/lib/typescript/src/components/alert/index.d.ts +3 -6
  66. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/bottom-sheet/index.d.ts +21 -1
  68. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/button/index.d.ts +11 -0
  70. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/empty-state/index.d.ts +4 -5
  72. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  73. package/lib/typescript/src/components/inline-select/index.d.ts +29 -6
  74. package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/input/index.d.ts +13 -0
  76. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/input-group/index.d.ts +0 -1
  78. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  79. package/lib/typescript/src/components/item/index.d.ts +286 -0
  80. package/lib/typescript/src/components/item/index.d.ts.map +1 -0
  81. package/lib/typescript/src/components/label/index.d.ts +5 -3
  82. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/message/index.d.ts +170 -0
  84. package/lib/typescript/src/components/message/index.d.ts.map +1 -0
  85. package/lib/typescript/src/components/scroll-fade/index.d.ts +47 -0
  86. package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -0
  87. package/lib/typescript/src/components/select/index.d.ts +16 -1
  88. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/shimmer/index.d.ts +60 -0
  90. package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -0
  91. package/lib/typescript/src/components/surface/index.d.ts +30 -0
  92. package/lib/typescript/src/components/surface/index.d.ts.map +1 -0
  93. package/lib/typescript/src/components/switch/index.d.ts +10 -0
  94. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/toast/index.d.ts +4 -4
  96. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  97. package/lib/typescript/src/hooks/index.d.ts +8 -0
  98. package/lib/typescript/src/hooks/index.d.ts.map +1 -0
  99. package/lib/typescript/src/hooks/use-breakpoint.d.ts +32 -0
  100. package/lib/typescript/src/hooks/use-breakpoint.d.ts.map +1 -0
  101. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +28 -0
  102. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -0
  103. package/lib/typescript/src/hooks/use-debounced-value.d.ts +13 -0
  104. package/lib/typescript/src/hooks/use-debounced-value.d.ts.map +1 -0
  105. package/lib/typescript/src/hooks/use-disclosure.d.ts +32 -0
  106. package/lib/typescript/src/hooks/use-disclosure.d.ts.map +1 -0
  107. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +20 -0
  108. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -0
  109. package/lib/typescript/src/hooks/use-keyboard.d.ts +20 -0
  110. package/lib/typescript/src/hooks/use-keyboard.d.ts.map +1 -0
  111. package/lib/typescript/src/hooks/use-previous.d.ts +15 -0
  112. package/lib/typescript/src/hooks/use-previous.d.ts.map +1 -0
  113. package/lib/typescript/src/icons/index.d.ts +4 -4
  114. package/lib/typescript/src/index.d.ts +8 -0
  115. package/lib/typescript/src/index.d.ts.map +1 -1
  116. package/lib/typescript/src/native/index.d.ts +35 -0
  117. package/lib/typescript/src/native/index.d.ts.map +1 -0
  118. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +22 -0
  119. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -0
  120. package/lib/typescript/src/providers/panel-ui-provider.d.ts +1 -1
  121. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  122. package/lib/typescript/src/theme/use-theme.d.ts +1 -1
  123. package/package.json +19 -3
  124. package/src/components/accordion/index.tsx +7 -5
  125. package/src/components/alert/index.tsx +3 -6
  126. package/src/components/bottom-sheet/index.tsx +82 -2
  127. package/src/components/button/index.tsx +52 -0
  128. package/src/components/empty-state/index.tsx +4 -5
  129. package/src/components/inline-select/index.tsx +191 -44
  130. package/src/components/input/index.tsx +39 -3
  131. package/src/components/input-group/index.tsx +0 -1
  132. package/src/components/item/index.tsx +387 -0
  133. package/src/components/label/index.tsx +5 -3
  134. package/src/components/message/index.tsx +312 -0
  135. package/src/components/scroll-fade/index.tsx +280 -0
  136. package/src/components/select/index.tsx +54 -9
  137. package/src/components/shimmer/index.tsx +228 -0
  138. package/src/components/surface/index.tsx +64 -0
  139. package/src/components/switch/index.tsx +27 -1
  140. package/src/components/toast/index.tsx +7 -8
  141. package/src/components/typography/index.tsx +3 -3
  142. package/src/hooks/index.ts +24 -0
  143. package/src/hooks/use-breakpoint.ts +52 -0
  144. package/src/hooks/use-copy-to-clipboard.ts +79 -0
  145. package/src/hooks/use-debounced-value.ts +23 -0
  146. package/src/hooks/use-disclosure.ts +57 -0
  147. package/src/hooks/use-keyboard-avoidance.ts +140 -0
  148. package/src/hooks/use-keyboard.ts +43 -0
  149. package/src/hooks/use-previous.ts +24 -0
  150. package/src/icons/index.tsx +4 -4
  151. package/src/index.ts +41 -0
  152. package/src/native/index.ts +79 -0
  153. package/src/primitives/keyboard-avoider.tsx +49 -0
  154. package/src/providers/panel-ui-provider.tsx +30 -9
  155. package/src/theme/use-theme.ts +1 -1
  156. package/theme.css +23 -3
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Accordion — collapsible sections.
3
3
  *
4
- * Adapted from: heroui-inc/heroui-native src/components/accordion/
5
- * (the Item / Trigger / Indicator / Content anatomy, single vs multiple
6
- * selection, the rotating chevron indicator, and layout-transition expand).
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
- * Upstream ships `default` and `surface`; the extra variants here follow the
9
- * same slot structure.
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
- * Adapted from: heroui-inc/heroui-native src/components/alert/alert.tsx
5
- * (Indicator / Content / Title / Description anatomy, status-driven icon and
6
- * title colour), restyled with Coss UI tokens.
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
- {children}
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 { height: screenHeight } = useWindowDimensions();
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
- * Adapted from: cosscom/coss packages/ui/src/components/empty.tsx
5
- * (centered Header / Media / Title / Description / Content anatomy, and the
6
- * `icon` media variant that stacks two rotated ghost cards behind the icon).
7
- * Coss does that with CSS pseudo-elements; on native it is two absolutely
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 { Pressable, View } from 'react-native';
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: 'mt-2 overflow-hidden rounded-2xl border border-border bg-popover p-2 shadow-sm',
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. Rendered inline beneath the trigger — no overlay involved. */
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
- setOpen((current) => {
124
- chevron.value = withTiming(current ? 0 : 1, { duration: 160 });
125
- return !current;
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
- chevron.value = withTiming(0, { duration: 160 });
135
- setOpen(false);
212
+ close();
136
213
  },
137
214
  }),
138
- [value, onValueChange, chevron]
215
+ [value, onValueChange, close]
139
216
  );
140
217
 
141
- const slots = inlineSelectVariants({ disabled: !!disabled });
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
- <Pressable
152
- accessibilityRole="button"
153
- accessibilityState={{ disabled: !!disabled, expanded: open }}
154
- disabled={disabled}
155
- onPress={toggle}
156
- className={slots.trigger()}
157
- >
158
- {selectedLabel ? (
159
- <Text className={slots.triggerLabel()}>{selectedLabel}</Text>
160
- ) : (
161
- <Text className={slots.placeholder()}>{placeholder}</Text>
162
- )}
163
- <Animated.View style={chevronStyle}>
164
- <ChevronDownIcon
165
- color={typeof chevronColor === 'string' ? chevronColor : '#737373'}
166
- />
167
- </Animated.View>
168
- </Pressable>
169
- {open ? (
170
- <Animated.View
171
- entering={FadeIn.duration(140)}
172
- exiting={FadeOut.duration(120)}
173
- className={slots.list()}
174
- >
175
- {children}
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
- return (
79
- <View className={slots.container({ className: containerClassName })}>
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
- </View>
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.