panelui-native 0.7.0 → 0.12.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 (161) hide show
  1. package/README.md +5 -3
  2. package/lib/module/components/attachment/index.js +219 -0
  3. package/lib/module/components/attachment/index.js.map +1 -0
  4. package/lib/module/components/bottom-sheet/index.js +53 -3
  5. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  6. package/lib/module/components/button/index.js +19 -1
  7. package/lib/module/components/button/index.js.map +1 -1
  8. package/lib/module/components/dialog/index.js +6 -2
  9. package/lib/module/components/dialog/index.js.map +1 -1
  10. package/lib/module/components/empty-state/index.js +94 -17
  11. package/lib/module/components/empty-state/index.js.map +1 -1
  12. package/lib/module/components/frame/index.js +128 -15
  13. package/lib/module/components/frame/index.js.map +1 -1
  14. package/lib/module/components/input/index.js +107 -15
  15. package/lib/module/components/input/index.js.map +1 -1
  16. package/lib/module/components/line-chart/index.js +1024 -0
  17. package/lib/module/components/line-chart/index.js.map +1 -0
  18. package/lib/module/components/marker/index.js +190 -0
  19. package/lib/module/components/marker/index.js.map +1 -0
  20. package/lib/module/components/message/index.js +24 -8
  21. package/lib/module/components/message/index.js.map +1 -1
  22. package/lib/module/components/message-scroller/index.js +451 -0
  23. package/lib/module/components/message-scroller/index.js.map +1 -0
  24. package/lib/module/components/popover/index.js +511 -0
  25. package/lib/module/components/popover/index.js.map +1 -0
  26. package/lib/module/components/progress/index.js +52 -2
  27. package/lib/module/components/progress/index.js.map +1 -1
  28. package/lib/module/components/radio-group/index.js +132 -17
  29. package/lib/module/components/radio-group/index.js.map +1 -1
  30. package/lib/module/components/section-rail/index.js +355 -0
  31. package/lib/module/components/section-rail/index.js.map +1 -0
  32. package/lib/module/components/select/index.js +245 -56
  33. package/lib/module/components/select/index.js.map +1 -1
  34. package/lib/module/components/separator/index.js +91 -0
  35. package/lib/module/components/separator/index.js.map +1 -0
  36. package/lib/module/components/slider/index.js +355 -0
  37. package/lib/module/components/slider/index.js.map +1 -0
  38. package/lib/module/components/surface/index.js +21 -2
  39. package/lib/module/components/surface/index.js.map +1 -1
  40. package/lib/module/components/switch/index.js +10 -1
  41. package/lib/module/components/switch/index.js.map +1 -1
  42. package/lib/module/components/tabs/index.js +186 -16
  43. package/lib/module/components/tabs/index.js.map +1 -1
  44. package/lib/module/components/toggle-button/index.js +297 -0
  45. package/lib/module/components/toggle-button/index.js.map +1 -0
  46. package/lib/module/hooks/index.js +1 -0
  47. package/lib/module/hooks/index.js.map +1 -1
  48. package/lib/module/hooks/use-keyboard-avoidance.js +57 -8
  49. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  50. package/lib/module/hooks/use-scroll-sections.js +89 -0
  51. package/lib/module/hooks/use-scroll-sections.js.map +1 -0
  52. package/lib/module/icons/index.js +69 -0
  53. package/lib/module/icons/index.js.map +1 -1
  54. package/lib/module/index.js +11 -2
  55. package/lib/module/index.js.map +1 -1
  56. package/lib/module/native/index.js.map +1 -1
  57. package/lib/module/primitives/keyboard-avoider.js +2 -0
  58. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  59. package/lib/module/primitives/scrim.js +72 -0
  60. package/lib/module/primitives/scrim.js.map +1 -0
  61. package/lib/module/utils/haptics.js +57 -0
  62. package/lib/module/utils/haptics.js.map +1 -0
  63. package/lib/typescript/src/components/attachment/index.d.ts +93 -0
  64. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/bottom-sheet/index.d.ts +6 -1
  66. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/button/index.d.ts +9 -4
  68. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/dialog/index.d.ts +7 -1
  70. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/empty-state/index.d.ts +152 -2
  72. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  73. package/lib/typescript/src/components/frame/index.d.ts +47 -7
  74. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/input/index.d.ts +113 -2
  76. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/line-chart/index.d.ts +247 -0
  78. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -0
  79. package/lib/typescript/src/components/marker/index.d.ts +137 -0
  80. package/lib/typescript/src/components/marker/index.d.ts.map +1 -0
  81. package/lib/typescript/src/components/message/index.d.ts +8 -1
  82. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/message-scroller/index.d.ts +150 -0
  84. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -0
  85. package/lib/typescript/src/components/popover/index.d.ts +136 -0
  86. package/lib/typescript/src/components/popover/index.d.ts.map +1 -0
  87. package/lib/typescript/src/components/progress/index.d.ts +33 -0
  88. package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/radio-group/index.d.ts +39 -0
  90. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  91. package/lib/typescript/src/components/section-rail/index.d.ts +115 -0
  92. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -0
  93. package/lib/typescript/src/components/select/index.d.ts +46 -7
  94. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/separator/index.d.ts +47 -0
  96. package/lib/typescript/src/components/separator/index.d.ts.map +1 -0
  97. package/lib/typescript/src/components/slider/index.d.ts +203 -0
  98. package/lib/typescript/src/components/slider/index.d.ts.map +1 -0
  99. package/lib/typescript/src/components/surface/index.d.ts +51 -2
  100. package/lib/typescript/src/components/surface/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/tabs/index.d.ts +46 -4
  103. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  104. package/lib/typescript/src/components/toggle-button/index.d.ts +244 -0
  105. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -0
  106. package/lib/typescript/src/hooks/index.d.ts +1 -0
  107. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  108. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +10 -1
  109. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  110. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +31 -0
  111. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -0
  112. package/lib/typescript/src/icons/index.d.ts +6 -0
  113. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  114. package/lib/typescript/src/index.d.ts +13 -4
  115. package/lib/typescript/src/index.d.ts.map +1 -1
  116. package/lib/typescript/src/native/index.d.ts +16 -1
  117. package/lib/typescript/src/native/index.d.ts.map +1 -1
  118. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +8 -1
  119. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  120. package/lib/typescript/src/primitives/scrim.d.ts +21 -0
  121. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -0
  122. package/lib/typescript/src/utils/haptics.d.ts +12 -0
  123. package/lib/typescript/src/utils/haptics.d.ts.map +1 -0
  124. package/package.json +9 -1
  125. package/src/components/attachment/index.tsx +279 -0
  126. package/src/components/bottom-sheet/index.tsx +58 -2
  127. package/src/components/button/index.tsx +26 -5
  128. package/src/components/dialog/index.tsx +10 -1
  129. package/src/components/empty-state/index.tsx +60 -14
  130. package/src/components/frame/index.tsx +175 -29
  131. package/src/components/input/index.tsx +122 -16
  132. package/src/components/line-chart/index.tsx +1224 -0
  133. package/src/components/marker/index.tsx +194 -0
  134. package/src/components/message/index.tsx +29 -5
  135. package/src/components/message-scroller/index.tsx +574 -0
  136. package/src/components/popover/index.tsx +624 -0
  137. package/src/components/progress/index.tsx +81 -1
  138. package/src/components/radio-group/index.tsx +156 -18
  139. package/src/components/section-rail/index.tsx +459 -0
  140. package/src/components/select/index.tsx +265 -52
  141. package/src/components/separator/index.tsx +97 -0
  142. package/src/components/slider/index.tsx +371 -0
  143. package/src/components/surface/index.tsx +29 -3
  144. package/src/components/switch/index.tsx +8 -1
  145. package/src/components/tabs/index.tsx +208 -28
  146. package/src/components/toggle-button/index.tsx +374 -0
  147. package/src/hooks/index.ts +5 -0
  148. package/src/hooks/use-keyboard-avoidance.ts +73 -7
  149. package/src/hooks/use-scroll-sections.ts +127 -0
  150. package/src/icons/index.tsx +48 -0
  151. package/src/index.ts +97 -6
  152. package/src/native/index.ts +13 -1
  153. package/src/primitives/keyboard-avoider.tsx +9 -1
  154. package/src/primitives/scrim.tsx +95 -0
  155. package/src/utils/haptics.ts +63 -0
  156. package/theme.css +64 -2
  157. package/lib/module/components/inline-select/index.js +0 -284
  158. package/lib/module/components/inline-select/index.js.map +0 -1
  159. package/lib/typescript/src/components/inline-select/index.d.ts +0 -48
  160. package/lib/typescript/src/components/inline-select/index.d.ts.map +0 -1
  161. package/src/components/inline-select/index.tsx +0 -332
@@ -1,28 +1,69 @@
1
+ /**
2
+ * Select — a picker with one trigger and three ways of showing its options.
3
+ *
4
+ * Which one is right depends on what surrounds the trigger, not on what the
5
+ * options are, which is why it is a prop rather than three components:
6
+ *
7
+ * - `sheet` (default) takes the bottom of the screen. Best for a long list, or
8
+ * on a small screen where an anchored panel would cover the thing you are
9
+ * choosing for.
10
+ * - `inline` expands the list in normal layout flow. Everything below moves
11
+ * down. Right inside a settings list, where that reads as the row growing;
12
+ * wrong anywhere the shift is jarring.
13
+ * - `overlay` floats the list above the page through a portal, anchored to the
14
+ * trigger and flipped above it when there is no room below. Nothing else on
15
+ * the screen moves.
16
+ *
17
+ * ```tsx
18
+ * <Select value={region} onValueChange={setRegion} presentation="overlay">
19
+ * <Select.Item value="us" label="United States" />
20
+ * <Select.Item value="eu" label="Europe" />
21
+ * </Select>
22
+ * ```
23
+ */
1
24
  import {
2
25
  Children,
3
26
  createContext,
4
27
  isValidElement,
28
+ useCallback,
5
29
  useContext,
6
30
  useMemo,
31
+ useRef,
7
32
  useState,
8
- type ReactElement,
9
33
  type ReactNode,
10
34
  } from 'react';
11
- import { Pressable, ScrollView, View } from 'react-native';
35
+ import {
36
+ Pressable,
37
+ ScrollView,
38
+ useWindowDimensions,
39
+ View,
40
+ type LayoutChangeEvent,
41
+ } from 'react-native';
42
+ import Animated, {
43
+ FadeIn,
44
+ FadeOut,
45
+ useAnimatedStyle,
46
+ useSharedValue,
47
+ withTiming,
48
+ } from 'react-native-reanimated';
12
49
  import { tv } from 'tailwind-variants';
13
50
  import { useCSSVariable } from 'uniwind';
14
51
  import { CheckIcon, ChevronDownIcon } from '../../icons';
15
52
  import { getNativeUI } from '../../native';
53
+ import { Portal } from '../../primitives/portal';
16
54
  import { Text } from '../../primitives/text';
17
55
  import { BottomSheet } from '../bottom-sheet';
18
56
 
19
57
  const selectVariants = tv({
20
58
  slots: {
59
+ // `rounded-lg`, the same radius Button and Input use — a select sitting in
60
+ // a form beside either of them should read as the same family of control.
21
61
  trigger:
22
- 'w-full flex-row items-center justify-between gap-3 rounded-2xl border border-input bg-background px-4 py-3.5',
62
+ 'w-full flex-row items-center justify-between gap-3 rounded-lg border border-input bg-background px-4 py-3.5',
23
63
  triggerLabel: 'flex-1 text-base font-medium text-foreground',
24
64
  placeholder: 'flex-1 text-base text-muted-foreground',
25
- item: 'flex-row items-center gap-2 rounded-xl px-3 py-3',
65
+ list: 'overflow-hidden rounded-xl border border-border bg-popover p-2 shadow-sm',
66
+ item: 'flex-row items-center gap-2 rounded-lg px-3 py-3',
26
67
  itemLabel: 'flex-1 text-base font-medium text-foreground',
27
68
  itemIndicator: 'h-5 w-5 items-center justify-center',
28
69
  },
@@ -31,11 +72,29 @@ const selectVariants = tv({
31
72
  true: { item: 'bg-accent' },
32
73
  },
33
74
  disabled: {
34
- true: { trigger: 'opacity-50' },
75
+ true: { trigger: 'opacity-[0.64]' },
76
+ },
77
+ presentation: {
78
+ sheet: {},
79
+ inline: { list: 'mt-2' },
80
+ overlay: { list: 'shadow-lg' },
35
81
  },
36
82
  },
83
+ defaultVariants: {
84
+ presentation: 'sheet',
85
+ },
37
86
  });
38
87
 
88
+ export type SelectPresentation = 'sheet' | 'inline' | 'overlay';
89
+
90
+ /**
91
+ * Height given to the native host per appearance — a menu is a compact button,
92
+ * a wheel is a full rotor. The host is told its size rather than asked to
93
+ * measure the platform's content, which arrives a frame late and leaves the
94
+ * control collapsed until something forces a second pass.
95
+ */
96
+ const NATIVE_HEIGHT = { menu: 44, wheel: 216 } as const;
97
+
39
98
  interface SelectContextValue {
40
99
  value: string | undefined;
41
100
  onSelect: (value: string) => void;
@@ -48,7 +107,7 @@ export interface SelectItemProps {
48
107
  label: string;
49
108
  }
50
109
 
51
- /** Declarative option. Rendered inside the picker sheet. */
110
+ /** Declarative option. Rendered inside whichever surface is presenting. */
52
111
  function SelectItem({ value, label }: SelectItemProps) {
53
112
  const context = useContext(SelectContext);
54
113
  if (!context) {
@@ -79,22 +138,45 @@ function SelectItem({ value, label }: SelectItemProps) {
79
138
  );
80
139
  }
81
140
 
141
+ /** Trigger frame in window coordinates, measured when the list opens. */
142
+ interface Anchor {
143
+ x: number;
144
+ y: number;
145
+ width: number;
146
+ height: number;
147
+ }
148
+
82
149
  export interface SelectProps {
83
150
  className?: string;
84
151
  value?: string;
85
152
  onValueChange: (value: string) => void;
86
153
  placeholder?: string;
87
- /** Sheet title shown above the options. */
88
- title?: string;
89
154
  disabled?: boolean;
90
155
  /**
91
- * Render the platform's own picker instead of the trigger-and-sheet pair.
156
+ * Where the options appear. `sheet` takes the bottom of the screen, `inline`
157
+ * expands the list in layout flow, `overlay` floats it above the page
158
+ * anchored to the trigger.
159
+ */
160
+ presentation?: SelectPresentation;
161
+ /** Sheet title shown above the options. `sheet` presentation only. */
162
+ title?: string;
163
+ /**
164
+ * Width of the floating list. `trigger` matches the trigger, `content` sizes
165
+ * to the longest option, or pass a pixel value. `overlay` only.
166
+ */
167
+ contentWidth?: 'trigger' | 'content' | number;
168
+ /** Gap between the trigger and the floating list. `overlay` only. */
169
+ offset?: number;
170
+ /** Called when the options open or close. */
171
+ onOpenChange?: (open: boolean) => void;
172
+ /**
173
+ * Render the platform's own picker instead of the trigger-and-list pair.
92
174
  * Requires the optional `@expo/ui` package; without it this prop does
93
175
  * nothing.
94
176
  *
95
177
  * **Theme tokens do not apply** — the platform draws the picker, so
96
- * `className` and `title` are ignored. `Select.Item` children still declare
97
- * the options.
178
+ * `className`, `title` and `presentation` are ignored. `Select.Item`
179
+ * children still declare the options.
98
180
  */
99
181
  native?: boolean;
100
182
  /**
@@ -110,13 +192,22 @@ function SelectRoot({
110
192
  value,
111
193
  onValueChange,
112
194
  placeholder = 'Select an option',
113
- title,
114
195
  disabled,
196
+ presentation = 'sheet',
197
+ title,
198
+ contentWidth = 'trigger',
199
+ offset = 8,
200
+ onOpenChange,
115
201
  native,
116
202
  nativeAppearance = 'menu',
117
203
  children,
118
204
  }: SelectProps) {
119
205
  const [open, setOpen] = useState(false);
206
+ const [anchor, setAnchor] = useState<Anchor | null>(null);
207
+ const [listHeight, setListHeight] = useState(0);
208
+ const triggerRef = useRef<View>(null);
209
+ const chevron = useSharedValue(0);
210
+ const { height: screenHeight } = useWindowDimensions();
120
211
  const nativeUI = native ? getNativeUI() : null;
121
212
 
122
213
  const options = useMemo(() => {
@@ -134,28 +225,61 @@ function SelectRoot({
134
225
  [options, value]
135
226
  );
136
227
 
228
+ const close = useCallback(() => {
229
+ chevron.value = withTiming(0, { duration: 160 });
230
+ setOpen(false);
231
+ onOpenChange?.(false);
232
+ }, [chevron, onOpenChange]);
233
+
234
+ const toggle = useCallback(() => {
235
+ if (open) {
236
+ close();
237
+ return;
238
+ }
239
+
240
+ const show = () => {
241
+ chevron.value = withTiming(1, { duration: 160 });
242
+ setOpen(true);
243
+ onOpenChange?.(true);
244
+ };
245
+
246
+ if (presentation !== 'overlay') {
247
+ show();
248
+ return;
249
+ }
250
+
251
+ // The floating list is positioned in window coordinates, so it has to know
252
+ // where the trigger actually landed — not where layout said it would.
253
+ triggerRef.current?.measureInWindow((x, y, width, height) => {
254
+ setAnchor({ x, y, width, height });
255
+ show();
256
+ });
257
+ }, [chevron, close, onOpenChange, open, presentation]);
258
+
137
259
  const context = useMemo<SelectContextValue>(
138
260
  () => ({
139
261
  value,
140
262
  onSelect: (next) => {
141
263
  onValueChange(next);
142
- setOpen(false);
264
+ close();
143
265
  },
144
266
  }),
145
- [value, onValueChange]
267
+ [value, onValueChange, close]
146
268
  );
147
269
 
148
- const { trigger, triggerLabel, placeholder: placeholderSlot } = selectVariants({
149
- disabled: !!disabled,
150
- });
270
+ const slots = selectVariants({ disabled: !!disabled, presentation });
151
271
  const chevronColor = useCSSVariable('--color-muted-foreground');
152
272
 
273
+ const chevronStyle = useAnimatedStyle(() => ({
274
+ transform: [{ rotate: `${chevron.value * 180}deg` }],
275
+ }));
276
+
153
277
  if (nativeUI) {
154
278
  const { Host, Picker } = nativeUI;
155
279
  // The native picker has no empty state, so an unset value shows the first
156
280
  // option rather than the placeholder.
157
281
  return (
158
- <Host matchContents>
282
+ <Host style={{ height: NATIVE_HEIGHT[nativeAppearance] }}>
159
283
  <Picker
160
284
  selectedValue={value ?? options[0]?.value ?? ''}
161
285
  onValueChange={(next: string) => onValueChange(next)}
@@ -163,51 +287,140 @@ function SelectRoot({
163
287
  enabled={!disabled}
164
288
  >
165
289
  {options.map((option) => (
166
- <Picker.Item
167
- key={option.value}
168
- label={option.label}
169
- value={option.value}
170
- />
290
+ <Picker.Item key={option.value} label={option.label} value={option.value} />
171
291
  ))}
172
292
  </Picker>
173
293
  </Host>
174
294
  );
175
295
  }
176
296
 
177
- return (
178
- <SelectContext.Provider value={context}>
179
- <Pressable
180
- accessibilityRole="button"
181
- accessibilityState={{ disabled: !!disabled, expanded: open }}
182
- disabled={disabled}
183
- onPress={() => setOpen(true)}
184
- className={trigger({ className })}
185
- >
186
- {selectedLabel ? (
187
- <Text className={triggerLabel()}>{selectedLabel}</Text>
188
- ) : (
189
- <Text className={placeholderSlot()}>{placeholder}</Text>
190
- )}
297
+ const trigger = (
298
+ <Pressable
299
+ ref={triggerRef}
300
+ accessibilityRole="button"
301
+ accessibilityState={{ disabled: !!disabled, expanded: open }}
302
+ disabled={disabled}
303
+ onPress={toggle}
304
+ className={slots.trigger()}
305
+ >
306
+ {selectedLabel ? (
307
+ <Text className={slots.triggerLabel()}>{selectedLabel}</Text>
308
+ ) : (
309
+ <Text className={slots.placeholder()}>{placeholder}</Text>
310
+ )}
311
+ <Animated.View style={chevronStyle}>
191
312
  <ChevronDownIcon
192
313
  color={typeof chevronColor === 'string' ? chevronColor : '#737373'}
193
314
  />
194
- </Pressable>
195
- <BottomSheet open={open} onOpenChange={setOpen}>
196
- <BottomSheet.Content>
197
- {/* BottomSheet.Content portals its children out of this subtree —
198
- re-provide the select context so Select.Item keeps working. */}
315
+ </Animated.View>
316
+ </Pressable>
317
+ );
318
+
319
+ if (presentation === 'sheet') {
320
+ return (
321
+ <SelectContext.Provider value={context}>
322
+ <View className={className}>{trigger}</View>
323
+ {/* The sheet only ever reports a close — it is opened from the
324
+ trigger — and close() has the chevron to put back. */}
325
+ <BottomSheet
326
+ open={open}
327
+ onOpenChange={(next) => {
328
+ if (!next) close();
329
+ }}
330
+ >
331
+ <BottomSheet.Content>
332
+ {/* BottomSheet.Content portals its children out of this subtree —
333
+ re-provide the select context so Select.Item keeps working. */}
334
+ <SelectContext.Provider value={context}>
335
+ {title ? (
336
+ <Text size="lg" weight="semibold" className="mb-2 px-3">
337
+ {title}
338
+ </Text>
339
+ ) : null}
340
+ <ScrollView bounces={false} className="max-h-96">
341
+ <View className="gap-1 pb-2">{children}</View>
342
+ </ScrollView>
343
+ </SelectContext.Provider>
344
+ </BottomSheet.Content>
345
+ </BottomSheet>
346
+ </SelectContext.Provider>
347
+ );
348
+ }
349
+
350
+ if (presentation === 'inline') {
351
+ return (
352
+ <SelectContext.Provider value={context}>
353
+ <View className={className}>
354
+ {trigger}
355
+ {open ? (
356
+ <Animated.View
357
+ entering={FadeIn.duration(140)}
358
+ exiting={FadeOut.duration(120)}
359
+ className={slots.list()}
360
+ >
361
+ {children}
362
+ </Animated.View>
363
+ ) : null}
364
+ </View>
365
+ </SelectContext.Provider>
366
+ );
367
+ }
368
+
369
+ // Flip above the trigger when the list would run off the bottom. listHeight
370
+ // is 0 on the first frame, so the list opens downwards and corrects itself
371
+ // once measured — which is invisible inside the 140ms fade.
372
+ const spaceBelow = anchor ? screenHeight - (anchor.y + anchor.height) - offset : 0;
373
+ const flip = !!anchor && listHeight > 0 && listHeight > spaceBelow;
374
+
375
+ const overlayPosition = anchor
376
+ ? {
377
+ position: 'absolute' as const,
378
+ left: anchor.x,
379
+ ...(flip
380
+ ? { bottom: screenHeight - anchor.y + offset }
381
+ : { top: anchor.y + anchor.height + offset }),
382
+ ...(contentWidth === 'trigger'
383
+ ? { width: anchor.width }
384
+ : typeof contentWidth === 'number'
385
+ ? { width: contentWidth }
386
+ : { minWidth: anchor.width }),
387
+ // Never collapse to nothing in a cramped viewport — the list scrolls.
388
+ maxHeight: Math.max((flip ? anchor.y : spaceBelow) - offset, 160),
389
+ }
390
+ : null;
391
+
392
+ const onListLayout = (event: LayoutChangeEvent) => {
393
+ setListHeight(event.nativeEvent.layout.height);
394
+ };
395
+
396
+ return (
397
+ <SelectContext.Provider value={context}>
398
+ <View className={className}>{trigger}</View>
399
+
400
+ {open && overlayPosition ? (
401
+ <Portal>
402
+ {/* Full-screen catcher so a press anywhere else dismisses the list. */}
403
+ <Pressable
404
+ accessibilityRole="button"
405
+ accessibilityLabel="Close"
406
+ onPress={close}
407
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}
408
+ />
199
409
  <SelectContext.Provider value={context}>
200
- {title ? (
201
- <Text size="lg" weight="semibold" className="mb-2 px-3">
202
- {title}
203
- </Text>
204
- ) : null}
205
- <ScrollView bounces={false} className="max-h-96">
206
- <View className="gap-1 pb-2">{children}</View>
207
- </ScrollView>
410
+ <Animated.View
411
+ entering={FadeIn.duration(140)}
412
+ exiting={FadeOut.duration(120)}
413
+ onLayout={onListLayout}
414
+ style={overlayPosition}
415
+ className={slots.list()}
416
+ >
417
+ <ScrollView bounces={false} showsVerticalScrollIndicator={false}>
418
+ {children}
419
+ </ScrollView>
420
+ </Animated.View>
208
421
  </SelectContext.Provider>
209
- </BottomSheet.Content>
210
- </BottomSheet>
422
+ </Portal>
423
+ ) : null}
211
424
  </SelectContext.Provider>
212
425
  );
213
426
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Separator — a rule between content.
3
+ *
4
+ * React Native has no `<hr>` and no border shorthand that survives a flex row,
5
+ * so a separator here is a view with a size on one axis and a stretch on the
6
+ * other. That is why orientation is a prop rather than something the layout
7
+ * infers: the component has to know which axis carries the thickness.
8
+ *
9
+ * ```tsx
10
+ * <Separator />
11
+ * <Separator orientation="vertical" />
12
+ * ```
13
+ *
14
+ * A vertical separator takes its length from the parent, so the parent needs a
15
+ * height — inside a `flex-row` with `items-stretch`, or with an explicit `h-*`
16
+ * on either the row or the separator itself. Without one it measures zero and
17
+ * nothing draws.
18
+ */
19
+ import { forwardRef } from 'react';
20
+ import { View, type ViewProps } from 'react-native';
21
+ import { tv, type VariantProps } from 'tailwind-variants';
22
+
23
+ const separatorVariants = tv({
24
+ base: 'bg-border',
25
+ variants: {
26
+ orientation: {
27
+ horizontal: 'w-full',
28
+ vertical: 'h-full',
29
+ },
30
+ variant: {
31
+ thin: '',
32
+ thick: '',
33
+ },
34
+ },
35
+ compoundVariants: [
36
+ // Thickness is on whichever axis is *not* the length, so it cannot be
37
+ // expressed by the orientation and variant classes independently.
38
+ { orientation: 'horizontal', variant: 'thin', class: 'h-px' },
39
+ { orientation: 'horizontal', variant: 'thick', class: 'h-1' },
40
+ { orientation: 'vertical', variant: 'thin', class: 'w-px' },
41
+ { orientation: 'vertical', variant: 'thick', class: 'w-1' },
42
+ ],
43
+ defaultVariants: {
44
+ orientation: 'horizontal',
45
+ variant: 'thin',
46
+ },
47
+ });
48
+
49
+ export interface SeparatorProps
50
+ extends ViewProps,
51
+ VariantProps<typeof separatorVariants> {
52
+ className?: string;
53
+ /**
54
+ * Thickness in pixels, overriding the variant. Sets the height of a
55
+ * horizontal separator and the width of a vertical one.
56
+ */
57
+ thickness?: number;
58
+ /**
59
+ * Whether the separator is only visual. A decorative separator is skipped by
60
+ * screen readers; set false when the split itself carries meaning — between
61
+ * two groups of menu items, say — and it is announced instead.
62
+ */
63
+ decorative?: boolean;
64
+ }
65
+
66
+ export const Separator = forwardRef<View, SeparatorProps>(
67
+ (
68
+ {
69
+ className,
70
+ orientation = 'horizontal',
71
+ variant,
72
+ thickness,
73
+ decorative = true,
74
+ style,
75
+ ...props
76
+ },
77
+ ref
78
+ ) => (
79
+ <View
80
+ ref={ref}
81
+ // `role` rather than `accessibilityRole` — React Native's older role list
82
+ // has no separator, the ARIA-aligned one does.
83
+ role={decorative ? 'none' : 'separator'}
84
+ accessible={!decorative}
85
+ aria-orientation={decorative ? undefined : orientation}
86
+ className={separatorVariants({ orientation, variant, className })}
87
+ style={
88
+ thickness !== undefined
89
+ ? [orientation === 'horizontal' ? { height: thickness } : { width: thickness }, style]
90
+ : style
91
+ }
92
+ {...props}
93
+ />
94
+ )
95
+ );
96
+
97
+ Separator.displayName = 'Separator';