panelui-native 0.6.0 → 0.9.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 (181) hide show
  1. package/README.md +1 -1
  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/attachment/index.js +219 -0
  7. package/lib/module/components/attachment/index.js.map +1 -0
  8. package/lib/module/components/bottom-sheet/index.js +82 -4
  9. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  10. package/lib/module/components/button/index.js +36 -1
  11. package/lib/module/components/button/index.js.map +1 -1
  12. package/lib/module/components/dialog/index.js +6 -2
  13. package/lib/module/components/dialog/index.js.map +1 -1
  14. package/lib/module/components/empty-state/index.js +98 -22
  15. package/lib/module/components/empty-state/index.js.map +1 -1
  16. package/lib/module/components/inline-select/index.js +171 -46
  17. package/lib/module/components/inline-select/index.js.map +1 -1
  18. package/lib/module/components/input/index.js +28 -5
  19. package/lib/module/components/input/index.js.map +1 -1
  20. package/lib/module/components/input-group/index.js +0 -1
  21. package/lib/module/components/input-group/index.js.map +1 -1
  22. package/lib/module/components/item/index.js +359 -0
  23. package/lib/module/components/item/index.js.map +1 -0
  24. package/lib/module/components/label/index.js +5 -3
  25. package/lib/module/components/label/index.js.map +1 -1
  26. package/lib/module/components/line-chart/index.js +1024 -0
  27. package/lib/module/components/line-chart/index.js.map +1 -0
  28. package/lib/module/components/marker/index.js +190 -0
  29. package/lib/module/components/marker/index.js.map +1 -0
  30. package/lib/module/components/message/index.js +334 -0
  31. package/lib/module/components/message/index.js.map +1 -0
  32. package/lib/module/components/message-scroller/index.js +451 -0
  33. package/lib/module/components/message-scroller/index.js.map +1 -0
  34. package/lib/module/components/popover/index.js +500 -0
  35. package/lib/module/components/popover/index.js.map +1 -0
  36. package/lib/module/components/radio-group/index.js +104 -16
  37. package/lib/module/components/radio-group/index.js.map +1 -1
  38. package/lib/module/components/scroll-fade/index.js +119 -71
  39. package/lib/module/components/scroll-fade/index.js.map +1 -1
  40. package/lib/module/components/select/index.js +35 -6
  41. package/lib/module/components/select/index.js.map +1 -1
  42. package/lib/module/components/separator/index.js +91 -0
  43. package/lib/module/components/separator/index.js.map +1 -0
  44. package/lib/module/components/shimmer/index.js +111 -64
  45. package/lib/module/components/shimmer/index.js.map +1 -1
  46. package/lib/module/components/surface/index.js +24 -4
  47. package/lib/module/components/surface/index.js.map +1 -1
  48. package/lib/module/components/switch/index.js +20 -1
  49. package/lib/module/components/switch/index.js.map +1 -1
  50. package/lib/module/components/toast/index.js +7 -8
  51. package/lib/module/components/toast/index.js.map +1 -1
  52. package/lib/module/components/typography/index.js +3 -3
  53. package/lib/module/hooks/index.js +1 -0
  54. package/lib/module/hooks/index.js.map +1 -1
  55. package/lib/module/hooks/use-breakpoint.js +2 -2
  56. package/lib/module/hooks/use-copy-to-clipboard.js +0 -2
  57. package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -1
  58. package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
  59. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
  60. package/lib/module/icons/index.js +73 -4
  61. package/lib/module/icons/index.js.map +1 -1
  62. package/lib/module/index.js +13 -1
  63. package/lib/module/index.js.map +1 -1
  64. package/lib/module/native/index.js +53 -0
  65. package/lib/module/native/index.js.map +1 -0
  66. package/lib/module/primitives/keyboard-avoider.js +40 -0
  67. package/lib/module/primitives/keyboard-avoider.js.map +1 -0
  68. package/lib/module/primitives/scrim.js +72 -0
  69. package/lib/module/primitives/scrim.js.map +1 -0
  70. package/lib/module/providers/panel-ui-provider.js +24 -4
  71. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  72. package/lib/module/theme/use-theme.js +1 -1
  73. package/lib/typescript/src/components/accordion/index.d.ts +7 -5
  74. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/alert/index.d.ts +3 -6
  76. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/attachment/index.d.ts +93 -0
  78. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -0
  79. package/lib/typescript/src/components/bottom-sheet/index.d.ts +27 -2
  80. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  81. package/lib/typescript/src/components/button/index.d.ts +11 -0
  82. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/dialog/index.d.ts +7 -1
  84. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  85. package/lib/typescript/src/components/empty-state/index.d.ts +156 -7
  86. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  87. package/lib/typescript/src/components/inline-select/index.d.ts +29 -6
  88. package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/input/index.d.ts +13 -0
  90. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  91. package/lib/typescript/src/components/input-group/index.d.ts +0 -1
  92. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/item/index.d.ts +286 -0
  94. package/lib/typescript/src/components/item/index.d.ts.map +1 -0
  95. package/lib/typescript/src/components/label/index.d.ts +5 -3
  96. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  97. package/lib/typescript/src/components/line-chart/index.d.ts +247 -0
  98. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -0
  99. package/lib/typescript/src/components/marker/index.d.ts +137 -0
  100. package/lib/typescript/src/components/marker/index.d.ts.map +1 -0
  101. package/lib/typescript/src/components/message/index.d.ts +177 -0
  102. package/lib/typescript/src/components/message/index.d.ts.map +1 -0
  103. package/lib/typescript/src/components/message-scroller/index.d.ts +150 -0
  104. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -0
  105. package/lib/typescript/src/components/popover/index.d.ts +130 -0
  106. package/lib/typescript/src/components/popover/index.d.ts.map +1 -0
  107. package/lib/typescript/src/components/radio-group/index.d.ts +32 -0
  108. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  109. package/lib/typescript/src/components/scroll-fade/index.d.ts +18 -9
  110. package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -1
  111. package/lib/typescript/src/components/select/index.d.ts +16 -1
  112. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  113. package/lib/typescript/src/components/separator/index.d.ts +47 -0
  114. package/lib/typescript/src/components/separator/index.d.ts.map +1 -0
  115. package/lib/typescript/src/components/shimmer/index.d.ts +38 -20
  116. package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
  117. package/lib/typescript/src/components/surface/index.d.ts +51 -2
  118. package/lib/typescript/src/components/surface/index.d.ts.map +1 -1
  119. package/lib/typescript/src/components/switch/index.d.ts +10 -0
  120. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  121. package/lib/typescript/src/components/toast/index.d.ts +4 -4
  122. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  123. package/lib/typescript/src/hooks/index.d.ts +1 -0
  124. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  125. package/lib/typescript/src/hooks/use-breakpoint.d.ts +2 -2
  126. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +0 -2
  127. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -1
  128. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +20 -0
  129. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -0
  130. package/lib/typescript/src/icons/index.d.ts +10 -4
  131. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  132. package/lib/typescript/src/index.d.ts +11 -1
  133. package/lib/typescript/src/index.d.ts.map +1 -1
  134. package/lib/typescript/src/native/index.d.ts +35 -0
  135. package/lib/typescript/src/native/index.d.ts.map +1 -0
  136. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +22 -0
  137. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -0
  138. package/lib/typescript/src/primitives/scrim.d.ts +21 -0
  139. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -0
  140. package/lib/typescript/src/providers/panel-ui-provider.d.ts +1 -1
  141. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  142. package/lib/typescript/src/theme/use-theme.d.ts +1 -1
  143. package/package.json +16 -3
  144. package/src/components/accordion/index.tsx +7 -5
  145. package/src/components/alert/index.tsx +3 -6
  146. package/src/components/attachment/index.tsx +279 -0
  147. package/src/components/bottom-sheet/index.tsx +102 -2
  148. package/src/components/button/index.tsx +52 -0
  149. package/src/components/dialog/index.tsx +10 -1
  150. package/src/components/empty-state/index.tsx +64 -19
  151. package/src/components/inline-select/index.tsx +191 -44
  152. package/src/components/input/index.tsx +39 -3
  153. package/src/components/input-group/index.tsx +0 -1
  154. package/src/components/item/index.tsx +387 -0
  155. package/src/components/label/index.tsx +5 -3
  156. package/src/components/line-chart/index.tsx +1224 -0
  157. package/src/components/marker/index.tsx +194 -0
  158. package/src/components/message/index.tsx +336 -0
  159. package/src/components/message-scroller/index.tsx +574 -0
  160. package/src/components/popover/index.tsx +599 -0
  161. package/src/components/radio-group/index.tsx +108 -17
  162. package/src/components/scroll-fade/index.tsx +163 -51
  163. package/src/components/select/index.tsx +54 -9
  164. package/src/components/separator/index.tsx +97 -0
  165. package/src/components/shimmer/index.tsx +151 -58
  166. package/src/components/surface/index.tsx +32 -5
  167. package/src/components/switch/index.tsx +27 -1
  168. package/src/components/toast/index.tsx +7 -8
  169. package/src/components/typography/index.tsx +3 -3
  170. package/src/hooks/index.ts +5 -0
  171. package/src/hooks/use-breakpoint.ts +2 -2
  172. package/src/hooks/use-copy-to-clipboard.ts +0 -2
  173. package/src/hooks/use-keyboard-avoidance.ts +140 -0
  174. package/src/icons/index.tsx +52 -4
  175. package/src/index.ts +92 -0
  176. package/src/native/index.ts +79 -0
  177. package/src/primitives/keyboard-avoider.tsx +49 -0
  178. package/src/primitives/scrim.tsx +95 -0
  179. package/src/providers/panel-ui-provider.tsx +30 -9
  180. package/src/theme/use-theme.ts +1 -1
  181. package/theme.css +63 -3
@@ -13,6 +13,7 @@ import {
13
13
  import { Pressable, View, type ViewProps } from 'react-native';
14
14
  import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';
15
15
  import { Portal } from '../../primitives/portal';
16
+ import { Scrim } from '../../primitives/scrim';
16
17
  import { Text, type TextProps } from '../../primitives/text';
17
18
  import { cn } from '../../utils/cn';
18
19
 
@@ -82,12 +83,19 @@ export interface DialogContentProps extends ViewProps {
82
83
  className?: string;
83
84
  /** Tap on the backdrop closes the dialog. Default true. */
84
85
  dismissible?: boolean;
86
+ /**
87
+ * Frost the screen behind the dialog instead of dimming it. Uses `expo-blur`
88
+ * when installed and falls back to the dim when it is not, so it is safe to
89
+ * pass either way.
90
+ */
91
+ blur?: boolean;
85
92
  children?: ReactNode;
86
93
  }
87
94
 
88
95
  function DialogContent({
89
96
  className,
90
97
  dismissible = true,
98
+ blur = false,
91
99
  children,
92
100
  ...props
93
101
  }: DialogContentProps) {
@@ -106,9 +114,10 @@ function DialogContent({
106
114
  exiting={FadeOut.duration(150)}
107
115
  className="absolute inset-0 items-center justify-center p-6"
108
116
  >
117
+ <Scrim blur={blur} />
109
118
  <Pressable
110
119
  accessibilityLabel="Close dialog"
111
- className="absolute inset-0 bg-black/50"
120
+ className="absolute inset-0"
112
121
  onPress={dismissible ? () => setOpen(false) : undefined}
113
122
  />
114
123
  <Animated.View
@@ -1,32 +1,54 @@
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
- import { forwardRef, type ReactNode } from 'react';
9
+ import { createContext, forwardRef, useContext, type ReactNode } from 'react';
11
10
  import { View, type Text as RNText, type ViewProps } from 'react-native';
12
11
  import { tv, type VariantProps } from 'tailwind-variants';
13
12
  import { Text, type TextProps } from '../../primitives/text';
14
13
 
14
+ type EmptyStateSize = 'sm' | 'default' | 'lg';
15
+
16
+ /** Sub-components read the size so the title, description and media scale with the root. */
17
+ const EmptyStateContext = createContext<{ size: EmptyStateSize }>({ size: 'default' });
18
+
15
19
  const emptyStateVariants = tv({
16
20
  slots: {
17
- root: 'w-full flex-1 items-center justify-center gap-6 px-6 py-12',
21
+ root: 'w-full items-center justify-center',
18
22
  header: 'w-full max-w-sm items-center gap-1',
19
- title: 'text-center text-xl font-semibold text-foreground',
20
- description: 'text-center text-sm text-muted-foreground',
23
+ title: 'text-center font-semibold text-foreground',
24
+ description: 'text-center text-muted-foreground',
21
25
  content: 'w-full max-w-sm items-center gap-3',
22
26
  },
27
+ variants: {
28
+ variant: {
29
+ // Fills whatever space it is given — a whole screen, or a flex parent.
30
+ default: { root: 'flex-1' },
31
+ // A self-contained block that sits inside content rather than filling a
32
+ // screen: bordered, on the card fill, sized to its contents.
33
+ card: { root: 'rounded-2xl border border-border bg-card' },
34
+ },
35
+ size: {
36
+ sm: { root: 'gap-4 px-4 py-8', title: 'text-base', description: 'text-sm' },
37
+ default: { root: 'gap-6 px-6 py-12', title: 'text-xl', description: 'text-sm' },
38
+ lg: { root: 'gap-6 px-6 py-16', title: 'text-2xl', description: 'text-base' },
39
+ },
40
+ },
41
+ defaultVariants: {
42
+ variant: 'default',
43
+ size: 'default',
44
+ },
23
45
  });
24
46
 
25
47
  const mediaVariants = tv({
26
48
  slots: {
27
49
  wrapper: 'items-center justify-center',
28
- card: 'h-9 w-9 shrink-0 items-center justify-center',
29
- ghost: 'absolute h-9 w-9 rounded-md border border-border bg-card',
50
+ card: 'shrink-0 items-center justify-center',
51
+ ghost: 'absolute rounded-md border border-border bg-card',
30
52
  },
31
53
  variants: {
32
54
  variant: {
@@ -38,21 +60,41 @@ const mediaVariants = tv({
38
60
  card: 'rounded-md border border-border bg-card',
39
61
  },
40
62
  },
63
+ size: {
64
+ sm: { card: 'h-8 w-8', ghost: 'h-8 w-8' },
65
+ default: { card: 'h-9 w-9', ghost: 'h-9 w-9' },
66
+ lg: { card: 'h-11 w-11', ghost: 'h-11 w-11' },
67
+ },
41
68
  },
42
69
  defaultVariants: {
43
70
  variant: 'default',
71
+ size: 'default',
44
72
  },
45
73
  });
46
74
 
47
- export interface EmptyStateProps extends ViewProps {
75
+ export interface EmptyStateProps
76
+ extends ViewProps,
77
+ VariantProps<typeof emptyStateVariants> {
48
78
  className?: string;
79
+ /**
80
+ * `default` fills its parent — a whole screen, or a flex slot. `card` is a
81
+ * self-contained bordered block for an empty state that sits inside content
82
+ * rather than owning the screen.
83
+ */
84
+ variant?: 'default' | 'card';
85
+ /** Scales the padding, media, title and description together. */
86
+ size?: EmptyStateSize;
49
87
  children?: ReactNode;
50
88
  }
51
89
 
52
90
  const EmptyStateRoot = forwardRef<View, EmptyStateProps>(
53
- ({ className, ...props }, ref) => {
54
- const { root } = emptyStateVariants();
55
- return <View ref={ref} className={root({ className })} {...props} />;
91
+ ({ className, variant, size = 'default', ...props }, ref) => {
92
+ const { root } = emptyStateVariants({ variant, size });
93
+ return (
94
+ <EmptyStateContext.Provider value={{ size }}>
95
+ <View ref={ref} className={root({ className })} {...props} />
96
+ </EmptyStateContext.Provider>
97
+ );
56
98
  }
57
99
  );
58
100
  EmptyStateRoot.displayName = 'EmptyState';
@@ -79,7 +121,8 @@ export interface EmptyStateMediaProps
79
121
  */
80
122
  const EmptyStateMedia = forwardRef<View, EmptyStateMediaProps>(
81
123
  ({ className, variant = 'default', children, ...props }, ref) => {
82
- const { wrapper, card, ghost } = mediaVariants({ variant });
124
+ const { size } = useContext(EmptyStateContext);
125
+ const { wrapper, card, ghost } = mediaVariants({ variant, size });
83
126
 
84
127
  return (
85
128
  <View ref={ref} className={wrapper({ className: `mb-4 ${className ?? ''}` })} {...props}>
@@ -105,21 +148,23 @@ const EmptyStateMedia = forwardRef<View, EmptyStateMediaProps>(
105
148
  EmptyStateMedia.displayName = 'EmptyState.Media';
106
149
 
107
150
  const EmptyStateTitle = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => {
108
- const { title } = emptyStateVariants();
151
+ const { size } = useContext(EmptyStateContext);
152
+ const { title } = emptyStateVariants({ size });
109
153
  return <Text ref={ref} className={title({ className })} {...props} />;
110
154
  });
111
155
  EmptyStateTitle.displayName = 'EmptyState.Title';
112
156
 
113
157
  const EmptyStateDescription = forwardRef<RNText, TextProps>(
114
158
  ({ className, ...props }, ref) => {
115
- const { description } = emptyStateVariants();
159
+ const { size } = useContext(EmptyStateContext);
160
+ const { description } = emptyStateVariants({ size });
116
161
  return <Text ref={ref} className={description({ className })} {...props} />;
117
162
  }
118
163
  );
119
164
  EmptyStateDescription.displayName = 'EmptyState.Description';
120
165
 
121
166
  /** Slot below the header, for actions such as a primary button. */
122
- const EmptyStateContent = forwardRef<View, EmptyStateProps>(
167
+ const EmptyStateContent = forwardRef<View, ViewProps & { className?: string }>(
123
168
  ({ className, ...props }, ref) => {
124
169
  const { content } = emptyStateVariants();
125
170
  return <View ref={ref} className={content({ className })} {...props} />;
@@ -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.