panelui-native 0.51.1 → 0.54.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 +4 -1
- package/lib/module/components/candlestick-chart/index.js +1161 -0
- package/lib/module/components/candlestick-chart/index.js.map +1 -0
- package/lib/module/components/combobox/index.js +73 -7
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/context-menu/index.js +529 -0
- package/lib/module/components/context-menu/index.js.map +1 -0
- package/lib/module/components/menu/index.js +18 -10
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/popover/index.js +71 -7
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/sortable/index.js +159 -23
- package/lib/module/components/sortable/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +56 -15
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/time-picker/index.js +295 -31
- package/lib/module/components/time-picker/index.js.map +1 -1
- package/lib/module/index.js +3 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/candlestick-chart/index.d.ts +278 -0
- package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/context-menu/index.d.ts +270 -0
- package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -0
- package/lib/typescript/src/components/menu/index.d.ts +20 -20
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/popover/index.d.ts +51 -1
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
- package/lib/typescript/src/components/sortable/index.d.ts +13 -3
- package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts +34 -2
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/candlestick-chart/index.tsx +1360 -0
- package/src/components/combobox/index.tsx +149 -18
- package/src/components/context-menu/index.tsx +658 -0
- package/src/components/menu/index.tsx +17 -10
- package/src/components/popover/index.tsx +94 -6
- package/src/components/sortable/index.tsx +278 -36
- package/src/components/tabs/index.tsx +82 -16
- package/src/components/tag-input/index.tsx +619 -0
- package/src/components/time-picker/index.tsx +330 -35
- package/src/index.ts +35 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContextMenu — the actions that belong to a piece of content, opened on it.
|
|
3
|
+
*
|
|
4
|
+
* A `Menu` hangs off a control that exists to be pressed: a ⋯ button, a toolbar
|
|
5
|
+
* item, something whose whole job is to open the menu. A context menu has no
|
|
6
|
+
* such control. The target is the content itself — a message, a note, a photo,
|
|
7
|
+
* a row — and the actions are reached by pressing and holding it.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* <ContextMenu>
|
|
11
|
+
* <ContextMenu.Trigger>
|
|
12
|
+
* <Message>Would you like an interactive todo list?</Message>
|
|
13
|
+
* </ContextMenu.Trigger>
|
|
14
|
+
* <ContextMenu.Content>
|
|
15
|
+
* <ContextMenu.Item icon={<Share2 size={16} />}>Share</ContextMenu.Item>
|
|
16
|
+
* <ContextMenu.Item icon={<Copy size={16} />}>Copy</ContextMenu.Item>
|
|
17
|
+
* <ContextMenu.Separator />
|
|
18
|
+
* <ContextMenu.Item variant="destructive" icon={<Flag size={16} />}>
|
|
19
|
+
* Report
|
|
20
|
+
* </ContextMenu.Item>
|
|
21
|
+
* </ContextMenu.Content>
|
|
22
|
+
* </ContextMenu>
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* ## It is a Menu, and deliberately so
|
|
26
|
+
*
|
|
27
|
+
* The rows here *are* `Menu`'s rows — the same components, not a second set
|
|
28
|
+
* styled to match. `ContextMenu.Item` and `Menu.Item` are one implementation,
|
|
29
|
+
* so the destructive colour, the press-in scale, the indicator column and the
|
|
30
|
+
* dismiss-on-select rule cannot drift apart between the two ways of reaching
|
|
31
|
+
* them. The panel is `Menu`'s panel, which is `Popover`'s, so `presentation`,
|
|
32
|
+
* submenus and edge-flipping all arrive already working.
|
|
33
|
+
*
|
|
34
|
+
* What this component owns is the two things a menu opened on content needs and
|
|
35
|
+
* a menu opened from a button does not: the long press, and where the panel
|
|
36
|
+
* goes.
|
|
37
|
+
*
|
|
38
|
+
* ## Anchored to the finger, not to the target
|
|
39
|
+
*
|
|
40
|
+
* A toolbar menu is placed against its trigger, because the trigger is small
|
|
41
|
+
* and its position is the only sensible answer. A context menu's target is
|
|
42
|
+
* often most of the screen — a whole message, a whole card — and the middle of
|
|
43
|
+
* it is not where the finger was. So the anchor is the press point by default,
|
|
44
|
+
* and the panel unfolds from it the way a popover unfolds from a button.
|
|
45
|
+
*
|
|
46
|
+
* `anchor="target"` places it against the target's bounds instead, which is the
|
|
47
|
+
* better answer for something small and list-shaped, where the panel lining up
|
|
48
|
+
* with the row reads as belonging to it.
|
|
49
|
+
*
|
|
50
|
+
* ## Why the gesture is not a Pressable
|
|
51
|
+
*
|
|
52
|
+
* The target usually has a press of its own — open the thread, play the video,
|
|
53
|
+
* follow the link — and the two must not both fire. React Native's `Pressable`
|
|
54
|
+
* decides between them after the fact, and the tap can still get through on the
|
|
55
|
+
* way to a long press; the recogniser here is asked for the arbitration up
|
|
56
|
+
* front instead, so a hold that opens the menu never also counts as a press.
|
|
57
|
+
*
|
|
58
|
+
* It is also what lets the target be anything at all. A cloned `onLongPress`
|
|
59
|
+
* needs a child that takes one, which rules out exactly the plain views —
|
|
60
|
+
* bubbles, cards, images — that content-native actions are usually attached to.
|
|
61
|
+
*/
|
|
62
|
+
import {
|
|
63
|
+
Children,
|
|
64
|
+
cloneElement,
|
|
65
|
+
createContext,
|
|
66
|
+
isValidElement,
|
|
67
|
+
useCallback,
|
|
68
|
+
useContext,
|
|
69
|
+
useEffect,
|
|
70
|
+
useMemo,
|
|
71
|
+
useRef,
|
|
72
|
+
useState,
|
|
73
|
+
type ReactElement,
|
|
74
|
+
type ReactNode,
|
|
75
|
+
} from 'react';
|
|
76
|
+
import { View, type ViewProps } from 'react-native';
|
|
77
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
78
|
+
import Animated, {
|
|
79
|
+
FadeOut,
|
|
80
|
+
runOnJS,
|
|
81
|
+
useAnimatedStyle,
|
|
82
|
+
useReducedMotion,
|
|
83
|
+
useSharedValue,
|
|
84
|
+
withSpring,
|
|
85
|
+
} from 'react-native-reanimated';
|
|
86
|
+
import { useCSSVariable } from 'uniwind';
|
|
87
|
+
import { Portal } from '../../primitives/portal';
|
|
88
|
+
import {
|
|
89
|
+
Menu,
|
|
90
|
+
type MenuCheckboxItemProps,
|
|
91
|
+
type MenuContentProps,
|
|
92
|
+
type MenuItemProps,
|
|
93
|
+
type MenuProps,
|
|
94
|
+
} from '../menu';
|
|
95
|
+
import { usePopoverAnchor } from '../popover';
|
|
96
|
+
import { cn } from '../../utils/cn';
|
|
97
|
+
import { selectionTick } from '../../utils/haptics';
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* How long the target is held before the menu opens.
|
|
101
|
+
*
|
|
102
|
+
* Long enough not to fire while a finger is on its way to a scroll, short
|
|
103
|
+
* enough that nobody wonders whether the hold is working. The platforms sit
|
|
104
|
+
* either side of this; it is the value the rest of this library holds at.
|
|
105
|
+
*/
|
|
106
|
+
const DEFAULT_DELAY = 350;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* How far the finger may travel during the hold before it stops counting.
|
|
110
|
+
*
|
|
111
|
+
* Generous rather than tight, and the reason is what the target usually sits
|
|
112
|
+
* in: a scroller. A threshold small enough to feel precise cancels the menu for
|
|
113
|
+
* anyone whose thumb drifts while holding still, and the gesture it is being
|
|
114
|
+
* told apart from — a scroll — has moved a great deal further than this by the
|
|
115
|
+
* time it matters.
|
|
116
|
+
*/
|
|
117
|
+
const DEFAULT_SLOP = 12;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Extra height on every row.
|
|
121
|
+
*
|
|
122
|
+
* A context menu is opened by a hold and read with the hand still over it, at
|
|
123
|
+
* whatever angle the phone happened to be held at. A menu dropped from a button
|
|
124
|
+
* is aimed at deliberately; this one is landed on. The rows are taller than
|
|
125
|
+
* `Menu`'s for that reason alone, and it is the only measurement that differs.
|
|
126
|
+
*/
|
|
127
|
+
const ROW_CLASS = 'py-4 ps-4 pe-3.5';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Floor for the panel's width.
|
|
131
|
+
*
|
|
132
|
+
* A context menu has no trigger to take a width from, and a column of one-word
|
|
133
|
+
* verbs left to size itself lands somewhere around a thumb's width — too narrow
|
|
134
|
+
* to aim at, and too narrow to read as a panel belonging to the whole piece of
|
|
135
|
+
* content it was opened on.
|
|
136
|
+
*/
|
|
137
|
+
const DEFAULT_MIN_WIDTH = 280;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The colour an icon falls back to before the stylesheet has been read.
|
|
141
|
+
*
|
|
142
|
+
* A general-purpose icon set defaults an unset colour to `currentColor`, which
|
|
143
|
+
* React Native cannot resolve and refuses to paint. A neutral mid grey is
|
|
144
|
+
* legible on either a light or a dark panel for the frame or two it lasts.
|
|
145
|
+
*/
|
|
146
|
+
const ICON_FALLBACK = '#737373';
|
|
147
|
+
|
|
148
|
+
function useTint(variable: string, fallback: string): string {
|
|
149
|
+
const raw = useCSSVariable(variable);
|
|
150
|
+
return typeof raw === 'string' ? raw : fallback;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Paints a row's glyph to match its label, without the caller saying so twice.
|
|
155
|
+
*
|
|
156
|
+
* The icons on these rows come from whatever set the app already uses, and a
|
|
157
|
+
* general-purpose one has no idea what an overlay's foreground is — left alone
|
|
158
|
+
* it paints `currentColor`, which React Native will not draw at all. Setting it
|
|
159
|
+
* here means a destructive row's icon turns red along with its label, which is
|
|
160
|
+
* the one place the two disagreeing would matter.
|
|
161
|
+
*
|
|
162
|
+
* An explicit colour on the element still wins: a brand mark that carries its
|
|
163
|
+
* own colours is not something to overrule.
|
|
164
|
+
*/
|
|
165
|
+
function useGlyph(variant: 'default' | 'destructive' | undefined) {
|
|
166
|
+
const foreground = useTint('--color-overlay-foreground', ICON_FALLBACK);
|
|
167
|
+
const destructive = useTint('--color-destructive', ICON_FALLBACK);
|
|
168
|
+
const tint = variant === 'destructive' ? destructive : foreground;
|
|
169
|
+
|
|
170
|
+
return useCallback(
|
|
171
|
+
(icon: ReactNode): ReactNode => {
|
|
172
|
+
if (!isValidElement(icon)) return icon;
|
|
173
|
+
const element = icon as ReactElement<{ color?: string }>;
|
|
174
|
+
if (element.props.color !== undefined) return element;
|
|
175
|
+
return cloneElement(element, { color: tint });
|
|
176
|
+
},
|
|
177
|
+
[tint]
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The target's frame in window coordinates, taken as the menu opens. */
|
|
182
|
+
interface TargetRect {
|
|
183
|
+
x: number;
|
|
184
|
+
y: number;
|
|
185
|
+
width: number;
|
|
186
|
+
height: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface ContextMenuContextValue {
|
|
190
|
+
/** Where the target was when it was held, or `null` before anything has. */
|
|
191
|
+
target: TargetRect | null;
|
|
192
|
+
setTarget: (rect: TargetRect | null) => void;
|
|
193
|
+
/** The trigger's children, so the preview can draw the same thing again. */
|
|
194
|
+
content: ReactNode;
|
|
195
|
+
setContent: (node: ReactNode) => void;
|
|
196
|
+
/** Whether a `ContextMenu.Preview` was declared inside the panel. */
|
|
197
|
+
hasPreview: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const ContextMenuContext = createContext<ContextMenuContextValue | null>(null);
|
|
201
|
+
|
|
202
|
+
function useContextMenu(part: string): ContextMenuContextValue {
|
|
203
|
+
const context = useContext(ContextMenuContext);
|
|
204
|
+
if (!context) throw new Error(`${part} must be used inside <ContextMenu>.`);
|
|
205
|
+
return context;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Whether a `ContextMenu.Preview` appears anywhere in the declared tree.
|
|
210
|
+
*
|
|
211
|
+
* Sniffed from the elements rather than reported by the preview when it mounts,
|
|
212
|
+
* because the answer is needed *before* it does: the trigger has to know at the
|
|
213
|
+
* moment of the hold whether the panel will be sharing the screen with a lifted
|
|
214
|
+
* copy of the target, and by the time a child of the panel exists the anchor has
|
|
215
|
+
* already been set.
|
|
216
|
+
*/
|
|
217
|
+
function declaresPreview(children: ReactNode): boolean {
|
|
218
|
+
let found = false;
|
|
219
|
+
Children.forEach(children, (child) => {
|
|
220
|
+
if (found || !isValidElement(child)) return;
|
|
221
|
+
if (child.type === ContextMenuPreview) {
|
|
222
|
+
found = true;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const inner = (child.props as { children?: ReactNode }).children;
|
|
226
|
+
if (inner && declaresPreview(inner)) found = true;
|
|
227
|
+
});
|
|
228
|
+
return found;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The root takes exactly what `Menu`'s root takes — `open`, `onOpenChange`,
|
|
233
|
+
* `defaultOpen`, `presentation` and `haptics` — because it *is* that root.
|
|
234
|
+
*/
|
|
235
|
+
export type ContextMenuProps = MenuProps;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The root. Provides the menu's own context and the popover underneath it.
|
|
239
|
+
*
|
|
240
|
+
* It renders a `Menu`, which is not a shortcut — it is the point. Everything a
|
|
241
|
+
* menu is, this is, and the parts below are the only difference.
|
|
242
|
+
*/
|
|
243
|
+
function ContextMenuRoot({ children, ...props }: ContextMenuProps) {
|
|
244
|
+
const [target, setTarget] = useState<TargetRect | null>(null);
|
|
245
|
+
const [content, setContent] = useState<ReactNode>(null);
|
|
246
|
+
const hasPreview = useMemo(() => declaresPreview(children), [children]);
|
|
247
|
+
|
|
248
|
+
const context = useMemo<ContextMenuContextValue>(
|
|
249
|
+
() => ({ target, setTarget, content, setContent, hasPreview }),
|
|
250
|
+
[target, content, hasPreview]
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<ContextMenuContext.Provider value={context}>
|
|
255
|
+
<Menu {...props}>{children}</Menu>
|
|
256
|
+
</ContextMenuContext.Provider>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Which rectangle the panel is placed against. */
|
|
261
|
+
export type ContextMenuAnchor = 'point' | 'target';
|
|
262
|
+
|
|
263
|
+
export interface ContextMenuTriggerProps extends Omit<ViewProps, 'children'> {
|
|
264
|
+
/**
|
|
265
|
+
* Classes on the wrapper the content sits in, which lays out like any other
|
|
266
|
+
* view — it does not shrink to its child, because the things held are usually
|
|
267
|
+
* meant to fill their place in the layout. It is also the rect
|
|
268
|
+
* `anchor="target"` measures.
|
|
269
|
+
*/
|
|
270
|
+
className?: string;
|
|
271
|
+
/**
|
|
272
|
+
* The content the actions belong to. Anything at all — it is not required to
|
|
273
|
+
* be pressable, and is not cloned or altered.
|
|
274
|
+
*/
|
|
275
|
+
children: ReactNode;
|
|
276
|
+
/**
|
|
277
|
+
* `point` anchors the panel where the finger landed, `target` against the
|
|
278
|
+
* bounds of the whole trigger.
|
|
279
|
+
*
|
|
280
|
+
* Point is the default because a context menu's target is usually large, and
|
|
281
|
+
* the middle of a whole message is not where the press was. Reach for
|
|
282
|
+
* `target` when the target is small and list-shaped and the panel should read
|
|
283
|
+
* as lining up with it.
|
|
284
|
+
*/
|
|
285
|
+
anchor?: ContextMenuAnchor;
|
|
286
|
+
/** How long the hold has to last, in milliseconds. 350 by default. */
|
|
287
|
+
delay?: number;
|
|
288
|
+
/**
|
|
289
|
+
* How far the finger may move during the hold before it stops being one, in
|
|
290
|
+
* points. 12 by default.
|
|
291
|
+
*
|
|
292
|
+
* Loose rather than tight, because the target is usually inside a scroller: a
|
|
293
|
+
* threshold small enough to feel precise cancels the menu for anyone whose
|
|
294
|
+
* thumb drifts while holding still, and a scroll has travelled much further
|
|
295
|
+
* than this by the time the two need telling apart. Tighten it only for a
|
|
296
|
+
* target that cannot be scrolled.
|
|
297
|
+
*/
|
|
298
|
+
slop?: number;
|
|
299
|
+
/** A short press on the target, which the hold never also counts as. */
|
|
300
|
+
onPress?: () => void;
|
|
301
|
+
/**
|
|
302
|
+
* Tick the haptic engine as the menu opens. Needs the optional
|
|
303
|
+
* `expo-haptics`, and is silent without it.
|
|
304
|
+
*
|
|
305
|
+
* Worth setting more often than not. A hold has no edge to it the way a press
|
|
306
|
+
* does — nothing moves under the finger at the moment it takes — so the tick
|
|
307
|
+
* is what tells someone the hold has been long enough, before the panel has
|
|
308
|
+
* had time to say so.
|
|
309
|
+
*/
|
|
310
|
+
haptics?: boolean;
|
|
311
|
+
/** Nothing opens the menu, and the short press stops firing too. */
|
|
312
|
+
disabled?: boolean;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Wraps the content and opens the menu when it is held.
|
|
317
|
+
*
|
|
318
|
+
* The wrapper is a plain view and lays out like one, stretching as a view does
|
|
319
|
+
* rather than shrinking to its child. That is the opposite of what a tooltip's
|
|
320
|
+
* trigger wants, and for the opposite reason: a tooltip names a control and
|
|
321
|
+
* belongs over it, while the things held here — a bubble, a card, a row — are
|
|
322
|
+
* usually meant to fill their place in the layout, and a wrapper that collapsed
|
|
323
|
+
* around them would change it.
|
|
324
|
+
*
|
|
325
|
+
* It is also the rect measured under `anchor="target"`, which is why that
|
|
326
|
+
* anchoring lines the panel up with the row rather than with the text in it.
|
|
327
|
+
*/
|
|
328
|
+
function ContextMenuTrigger({
|
|
329
|
+
className,
|
|
330
|
+
children,
|
|
331
|
+
anchor = 'point',
|
|
332
|
+
delay = DEFAULT_DELAY,
|
|
333
|
+
slop = DEFAULT_SLOP,
|
|
334
|
+
onPress,
|
|
335
|
+
haptics = false,
|
|
336
|
+
disabled = false,
|
|
337
|
+
...props
|
|
338
|
+
}: ContextMenuTriggerProps) {
|
|
339
|
+
const { setOpen, anchorTo } = usePopoverAnchor('ContextMenu.Trigger');
|
|
340
|
+
const { setTarget, setContent, hasPreview } = useContextMenu('ContextMenu.Trigger');
|
|
341
|
+
const ref = useRef<View>(null);
|
|
342
|
+
|
|
343
|
+
/*
|
|
344
|
+
* A preview overrules `anchor`, and has to.
|
|
345
|
+
*
|
|
346
|
+
* The panel is placed outside whatever rectangle it is given, so anchoring to
|
|
347
|
+
* the target is what keeps it clear of the lifted copy of that target. Anchor
|
|
348
|
+
* to the press instead and the panel opens over the very thing the preview
|
|
349
|
+
* exists to show.
|
|
350
|
+
*/
|
|
351
|
+
const against: ContextMenuAnchor = hasPreview ? 'target' : anchor;
|
|
352
|
+
|
|
353
|
+
/*
|
|
354
|
+
* Both branches end up doing the same thing — set an anchor, then open — and
|
|
355
|
+
* differ only in which rectangle they set. A point is a zero-sized rect,
|
|
356
|
+
* which the popover places a panel against exactly as it does a trigger's
|
|
357
|
+
* bounds; there is no separate code path for it downstream.
|
|
358
|
+
*
|
|
359
|
+
* The target is measured either way, because the preview draws at that rect
|
|
360
|
+
* whatever the panel is placed against.
|
|
361
|
+
*/
|
|
362
|
+
const openAt = useCallback(
|
|
363
|
+
(x: number, y: number) => {
|
|
364
|
+
// Ticked here rather than in the gesture callback so it fires once the
|
|
365
|
+
// hold has been accepted, which is the moment there is something to
|
|
366
|
+
// confirm — and on the same side of the bridge as the opening.
|
|
367
|
+
if (haptics) selectionTick();
|
|
368
|
+
|
|
369
|
+
// Measured on opening rather than on layout: the target may have
|
|
370
|
+
// scrolled since, and a stale rect anchors the panel to where it was.
|
|
371
|
+
ref.current?.measureInWindow((mx, my, width, height) => {
|
|
372
|
+
setTarget({ x: mx, y: my, width, height });
|
|
373
|
+
setContent(children);
|
|
374
|
+
|
|
375
|
+
/*
|
|
376
|
+
* The panel is placed outside the rectangle it is given, so under a
|
|
377
|
+
* preview that rectangle has to be the target's *lifted* bounds rather
|
|
378
|
+
* than its resting ones. The lift grows the target about its middle,
|
|
379
|
+
* and anchoring to the smaller rect left the panel overlapping the
|
|
380
|
+
* last few points of it — where the lifted copy, drawn over the panel,
|
|
381
|
+
* simply hid the row underneath.
|
|
382
|
+
*/
|
|
383
|
+
const grown = hasPreview ? (PREVIEW_SCALE - 1) / 2 : 0;
|
|
384
|
+
anchorTo(
|
|
385
|
+
against === 'point'
|
|
386
|
+
? { x, y, width: 0, height: 0 }
|
|
387
|
+
: {
|
|
388
|
+
x: mx - width * grown,
|
|
389
|
+
y: my - height * grown,
|
|
390
|
+
width: width * (1 + grown * 2),
|
|
391
|
+
height: height * (1 + grown * 2),
|
|
392
|
+
}
|
|
393
|
+
);
|
|
394
|
+
setOpen(true);
|
|
395
|
+
});
|
|
396
|
+
},
|
|
397
|
+
[against, anchorTo, setOpen, haptics, setTarget, setContent, children, hasPreview]
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
const gesture = useMemo(() => {
|
|
401
|
+
/*
|
|
402
|
+
* The hold and the tap are given to the recogniser as alternatives, so it
|
|
403
|
+
* decides between them rather than both firing. That is the whole reason
|
|
404
|
+
* this is a gesture and not a `Pressable`: the target below usually has a
|
|
405
|
+
* press of its own, and a hold that opened the menu must not also count as
|
|
406
|
+
* one.
|
|
407
|
+
*
|
|
408
|
+
* `absoluteX`/`absoluteY` are window coordinates, which is the space the
|
|
409
|
+
* popover places panels in — so the press point needs no conversion.
|
|
410
|
+
*/
|
|
411
|
+
const hold = Gesture.LongPress()
|
|
412
|
+
.minDuration(delay)
|
|
413
|
+
.maxDistance(slop)
|
|
414
|
+
.enabled(!disabled)
|
|
415
|
+
.onStart((event) => {
|
|
416
|
+
runOnJS(openAt)(event.absoluteX, event.absoluteY);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const tap = Gesture.Tap()
|
|
420
|
+
.enabled(!disabled && !!onPress)
|
|
421
|
+
.onEnd((_event, success) => {
|
|
422
|
+
if (success && onPress) runOnJS(onPress)();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
return Gesture.Exclusive(hold, tap);
|
|
426
|
+
}, [delay, slop, disabled, onPress, openAt]);
|
|
427
|
+
|
|
428
|
+
return (
|
|
429
|
+
<GestureDetector gesture={gesture}>
|
|
430
|
+
{/*
|
|
431
|
+
`collapsable={false}` keeps the wrapper as a real view on Android, where
|
|
432
|
+
a view that only groups children is otherwise flattened away — and a
|
|
433
|
+
flattened view cannot be measured, which `anchor="target"` needs.
|
|
434
|
+
*/}
|
|
435
|
+
<View ref={ref} collapsable={false} className={className} {...props}>
|
|
436
|
+
{children}
|
|
437
|
+
</View>
|
|
438
|
+
</GestureDetector>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** How far the lifted target grows. Enough to read as off the page. */
|
|
443
|
+
const PREVIEW_SCALE = 1.04;
|
|
444
|
+
|
|
445
|
+
/** Coming off the page. Soft, because the target is large and close to the eye. */
|
|
446
|
+
const PREVIEW_SPRING = { damping: 20, stiffness: 220, mass: 0.7 } as const;
|
|
447
|
+
|
|
448
|
+
export interface ContextMenuPreviewProps {
|
|
449
|
+
/**
|
|
450
|
+
* Drawn instead of the target itself. For a target that would be wrong to
|
|
451
|
+
* repeat — one carrying a video, a live map, a text field with a cursor in
|
|
452
|
+
* it — or one that should show more of itself once it has the screen.
|
|
453
|
+
*
|
|
454
|
+
* Left out, the target is drawn again as it stands, which is what makes the
|
|
455
|
+
* lift read as the content coming forward rather than as a picture of it
|
|
456
|
+
* appearing.
|
|
457
|
+
*/
|
|
458
|
+
children?: ReactNode;
|
|
459
|
+
/** Extra classes on the lifted copy. */
|
|
460
|
+
className?: string;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The target, lifted off the page while its actions are up.
|
|
465
|
+
*
|
|
466
|
+
* Declared inside `ContextMenu.Content`, but not drawn there — it floats over
|
|
467
|
+
* the dimmed screen at the place the target was measured, and the panel is
|
|
468
|
+
* anchored to that same rectangle so the two never overlap. Its presence is
|
|
469
|
+
* what switches the anchor: a panel placed at the press point would open across
|
|
470
|
+
* the very content the preview exists to hold up.
|
|
471
|
+
*
|
|
472
|
+
* What it draws is the trigger's own children, rendered a second time. That
|
|
473
|
+
* keeps the lift honest — it is the content itself coming forward, at the size
|
|
474
|
+
* and in the place it already occupied — and it is why a target that should not
|
|
475
|
+
* simply be repeated can pass its own `children` instead.
|
|
476
|
+
*
|
|
477
|
+
* It takes no touches. The actions are in the panel; the lifted content is
|
|
478
|
+
* there to say what they are about, and a second live copy of a pressable card
|
|
479
|
+
* would be a second place to press.
|
|
480
|
+
*/
|
|
481
|
+
function ContextMenuPreview({ children, className }: ContextMenuPreviewProps) {
|
|
482
|
+
const { target, content } = useContextMenu('ContextMenu.Preview');
|
|
483
|
+
const lift = useSharedValue(0);
|
|
484
|
+
const reduced = useReducedMotion();
|
|
485
|
+
|
|
486
|
+
useEffect(() => {
|
|
487
|
+
lift.value = reduced ? 1 : withSpring(1, PREVIEW_SPRING);
|
|
488
|
+
}, [lift, reduced]);
|
|
489
|
+
|
|
490
|
+
const style = useAnimatedStyle(() => ({
|
|
491
|
+
opacity: lift.value,
|
|
492
|
+
transform: [{ scale: 1 + lift.value * (PREVIEW_SCALE - 1) }],
|
|
493
|
+
}));
|
|
494
|
+
|
|
495
|
+
if (!target) return null;
|
|
496
|
+
|
|
497
|
+
return (
|
|
498
|
+
<Portal>
|
|
499
|
+
<Animated.View
|
|
500
|
+
pointerEvents="none"
|
|
501
|
+
exiting={reduced ? undefined : FadeOut.duration(140)}
|
|
502
|
+
style={[
|
|
503
|
+
style,
|
|
504
|
+
{
|
|
505
|
+
position: 'absolute',
|
|
506
|
+
left: target.x,
|
|
507
|
+
top: target.y,
|
|
508
|
+
width: target.width,
|
|
509
|
+
},
|
|
510
|
+
]}
|
|
511
|
+
className={className}
|
|
512
|
+
>
|
|
513
|
+
{children ?? content}
|
|
514
|
+
</Animated.View>
|
|
515
|
+
</Portal>
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
ContextMenuPreview.displayName = 'ContextMenu.Preview';
|
|
520
|
+
|
|
521
|
+
export interface ContextMenuItemProps extends MenuItemProps {
|
|
522
|
+
/**
|
|
523
|
+
* The row's glyph, drawn at the trailing edge rather than in front of the
|
|
524
|
+
* label. Painted to match the label unless it carries a colour of its own.
|
|
525
|
+
*/
|
|
526
|
+
icon?: ReactNode;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* One row: the verb at the leading edge, its glyph at the trailing one.
|
|
531
|
+
*
|
|
532
|
+
* The other way round is right for a menu dropped from a button, where the
|
|
533
|
+
* glyphs form a column the eye runs down to find the row it wants. A context
|
|
534
|
+
* menu is not read that way. It appears under the hand that opened it, already
|
|
535
|
+
* over the content, and what is being scanned is the *words* — so the words
|
|
536
|
+
* start at the edge, flush with one another, and the glyph sits at the far side
|
|
537
|
+
* confirming the row rather than introducing it.
|
|
538
|
+
*
|
|
539
|
+
* It is still `Menu.Item` underneath, handed the glyph as its trailing slot. So
|
|
540
|
+
* the press-in fill, the destructive colour and the dismiss-on-select rule are
|
|
541
|
+
* one implementation shared with `Menu`, and only the arrangement differs.
|
|
542
|
+
*/
|
|
543
|
+
function ContextMenuItem({ className, icon, variant, ...props }: ContextMenuItemProps) {
|
|
544
|
+
const glyph = useGlyph(variant);
|
|
545
|
+
|
|
546
|
+
return (
|
|
547
|
+
<Menu.Item
|
|
548
|
+
variant={variant}
|
|
549
|
+
className={cn(ROW_CLASS, className)}
|
|
550
|
+
trailing={icon ? glyph(icon) : undefined}
|
|
551
|
+
{...props}
|
|
552
|
+
/>
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
ContextMenuItem.displayName = 'ContextMenu.Item';
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* A row carrying a state. Its tick stays at the leading edge, where `Menu` puts
|
|
560
|
+
* it, because a tick is not a glyph naming the row — it is the answer to it,
|
|
561
|
+
* and a column of them is what makes a set of choices readable as one.
|
|
562
|
+
*/
|
|
563
|
+
function ContextMenuCheckboxItem({ className, ...props }: MenuCheckboxItemProps) {
|
|
564
|
+
return <Menu.CheckboxItem className={cn(ROW_CLASS, className)} {...props} />;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
ContextMenuCheckboxItem.displayName = 'ContextMenu.CheckboxItem';
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Everything `Menu.Content` takes. The four listed here are the ones whose
|
|
571
|
+
* defaults differ, and they are listed so the difference is visible.
|
|
572
|
+
*/
|
|
573
|
+
export interface ContextMenuContentProps extends MenuContentProps {
|
|
574
|
+
/** Which side of the anchor the panel opens on. Down from the press, flipping
|
|
575
|
+
* above it near the bottom of the screen. */
|
|
576
|
+
placement?: MenuContentProps['placement'];
|
|
577
|
+
/** Where it sits along the other axis. From the press, not centred on it. */
|
|
578
|
+
align?: MenuContentProps['align'];
|
|
579
|
+
/** Gap between the anchor and the panel. Small, so it reads as coming out of
|
|
580
|
+
* the press rather than floating near it. */
|
|
581
|
+
offset?: number;
|
|
582
|
+
/**
|
|
583
|
+
* Floor for the panel's width. A context menu has no trigger to take its
|
|
584
|
+
* width from, and a column of one-word verbs is too narrow to aim at.
|
|
585
|
+
*/
|
|
586
|
+
minWidth?: number;
|
|
587
|
+
/** Dim the screen behind the panel. On here, unlike a plain popover. */
|
|
588
|
+
scrim?: boolean;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The panel, with the defaults a context menu wants rather than a popover's.
|
|
593
|
+
*
|
|
594
|
+
* It unfolds down and from the press rather than being centred on it —
|
|
595
|
+
* centring is right for a panel under a button, and wrong for one at a
|
|
596
|
+
* fingertip, where it would put half the panel back under the hand that opened
|
|
597
|
+
* it. Near the bottom of the screen `Popover` flips it above the press and
|
|
598
|
+
* clamps it into the safe area, so the one case where down does not work
|
|
599
|
+
* answers itself.
|
|
600
|
+
*
|
|
601
|
+
* The gap to the anchor is small, so the panel reads as coming out of the press
|
|
602
|
+
* rather than floating near it.
|
|
603
|
+
*
|
|
604
|
+
* The screen dims behind it, which a popover does not do. A context menu is
|
|
605
|
+
* modal in practice — the content underneath is what the actions are *about*,
|
|
606
|
+
* so nothing else on the screen is available while it is up, and the dim is
|
|
607
|
+
* what says so.
|
|
608
|
+
*/
|
|
609
|
+
function ContextMenuContent({
|
|
610
|
+
placement = 'bottom',
|
|
611
|
+
align = 'start',
|
|
612
|
+
offset = 8,
|
|
613
|
+
minWidth = DEFAULT_MIN_WIDTH,
|
|
614
|
+
scrim = true,
|
|
615
|
+
children,
|
|
616
|
+
...props
|
|
617
|
+
}: ContextMenuContentProps) {
|
|
618
|
+
// The panel is portalled, so it mounts outside this provider's subtree.
|
|
619
|
+
// Re-provided here so `ContextMenu.Preview`, which is declared among these
|
|
620
|
+
// rows, can still reach the rectangle the trigger measured.
|
|
621
|
+
const context = useContextMenu('ContextMenu.Content');
|
|
622
|
+
|
|
623
|
+
return (
|
|
624
|
+
<Menu.Content
|
|
625
|
+
placement={placement}
|
|
626
|
+
align={align}
|
|
627
|
+
offset={offset}
|
|
628
|
+
minWidth={minWidth}
|
|
629
|
+
scrim={scrim}
|
|
630
|
+
{...props}
|
|
631
|
+
>
|
|
632
|
+
<ContextMenuContext.Provider value={context}>{children}</ContextMenuContext.Provider>
|
|
633
|
+
</Menu.Content>
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/*
|
|
638
|
+
* Everything not listed here is Menu's, passed straight through. The three that
|
|
639
|
+
* are listed are wrappers rather than second implementations — they set a class
|
|
640
|
+
* or move a slot and hand the row back to `Menu.Item`, so the press-in fill,
|
|
641
|
+
* the destructive colour and the dismiss-on-select rule stay in one place and
|
|
642
|
+
* cannot drift between the two ways of reaching a list of verbs.
|
|
643
|
+
*/
|
|
644
|
+
export const ContextMenu = Object.assign(ContextMenuRoot, {
|
|
645
|
+
Trigger: ContextMenuTrigger,
|
|
646
|
+
Content: ContextMenuContent,
|
|
647
|
+
Preview: ContextMenuPreview,
|
|
648
|
+
Background: Menu.Background,
|
|
649
|
+
Label: Menu.Label,
|
|
650
|
+
Item: ContextMenuItem,
|
|
651
|
+
CheckboxItem: ContextMenuCheckboxItem,
|
|
652
|
+
RadioGroup: Menu.RadioGroup,
|
|
653
|
+
RadioItem: Menu.RadioItem,
|
|
654
|
+
Separator: Menu.Separator,
|
|
655
|
+
Sub: Menu.Sub,
|
|
656
|
+
SubTrigger: Menu.SubTrigger,
|
|
657
|
+
SubContent: Menu.SubContent,
|
|
658
|
+
});
|