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
@@ -0,0 +1,624 @@
1
+ /**
2
+ * Popover — a panel anchored to the thing that opened it.
3
+ *
4
+ * A dialog takes the screen and asks to be dealt with; a popover stays next to
5
+ * its trigger and keeps the context around it visible. That difference is the
6
+ * whole reason both exist, and it is why this one is positioned rather than
7
+ * centred.
8
+ *
9
+ * ```tsx
10
+ * <Popover>
11
+ * <Popover.Trigger>
12
+ * <Button variant="outline">Options</Button>
13
+ * </Popover.Trigger>
14
+ * <Popover.Content placement="bottom" align="end">
15
+ * <Popover.Title>Export</Popover.Title>
16
+ * <Popover.Description>Choose a format.</Popover.Description>
17
+ * </Popover.Content>
18
+ * </Popover>
19
+ * ```
20
+ *
21
+ * Placement is a preference, not a promise. The trigger is measured in window
22
+ * coordinates when it is pressed, the panel measures itself on its first
23
+ * layout, and the two are reconciled against the safe area: a panel that would
24
+ * run off the bottom flips above the trigger, and one that would run off the
25
+ * side slides back inside. So `placement="bottom"` means *below, if below
26
+ * fits* — which is the only behaviour that survives a trigger near an edge.
27
+ *
28
+ * The first frame is rendered transparent, because the panel's own size is not
29
+ * known until it has laid out once. Without that it would appear at the origin
30
+ * and jump into place.
31
+ */
32
+ import {
33
+ cloneElement,
34
+ createContext,
35
+ isValidElement,
36
+ useCallback,
37
+ useContext,
38
+ useEffect,
39
+ useMemo,
40
+ useRef,
41
+ useState,
42
+ type ReactElement,
43
+ type ReactNode,
44
+ } from 'react';
45
+ import {
46
+ Pressable,
47
+ useWindowDimensions,
48
+ View,
49
+ type LayoutChangeEvent,
50
+ type ViewProps,
51
+ } from 'react-native';
52
+ import Animated, {
53
+ FadeOut,
54
+ useAnimatedStyle,
55
+ useReducedMotion,
56
+ useSharedValue,
57
+ withSpring,
58
+ withTiming,
59
+ } from 'react-native-reanimated';
60
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
61
+ import { Portal } from '../../primitives/portal';
62
+ import { Scrim } from '../../primitives/scrim';
63
+ import { BottomSheet } from '../bottom-sheet';
64
+ import { Text, type TextProps } from '../../primitives/text';
65
+ import { cn } from '../../utils/cn';
66
+
67
+ /** Gap between the trigger and the panel. */
68
+ const DEFAULT_OFFSET = 8;
69
+ /** Smallest gap allowed between the panel and the edge of the safe area. */
70
+ const SCREEN_MARGIN = 12;
71
+ /** Side of the arrow square before it is rotated 45°. */
72
+ const ARROW_SIZE = 12;
73
+
74
+ export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right';
75
+ export type PopoverAlign = 'start' | 'center' | 'end';
76
+
77
+ interface TriggerRect {
78
+ x: number;
79
+ y: number;
80
+ width: number;
81
+ height: number;
82
+ }
83
+
84
+ interface PopoverContextValue {
85
+ open: boolean;
86
+ setOpen: (open: boolean) => void;
87
+ trigger: TriggerRect | null;
88
+ setTrigger: (rect: TriggerRect | null) => void;
89
+ /** Resolved placement, published by Content so Arrow knows which way to point. */
90
+ placement: PopoverPlacement;
91
+ setPlacement: (placement: PopoverPlacement) => void;
92
+ /** Trigger centre along the cross axis, relative to the panel origin. */
93
+ arrowOffset: number;
94
+ setArrowOffset: (offset: number) => void;
95
+ /** Whether Content is presenting as a bottom sheet — Arrow is null then. */
96
+ presentation: PopoverPresentation;
97
+ }
98
+
99
+ export type PopoverPresentation = 'popover' | 'bottom-sheet';
100
+
101
+ const PopoverContext = createContext<PopoverContextValue | null>(null);
102
+
103
+ function usePopover(component: string): PopoverContextValue {
104
+ const context = useContext(PopoverContext);
105
+ if (!context) {
106
+ throw new Error(`${component} must be used within a <Popover>`);
107
+ }
108
+ return context;
109
+ }
110
+
111
+ export interface PopoverProps {
112
+ children: ReactNode;
113
+ /** Controlled open state. */
114
+ open?: boolean;
115
+ onOpenChange?: (open: boolean) => void;
116
+ /** Initial state when uncontrolled. */
117
+ defaultOpen?: boolean;
118
+ /**
119
+ * `popover` is the anchored panel. `bottom-sheet` presents the content in a
120
+ * draggable sheet instead — better on a small screen, or when the content is
121
+ * a form rather than a menu. Placement, align and the arrow do not apply to a
122
+ * sheet.
123
+ */
124
+ presentation?: PopoverPresentation;
125
+ }
126
+
127
+ function PopoverRoot({
128
+ children,
129
+ open,
130
+ onOpenChange,
131
+ defaultOpen = false,
132
+ presentation = 'popover',
133
+ }: PopoverProps) {
134
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
135
+ const [trigger, setTrigger] = useState<TriggerRect | null>(null);
136
+ const [placement, setPlacement] = useState<PopoverPlacement>('bottom');
137
+ const [arrowOffset, setArrowOffset] = useState(0);
138
+
139
+ const isControlled = open !== undefined;
140
+ const resolvedOpen = isControlled ? open : internalOpen;
141
+
142
+ const setOpen = useCallback(
143
+ (next: boolean) => {
144
+ if (!isControlled) setInternalOpen(next);
145
+ onOpenChange?.(next);
146
+ },
147
+ [isControlled, onOpenChange]
148
+ );
149
+
150
+ const context = useMemo(
151
+ () => ({
152
+ open: resolvedOpen,
153
+ setOpen,
154
+ trigger,
155
+ setTrigger,
156
+ placement,
157
+ setPlacement,
158
+ arrowOffset,
159
+ setArrowOffset,
160
+ presentation,
161
+ }),
162
+ [resolvedOpen, setOpen, trigger, placement, arrowOffset, presentation]
163
+ );
164
+
165
+ return <PopoverContext.Provider value={context}>{children}</PopoverContext.Provider>;
166
+ }
167
+
168
+ export interface PopoverTriggerProps {
169
+ children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
170
+ }
171
+
172
+ /**
173
+ * Wraps its child and toggles the popover on press.
174
+ *
175
+ * The child is wrapped in a view rather than given a ref directly: the ref has
176
+ * to survive whatever the child is — a Button, a plain Pressable, an icon —
177
+ * and only a wrapper we own is guaranteed to be measurable.
178
+ */
179
+ function PopoverTrigger({ children }: PopoverTriggerProps) {
180
+ const { open, setOpen, setTrigger } = usePopover('Popover.Trigger');
181
+ const ref = useRef<View>(null);
182
+
183
+ const measureThenToggle = (...args: unknown[]) => {
184
+ if (isValidElement(children)) children.props.onPress?.(...args);
185
+
186
+ if (open) {
187
+ setOpen(false);
188
+ return;
189
+ }
190
+
191
+ // Measured on every open rather than on layout: the trigger may have
192
+ // scrolled since it was laid out, and a stale rect anchors the panel to
193
+ // where the trigger used to be.
194
+ ref.current?.measureInWindow((x, y, width, height) => {
195
+ setTrigger({ x, y, width, height });
196
+ setOpen(true);
197
+ });
198
+ };
199
+
200
+ return (
201
+ <View ref={ref} collapsable={false}>
202
+ {isValidElement(children)
203
+ ? cloneElement(children, { onPress: measureThenToggle })
204
+ : children}
205
+ </View>
206
+ );
207
+ }
208
+
209
+ export interface PopoverContentProps extends ViewProps {
210
+ className?: string;
211
+ /** Preferred side of the trigger. Flipped when that side does not fit. */
212
+ placement?: PopoverPlacement;
213
+ /** Where the panel sits along the trigger's other axis. */
214
+ align?: PopoverAlign;
215
+ /** Gap between the trigger and the panel, in pixels. */
216
+ offset?: number;
217
+ /** Nudge along the alignment axis, in pixels. */
218
+ alignOffset?: number;
219
+ /**
220
+ * `content-fit` sizes to the content, `trigger` matches the trigger's width,
221
+ * `full` spans the safe area, and a number is that many pixels.
222
+ */
223
+ width?: number | 'trigger' | 'full' | 'content-fit';
224
+ /**
225
+ * Floor for the panel's width, in pixels. Worth setting with
226
+ * `width="trigger"`, where a narrow trigger would otherwise squeeze the
227
+ * content into a column.
228
+ */
229
+ minWidth?: number;
230
+ /** Tap outside the panel closes it. Default true. */
231
+ dismissible?: boolean;
232
+ /**
233
+ * Frost the background behind the panel instead of dimming it. Uses
234
+ * `expo-blur` when installed and falls back to the dimmed scrim when it is
235
+ * not, so it is safe to pass either way.
236
+ */
237
+ blur?: boolean;
238
+ children?: ReactNode;
239
+ }
240
+
241
+ function PopoverContent({
242
+ className,
243
+ placement = 'bottom',
244
+ align = 'center',
245
+ offset = DEFAULT_OFFSET,
246
+ alignOffset = 0,
247
+ width = 'content-fit',
248
+ minWidth,
249
+ dismissible = true,
250
+ blur = false,
251
+ children,
252
+ onLayout: onLayoutProp,
253
+ style,
254
+ ...props
255
+ }: PopoverContentProps) {
256
+ const context = usePopover('Popover.Content');
257
+ const { open, setOpen, trigger, setPlacement, setArrowOffset, presentation } = context;
258
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
259
+ const insets = useSafeAreaInsets();
260
+ const [size, setSize] = useState<{ width: number; height: number } | null>(null);
261
+
262
+ // Panel size changes with its content, so it is re-measured rather than
263
+ // measured once — a popover whose body grows should not stay the old size.
264
+ const onLayout = (event: LayoutChangeEvent) => {
265
+ const { width: w, height: h } = event.nativeEvent.layout;
266
+ setSize((current) =>
267
+ current && Math.abs(current.width - w) < 1 && Math.abs(current.height - h) < 1
268
+ ? current
269
+ : { width: w, height: h }
270
+ );
271
+ onLayoutProp?.(event);
272
+ };
273
+
274
+ const bounds = {
275
+ left: insets.left + SCREEN_MARGIN,
276
+ right: screenWidth - insets.right - SCREEN_MARGIN,
277
+ top: insets.top + SCREEN_MARGIN,
278
+ bottom: screenHeight - insets.bottom - SCREEN_MARGIN,
279
+ };
280
+
281
+ const available = bounds.right - bounds.left;
282
+ const requestedWidth =
283
+ width === 'content-fit'
284
+ ? undefined
285
+ : width === 'trigger'
286
+ ? trigger?.width
287
+ : width === 'full'
288
+ ? available
289
+ : width;
290
+
291
+ // The floor never wins past the space there actually is — a panel wider than
292
+ // the screen is worse than a cramped one.
293
+ const resolvedWidth =
294
+ minWidth !== undefined && requestedWidth !== undefined
295
+ ? Math.min(Math.max(requestedWidth, minWidth), available)
296
+ : requestedWidth;
297
+
298
+ const position =
299
+ trigger && size
300
+ ? place({ trigger, size, placement, align, offset, alignOffset, bounds })
301
+ : null;
302
+
303
+ // Publish the side actually used and where the trigger centre landed, so the
304
+ // arrow points at the trigger even after a flip or a clamp.
305
+ const resolvedPlacement = position?.placement;
306
+ const resolvedArrow = position?.arrowOffset;
307
+ useEffect(() => {
308
+ if (resolvedPlacement) setPlacement(resolvedPlacement);
309
+ if (resolvedArrow !== undefined) setArrowOffset(resolvedArrow);
310
+ }, [resolvedPlacement, resolvedArrow, setPlacement, setArrowOffset]);
311
+
312
+ /*
313
+ * The entrance is driven by hand rather than by an `entering` preset, and the
314
+ * reason is the measuring frame. A layout animation fires on mount — which
315
+ * here is the frame *before* the panel knows where it goes, so the whole
316
+ * animation would play at the origin, invisibly, and the panel would then
317
+ * snap into place fully formed. Holding the values until a position exists
318
+ * is the only way to have the animation and the correct position both.
319
+ */
320
+ const appear = useSharedValue(0);
321
+ const settle = useSharedValue(0);
322
+ const reducedMotion = useReducedMotion();
323
+ const placed = !!position;
324
+
325
+ useEffect(() => {
326
+ if (!placed) {
327
+ appear.value = 0;
328
+ settle.value = 0;
329
+ return;
330
+ }
331
+ if (reducedMotion) {
332
+ appear.value = 1;
333
+ settle.value = 1;
334
+ return;
335
+ }
336
+ appear.value = withTiming(1, { duration: 120 });
337
+ settle.value = withSpring(1, { damping: 18, stiffness: 250, mass: 0.6 });
338
+ }, [placed, reducedMotion, appear, settle]);
339
+
340
+ // Starts slightly small and shifted towards the trigger, so the panel
341
+ // appears to unfold from it rather than fade in over it.
342
+ const origin = ENTRY_SHIFT[resolvedPlacement ?? placement];
343
+ const panelStyle = useAnimatedStyle(() => ({
344
+ opacity: appear.value,
345
+ transform: [
346
+ { translateX: origin.x * (1 - settle.value) },
347
+ { translateY: origin.y * (1 - settle.value) },
348
+ { scale: 0.94 + 0.06 * settle.value },
349
+ ],
350
+ }));
351
+
352
+ // The sheet presentation hands off entirely to BottomSheet — it owns its own
353
+ // portal, backdrop and dismiss gesture, so there is nothing to anchor here.
354
+ // The context is re-provided inside so Popover.Close keeps closing it.
355
+ if (presentation === 'bottom-sheet') {
356
+ return (
357
+ <BottomSheet open={open} onOpenChange={setOpen}>
358
+ <BottomSheet.Content dismissible={dismissible} className={className}>
359
+ <PopoverContext.Provider value={context}>{children}</PopoverContext.Provider>
360
+ </BottomSheet.Content>
361
+ </BottomSheet>
362
+ );
363
+ }
364
+
365
+ if (!open || !trigger) return null;
366
+
367
+ return (
368
+ <Portal>
369
+ {/* Portal content mounts under PortalHost, outside this provider's
370
+ subtree — re-provide the context so Popover.Close and Popover.Arrow
371
+ keep working. */}
372
+ <PopoverContext.Provider value={context}>
373
+ <View className="absolute inset-0">
374
+ {/* A popover does not dim the screen by default — the backdrop is
375
+ there only to catch the outside tap. `blur` opts into a frost. */}
376
+ {blur ? <Scrim blur intensity={20} dimClassName="bg-black/30" /> : null}
377
+ <Pressable
378
+ accessibilityLabel="Close"
379
+ className="absolute inset-0"
380
+ onPress={dismissible ? () => setOpen(false) : undefined}
381
+ />
382
+ {/*
383
+ Two views, not one, and the reason is a Reanimated rule: a layout
384
+ animation and an animated style may not drive the same property on
385
+ the same component, or the layout animation silently wins. The exit
386
+ fade and `panelStyle`'s opacity both want it — so the outer view
387
+ owns the position and the exit, and the inner one owns the entrance
388
+ and the panel's own surface.
389
+ */}
390
+ <Animated.View
391
+ exiting={FadeOut.duration(120)}
392
+ onLayout={onLayout}
393
+ style={{
394
+ position: 'absolute',
395
+ // Until it has measured itself the panel has no honest position,
396
+ // so it is laid out off-screen rather than at the origin — an
397
+ // invisible view at 0,0 still catches taps.
398
+ top: position?.top ?? -9999,
399
+ left: position?.left ?? -9999,
400
+ maxWidth: bounds.right - bounds.left,
401
+ width: resolvedWidth,
402
+ }}
403
+ >
404
+ <Animated.View
405
+ accessibilityViewIsModal
406
+ style={[panelStyle, style]}
407
+ className={cn(
408
+ 'gap-1.5 rounded-2xl border border-border bg-popover p-4 shadow-lg',
409
+ className
410
+ )}
411
+ {...props}
412
+ >
413
+ {children}
414
+ </Animated.View>
415
+ </Animated.View>
416
+ </View>
417
+ </PopoverContext.Provider>
418
+ </Portal>
419
+ );
420
+ }
421
+
422
+ export interface PopoverArrowProps extends ViewProps {
423
+ className?: string;
424
+ }
425
+
426
+ /**
427
+ * A small square rotated into a diamond, half-buried under the panel so only
428
+ * the point shows. It needs the panel to have a border for its own two visible
429
+ * edges to line up with — without one it reads as a floating lozenge.
430
+ *
431
+ * It points at the trigger's centre, which the panel resolves and publishes:
432
+ * when `align` shifts the panel off-centre, or a clamp slides it back on
433
+ * screen, the arrow stays over the trigger rather than over the panel's middle.
434
+ */
435
+ function PopoverArrow({ className, style, ...props }: PopoverArrowProps) {
436
+ const { trigger, placement, arrowOffset, presentation } = usePopover('Popover.Arrow');
437
+ // A sheet has no trigger edge to point at.
438
+ if (!trigger || presentation === 'bottom-sheet') return null;
439
+
440
+ const vertical = placement === 'top' || placement === 'bottom';
441
+ // The arrow sits on the edge facing the trigger, which is the opposite edge
442
+ // to the placement: a panel placed below has its arrow on top.
443
+ const edge = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[placement];
444
+
445
+ return (
446
+ <View
447
+ pointerEvents="none"
448
+ accessibilityElementsHidden
449
+ importantForAccessibility="no-hide-descendants"
450
+ style={[
451
+ {
452
+ position: 'absolute',
453
+ [edge]: -ARROW_SIZE / 2,
454
+ width: ARROW_SIZE,
455
+ height: ARROW_SIZE,
456
+ transform: [{ rotate: '45deg' }],
457
+ // Centred on the trigger along the cross axis. `marginLeft/Top` backs
458
+ // it off by half its own size so `arrowOffset` lands on its centre.
459
+ ...(vertical
460
+ ? { left: arrowOffset, marginLeft: -ARROW_SIZE / 2 }
461
+ : { top: arrowOffset, marginTop: -ARROW_SIZE / 2 }),
462
+ },
463
+ style,
464
+ ]}
465
+ // Only the two edges that face outwards are drawn; the other two sit
466
+ // under the panel and would show as a seam across it.
467
+ className={cn(
468
+ 'border-border bg-popover',
469
+ placement === 'bottom' && 'border-l border-t',
470
+ placement === 'top' && 'border-b border-r',
471
+ placement === 'right' && 'border-b border-l',
472
+ placement === 'left' && 'border-r border-t',
473
+ className
474
+ )}
475
+ {...props}
476
+ />
477
+ );
478
+ }
479
+
480
+ const PopoverTitle = ({ className, ...props }: TextProps) => (
481
+ <Text
482
+ size="base"
483
+ weight="semibold"
484
+ className={cn('text-popover-foreground', className)}
485
+ {...props}
486
+ />
487
+ );
488
+ PopoverTitle.displayName = 'Popover.Title';
489
+
490
+ const PopoverDescription = ({ className, ...props }: TextProps) => (
491
+ <Text size="sm" muted className={className} {...props} />
492
+ );
493
+ PopoverDescription.displayName = 'Popover.Description';
494
+
495
+ export interface PopoverCloseProps {
496
+ children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
497
+ }
498
+
499
+ /** Wraps its child and closes the popover on press. */
500
+ function PopoverClose({ children }: PopoverCloseProps) {
501
+ const { setOpen } = usePopover('Popover.Close');
502
+ if (!isValidElement(children)) return children;
503
+
504
+ return cloneElement(children, {
505
+ onPress: (...args: unknown[]) => {
506
+ children.props.onPress?.(...args);
507
+ setOpen(false);
508
+ },
509
+ });
510
+ }
511
+
512
+ /* -------------------------------------------------------------------------- */
513
+ /* Placement */
514
+ /* -------------------------------------------------------------------------- */
515
+
516
+ interface PlaceArgs {
517
+ trigger: TriggerRect;
518
+ size: { width: number; height: number };
519
+ placement: PopoverPlacement;
520
+ align: PopoverAlign;
521
+ offset: number;
522
+ alignOffset: number;
523
+ bounds: { left: number; right: number; top: number; bottom: number };
524
+ }
525
+
526
+ /**
527
+ * Resolves the panel's window position.
528
+ *
529
+ * Two passes, in this order, because they answer different questions: the flip
530
+ * picks a *side* and only fires when the preferred one genuinely has less room
531
+ * than its opposite; the clamp then slides the panel along the other axis to
532
+ * keep it on screen. Doing the clamp first would let a panel be nudged inside
533
+ * the bounds and so look like it fits, hiding the fact that the wrong side was
534
+ * chosen.
535
+ */
536
+ function place({ trigger, size, placement, align, offset, alignOffset, bounds }: PlaceArgs) {
537
+ const roomAfter = {
538
+ bottom: bounds.bottom - (trigger.y + trigger.height + offset),
539
+ top: trigger.y - offset - bounds.top,
540
+ right: bounds.right - (trigger.x + trigger.width + offset),
541
+ left: trigger.x - offset - bounds.left,
542
+ };
543
+ const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' } as const;
544
+
545
+ const needed = placement === 'top' || placement === 'bottom' ? size.height : size.width;
546
+ const resolved =
547
+ roomAfter[placement] < needed && roomAfter[opposite[placement]] > roomAfter[placement]
548
+ ? opposite[placement]
549
+ : placement;
550
+
551
+ const clamp = (value: number, min: number, max: number) =>
552
+ // `max < min` when the panel is wider than the space it has; pinning to the
553
+ // start edge at least keeps its beginning readable.
554
+ Math.max(min, Math.min(value, Math.max(min, max)));
555
+
556
+ // The arrow points at the trigger's centre, not the panel's — those differ
557
+ // whenever `align` is not center, or the panel was clamped inside the screen.
558
+ // It is expressed relative to the panel origin and clamped so it can never
559
+ // slide off the panel edge.
560
+ if (resolved === 'top' || resolved === 'bottom') {
561
+ const top =
562
+ resolved === 'bottom'
563
+ ? trigger.y + trigger.height + offset
564
+ : trigger.y - size.height - offset;
565
+
566
+ const left =
567
+ align === 'start'
568
+ ? trigger.x + alignOffset
569
+ : align === 'end'
570
+ ? trigger.x + trigger.width - size.width + alignOffset
571
+ : trigger.x + trigger.width / 2 - size.width / 2 + alignOffset;
572
+
573
+ const clampedLeft = clamp(left, bounds.left, bounds.right - size.width);
574
+ const triggerCentreX = trigger.x + trigger.width / 2;
575
+
576
+ return {
577
+ placement: resolved,
578
+ top: clamp(top, bounds.top, bounds.bottom - size.height),
579
+ left: clampedLeft,
580
+ arrowOffset: clamp(triggerCentreX - clampedLeft, ARROW_SIZE, size.width - ARROW_SIZE),
581
+ };
582
+ }
583
+
584
+ const left =
585
+ resolved === 'right' ? trigger.x + trigger.width + offset : trigger.x - size.width - offset;
586
+
587
+ const top =
588
+ align === 'start'
589
+ ? trigger.y + alignOffset
590
+ : align === 'end'
591
+ ? trigger.y + trigger.height - size.height + alignOffset
592
+ : trigger.y + trigger.height / 2 - size.height / 2 + alignOffset;
593
+
594
+ const clampedTop = clamp(top, bounds.top, bounds.bottom - size.height);
595
+ const triggerCentreY = trigger.y + trigger.height / 2;
596
+
597
+ return {
598
+ placement: resolved,
599
+ top: clampedTop,
600
+ left: clamp(left, bounds.left, bounds.right - size.width),
601
+ arrowOffset: clamp(triggerCentreY - clampedTop, ARROW_SIZE, size.height - ARROW_SIZE),
602
+ };
603
+ }
604
+
605
+ /**
606
+ * Where the panel starts, relative to where it ends: towards the trigger, on
607
+ * whichever side was resolved. A preset like `ZoomIn` always grows from the
608
+ * centre, which reads the same whichever way the panel opened.
609
+ */
610
+ const ENTRY_SHIFT: Record<PopoverPlacement, { x: number; y: number }> = {
611
+ bottom: { x: 0, y: -8 },
612
+ top: { x: 0, y: 8 },
613
+ right: { x: -8, y: 0 },
614
+ left: { x: 8, y: 0 },
615
+ };
616
+
617
+ export const Popover = Object.assign(PopoverRoot, {
618
+ Trigger: PopoverTrigger,
619
+ Content: PopoverContent,
620
+ Arrow: PopoverArrow,
621
+ Title: PopoverTitle,
622
+ Description: PopoverDescription,
623
+ Close: PopoverClose,
624
+ });