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.
- package/README.md +5 -3
- package/lib/module/components/attachment/index.js +219 -0
- package/lib/module/components/attachment/index.js.map +1 -0
- package/lib/module/components/bottom-sheet/index.js +53 -3
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +19 -1
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/dialog/index.js +6 -2
- package/lib/module/components/dialog/index.js.map +1 -1
- package/lib/module/components/empty-state/index.js +94 -17
- package/lib/module/components/empty-state/index.js.map +1 -1
- package/lib/module/components/frame/index.js +128 -15
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/input/index.js +107 -15
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/line-chart/index.js +1024 -0
- package/lib/module/components/line-chart/index.js.map +1 -0
- package/lib/module/components/marker/index.js +190 -0
- package/lib/module/components/marker/index.js.map +1 -0
- package/lib/module/components/message/index.js +24 -8
- package/lib/module/components/message/index.js.map +1 -1
- package/lib/module/components/message-scroller/index.js +451 -0
- package/lib/module/components/message-scroller/index.js.map +1 -0
- package/lib/module/components/popover/index.js +511 -0
- package/lib/module/components/popover/index.js.map +1 -0
- package/lib/module/components/progress/index.js +52 -2
- package/lib/module/components/progress/index.js.map +1 -1
- package/lib/module/components/radio-group/index.js +132 -17
- package/lib/module/components/radio-group/index.js.map +1 -1
- package/lib/module/components/section-rail/index.js +355 -0
- package/lib/module/components/section-rail/index.js.map +1 -0
- package/lib/module/components/select/index.js +245 -56
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/separator/index.js +91 -0
- package/lib/module/components/separator/index.js.map +1 -0
- package/lib/module/components/slider/index.js +355 -0
- package/lib/module/components/slider/index.js.map +1 -0
- package/lib/module/components/surface/index.js +21 -2
- package/lib/module/components/surface/index.js.map +1 -1
- package/lib/module/components/switch/index.js +10 -1
- package/lib/module/components/switch/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +186 -16
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/toggle-button/index.js +297 -0
- package/lib/module/components/toggle-button/index.js.map +1 -0
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-keyboard-avoidance.js +57 -8
- package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
- package/lib/module/hooks/use-scroll-sections.js +89 -0
- package/lib/module/hooks/use-scroll-sections.js.map +1 -0
- package/lib/module/icons/index.js +69 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +11 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js.map +1 -1
- package/lib/module/primitives/keyboard-avoider.js +2 -0
- package/lib/module/primitives/keyboard-avoider.js.map +1 -1
- package/lib/module/primitives/scrim.js +72 -0
- package/lib/module/primitives/scrim.js.map +1 -0
- package/lib/module/utils/haptics.js +57 -0
- package/lib/module/utils/haptics.js.map +1 -0
- package/lib/typescript/src/components/attachment/index.d.ts +93 -0
- package/lib/typescript/src/components/attachment/index.d.ts.map +1 -0
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +6 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +9 -4
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/dialog/index.d.ts +7 -1
- package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
- package/lib/typescript/src/components/empty-state/index.d.ts +152 -2
- package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
- package/lib/typescript/src/components/frame/index.d.ts +47 -7
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
- package/lib/typescript/src/components/input/index.d.ts +113 -2
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts +247 -0
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/marker/index.d.ts +137 -0
- package/lib/typescript/src/components/marker/index.d.ts.map +1 -0
- package/lib/typescript/src/components/message/index.d.ts +8 -1
- package/lib/typescript/src/components/message/index.d.ts.map +1 -1
- package/lib/typescript/src/components/message-scroller/index.d.ts +150 -0
- package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -0
- package/lib/typescript/src/components/popover/index.d.ts +136 -0
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -0
- package/lib/typescript/src/components/progress/index.d.ts +33 -0
- package/lib/typescript/src/components/progress/index.d.ts.map +1 -1
- package/lib/typescript/src/components/radio-group/index.d.ts +39 -0
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/section-rail/index.d.ts +115 -0
- package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts +46 -7
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/separator/index.d.ts +47 -0
- package/lib/typescript/src/components/separator/index.d.ts.map +1 -0
- package/lib/typescript/src/components/slider/index.d.ts +203 -0
- package/lib/typescript/src/components/slider/index.d.ts.map +1 -0
- package/lib/typescript/src/components/surface/index.d.ts +51 -2
- package/lib/typescript/src/components/surface/index.d.ts.map +1 -1
- package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts +46 -4
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toggle-button/index.d.ts +244 -0
- package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/index.d.ts +1 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +10 -1
- package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts +31 -0
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +6 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +13 -4
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +16 -1
- package/lib/typescript/src/native/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts +8 -1
- package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
- package/lib/typescript/src/primitives/scrim.d.ts +21 -0
- package/lib/typescript/src/primitives/scrim.d.ts.map +1 -0
- package/lib/typescript/src/utils/haptics.d.ts +12 -0
- package/lib/typescript/src/utils/haptics.d.ts.map +1 -0
- package/package.json +9 -1
- package/src/components/attachment/index.tsx +279 -0
- package/src/components/bottom-sheet/index.tsx +58 -2
- package/src/components/button/index.tsx +26 -5
- package/src/components/dialog/index.tsx +10 -1
- package/src/components/empty-state/index.tsx +60 -14
- package/src/components/frame/index.tsx +175 -29
- package/src/components/input/index.tsx +122 -16
- package/src/components/line-chart/index.tsx +1224 -0
- package/src/components/marker/index.tsx +194 -0
- package/src/components/message/index.tsx +29 -5
- package/src/components/message-scroller/index.tsx +574 -0
- package/src/components/popover/index.tsx +624 -0
- package/src/components/progress/index.tsx +81 -1
- package/src/components/radio-group/index.tsx +156 -18
- package/src/components/section-rail/index.tsx +459 -0
- package/src/components/select/index.tsx +265 -52
- package/src/components/separator/index.tsx +97 -0
- package/src/components/slider/index.tsx +371 -0
- package/src/components/surface/index.tsx +29 -3
- package/src/components/switch/index.tsx +8 -1
- package/src/components/tabs/index.tsx +208 -28
- package/src/components/toggle-button/index.tsx +374 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/use-keyboard-avoidance.ts +73 -7
- package/src/hooks/use-scroll-sections.ts +127 -0
- package/src/icons/index.tsx +48 -0
- package/src/index.ts +97 -6
- package/src/native/index.ts +13 -1
- package/src/primitives/keyboard-avoider.tsx +9 -1
- package/src/primitives/scrim.tsx +95 -0
- package/src/utils/haptics.ts +63 -0
- package/theme.css +64 -2
- package/lib/module/components/inline-select/index.js +0 -284
- package/lib/module/components/inline-select/index.js.map +0 -1
- package/lib/typescript/src/components/inline-select/index.d.ts +0 -48
- package/lib/typescript/src/components/inline-select/index.d.ts.map +0 -1
- 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
|
+
});
|