panelui-native 0.28.1 → 0.30.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 +18 -1
- package/lib/module/components/calendar/index.js +350 -115
- package/lib/module/components/calendar/index.js.map +1 -1
- package/lib/module/components/code-block/highlight.js +363 -0
- package/lib/module/components/code-block/highlight.js.map +1 -0
- package/lib/module/components/code-block/index.js +313 -0
- package/lib/module/components/code-block/index.js.map +1 -0
- package/lib/module/components/date-picker/index.js +8 -1
- package/lib/module/components/date-picker/index.js.map +1 -1
- package/lib/module/components/menu/index.js +714 -0
- package/lib/module/components/menu/index.js.map +1 -0
- package/lib/module/components/plan/index.js +296 -0
- package/lib/module/components/plan/index.js.map +1 -0
- package/lib/module/components/popover/index.js +35 -4
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/radio-group/index.js +13 -8
- package/lib/module/components/radio-group/index.js.map +1 -1
- package/lib/module/components/reasoning/index.js +244 -0
- package/lib/module/components/reasoning/index.js.map +1 -0
- package/lib/module/components/sources/index.js +221 -0
- package/lib/module/components/sources/index.js.map +1 -0
- package/lib/module/components/task/index.js +300 -0
- package/lib/module/components/task/index.js.map +1 -0
- package/lib/module/components/textarea/index.js +295 -0
- package/lib/module/components/textarea/index.js.map +1 -0
- package/lib/module/components/tooltip/index.js +179 -23
- package/lib/module/components/tooltip/index.js.map +1 -1
- package/lib/module/icons/index.js +139 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +8 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/collapse.js +112 -0
- package/lib/module/primitives/collapse.js.map +1 -0
- package/lib/typescript/src/components/calendar/index.d.ts +68 -3
- package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
- package/lib/typescript/src/components/code-block/highlight.d.ts +39 -0
- package/lib/typescript/src/components/code-block/highlight.d.ts.map +1 -0
- package/lib/typescript/src/components/code-block/index.d.ts +113 -0
- package/lib/typescript/src/components/code-block/index.d.ts.map +1 -0
- package/lib/typescript/src/components/date-picker/index.d.ts +7 -1
- package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/menu/index.d.ts +261 -0
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -0
- package/lib/typescript/src/components/plan/index.d.ts +127 -0
- package/lib/typescript/src/components/plan/index.d.ts.map +1 -0
- package/lib/typescript/src/components/popover/index.d.ts +35 -1
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
- package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
- package/lib/typescript/src/components/reasoning/index.d.ts +97 -0
- package/lib/typescript/src/components/reasoning/index.d.ts.map +1 -0
- package/lib/typescript/src/components/sources/index.d.ts +85 -0
- package/lib/typescript/src/components/sources/index.d.ts.map +1 -0
- package/lib/typescript/src/components/task/index.d.ts +103 -0
- package/lib/typescript/src/components/task/index.d.ts.map +1 -0
- package/lib/typescript/src/components/textarea/index.d.ts +175 -0
- package/lib/typescript/src/components/textarea/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tooltip/index.d.ts +63 -8
- package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -1
- package/lib/typescript/src/icons/index.d.ts +14 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +10 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/collapse.d.ts +42 -0
- package/lib/typescript/src/primitives/collapse.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/calendar/index.tsx +389 -122
- package/src/components/code-block/highlight.ts +278 -0
- package/src/components/code-block/index.tsx +341 -0
- package/src/components/date-picker/index.tsx +19 -1
- package/src/components/menu/index.tsx +812 -0
- package/src/components/plan/index.tsx +325 -0
- package/src/components/popover/index.tsx +69 -2
- package/src/components/radio-group/index.tsx +11 -2
- package/src/components/reasoning/index.tsx +297 -0
- package/src/components/sources/index.tsx +278 -0
- package/src/components/task/index.tsx +329 -0
- package/src/components/textarea/index.tsx +381 -0
- package/src/components/tooltip/index.tsx +203 -20
- package/src/icons/index.tsx +102 -0
- package/src/index.ts +71 -0
- package/src/primitives/collapse.tsx +128 -0
- package/theme.css +112 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu — the list of things you can do to something.
|
|
3
|
+
*
|
|
4
|
+
* It is not a Select, and the difference is what the rows *are*. A select's
|
|
5
|
+
* rows are values: picking one answers a question the form asked, and the
|
|
6
|
+
* trigger then shows the answer. A menu's rows are verbs — rename, duplicate,
|
|
7
|
+
* delete — and the trigger goes on saying the same thing afterwards, because
|
|
8
|
+
* nothing about it was chosen. Anything built out of a popover and a column of
|
|
9
|
+
* pressables ends up re-deriving that distinction by hand, and re-deriving the
|
|
10
|
+
* roles, the dismiss-on-select rule and the destructive colour with it.
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* <Menu>
|
|
14
|
+
* <Menu.Trigger>
|
|
15
|
+
* <Button variant="outline">Options</Button>
|
|
16
|
+
* </Menu.Trigger>
|
|
17
|
+
* <Menu.Content align="start">
|
|
18
|
+
* <Menu.Item icon={<PencilIcon size={16} />}>Rename</Menu.Item>
|
|
19
|
+
* <Menu.Separator />
|
|
20
|
+
* <Menu.Item variant="destructive" icon={<TrashIcon size={16} />}>
|
|
21
|
+
* Delete
|
|
22
|
+
* </Menu.Item>
|
|
23
|
+
* </Menu.Content>
|
|
24
|
+
* </Menu>
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* The panel is a `Popover` underneath, so the menu inherits its measuring,
|
|
28
|
+
* flipping and edge-clamping rather than owning a second copy of them — a menu
|
|
29
|
+
* near the bottom of the screen opens upwards for the same reason a popover
|
|
30
|
+
* does, and `presentation="bottom-sheet"` moves the same rows into a sheet.
|
|
31
|
+
*
|
|
32
|
+
* Submenus expand in place rather than flying out sideways. A flyout needs a
|
|
33
|
+
* pointer to travel from the parent row to the child panel without crossing
|
|
34
|
+
* anything that would close it; a finger has no such path, and a second panel
|
|
35
|
+
* hanging off the first is usually the thing that pushes a menu off the edge
|
|
36
|
+
* of a phone. Opening downwards into the panel keeps every row under the
|
|
37
|
+
* thumb that opened it.
|
|
38
|
+
*/
|
|
39
|
+
import {
|
|
40
|
+
Children,
|
|
41
|
+
createContext,
|
|
42
|
+
isValidElement,
|
|
43
|
+
useCallback,
|
|
44
|
+
useContext,
|
|
45
|
+
useEffect,
|
|
46
|
+
useMemo,
|
|
47
|
+
useState,
|
|
48
|
+
type ReactElement,
|
|
49
|
+
type ReactNode,
|
|
50
|
+
} from 'react';
|
|
51
|
+
import { Pressable, View, type PressableProps, type ViewProps } from 'react-native';
|
|
52
|
+
import Animated, {
|
|
53
|
+
useAnimatedStyle,
|
|
54
|
+
useReducedMotion,
|
|
55
|
+
useSharedValue,
|
|
56
|
+
withTiming,
|
|
57
|
+
} from 'react-native-reanimated';
|
|
58
|
+
import { tv } from 'tailwind-variants';
|
|
59
|
+
import { useCSSVariable } from 'uniwind';
|
|
60
|
+
import { CheckIcon, ChevronRightIcon } from '../../icons';
|
|
61
|
+
import { Text, textChildren } from '../../primitives/text';
|
|
62
|
+
import { useDirectionSign } from '../../hooks/use-direction';
|
|
63
|
+
import { selectionTick } from '../../utils/haptics';
|
|
64
|
+
import { cn } from '../../utils/cn';
|
|
65
|
+
import { Popover, type PopoverContentProps, type PopoverProps } from '../popover';
|
|
66
|
+
|
|
67
|
+
/** Side of the column every indicator is drawn in. */
|
|
68
|
+
const INDICATOR_SIZE = 18;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A theme token as a colour an icon will accept.
|
|
72
|
+
*
|
|
73
|
+
* `useCSSVariable` answers with whatever the token holds, which for a length
|
|
74
|
+
* or a number is not a colour at all — so anything that is not a string is
|
|
75
|
+
* dropped and the icon falls back to what it inherits.
|
|
76
|
+
*/
|
|
77
|
+
function useTint(variable: string): string | undefined {
|
|
78
|
+
const raw = useCSSVariable(variable);
|
|
79
|
+
return typeof raw === 'string' ? raw : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** How long a row takes to light up under a finger, and to let go again. */
|
|
83
|
+
const PRESS_IN_DURATION = 90;
|
|
84
|
+
const PRESS_OUT_DURATION = 160;
|
|
85
|
+
|
|
86
|
+
/** How far a pressed row shrinks. Enough to feel, not enough to see move. */
|
|
87
|
+
const PRESS_SCALE = 0.98;
|
|
88
|
+
|
|
89
|
+
const menuVariants = tv({
|
|
90
|
+
slots: {
|
|
91
|
+
/*
|
|
92
|
+
* No background here: the panel's surface is a layer of its own, drawn by
|
|
93
|
+
* `Menu.Background` behind the rows, so that a caller can replace it with
|
|
94
|
+
* a gradient or a blur without also having to redraw the rows.
|
|
95
|
+
*/
|
|
96
|
+
content: 'gap-0.5 rounded-3xl p-1.5',
|
|
97
|
+
background: 'absolute inset-0 rounded-3xl bg-overlay',
|
|
98
|
+
label: 'px-3 pb-1 pt-2',
|
|
99
|
+
item: 'w-full flex-row items-center gap-2.5 rounded-2xl px-2.5 py-2.5',
|
|
100
|
+
itemLabel: 'text-base font-medium text-overlay-foreground',
|
|
101
|
+
itemDescription: 'text-sm',
|
|
102
|
+
shortcut: 'text-xs tracking-widest',
|
|
103
|
+
separator: 'my-1 h-px bg-border',
|
|
104
|
+
indicator: 'items-center justify-center',
|
|
105
|
+
},
|
|
106
|
+
variants: {
|
|
107
|
+
variant: {
|
|
108
|
+
// The pressed fill is animated rather than switched, so it is not a
|
|
109
|
+
// class here — only what the animation cannot carry.
|
|
110
|
+
default: {},
|
|
111
|
+
/*
|
|
112
|
+
* The row is tinted rather than only recoloured. A red word on an
|
|
113
|
+
* otherwise ordinary row is easy to read past at a glance, and this is
|
|
114
|
+
* the one row in the menu where reading past it is expensive.
|
|
115
|
+
*/
|
|
116
|
+
destructive: {
|
|
117
|
+
itemLabel: 'text-destructive',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
disabled: {
|
|
121
|
+
true: { item: 'opacity-[0.45]' },
|
|
122
|
+
},
|
|
123
|
+
/** Reserves the indicator column on a row that has no indicator of its own. */
|
|
124
|
+
inset: {
|
|
125
|
+
true: { item: 'ps-[38px]', label: 'ps-[38px]' },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
defaultVariants: {
|
|
129
|
+
variant: 'default',
|
|
130
|
+
disabled: false,
|
|
131
|
+
inset: false,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
export type MenuItemVariant = 'default' | 'destructive';
|
|
136
|
+
|
|
137
|
+
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The pressed state of a row, animated rather than switched.
|
|
141
|
+
*
|
|
142
|
+
* `active:` swaps a class wholesale, which lands the fill in one frame and
|
|
143
|
+
* takes it away in one frame — on a row the size of a menu item that reads as
|
|
144
|
+
* a flash rather than as a press. Interpolating a shared value fades it in and
|
|
145
|
+
* back out on the UI thread, and carries a shrink along with it that a class
|
|
146
|
+
* cannot express at all.
|
|
147
|
+
*/
|
|
148
|
+
function useMenuPress(variant: MenuItemVariant) {
|
|
149
|
+
const reduced = useReducedMotion();
|
|
150
|
+
const pressed = useSharedValue(0);
|
|
151
|
+
|
|
152
|
+
const accent = useTint('--color-accent');
|
|
153
|
+
const destructive = useTint('--color-destructive-subtle');
|
|
154
|
+
const fill = variant === 'destructive' ? destructive : accent;
|
|
155
|
+
|
|
156
|
+
/*
|
|
157
|
+
* The fill is a layer with an animated opacity rather than an animated
|
|
158
|
+
* `backgroundColor`, because crossing *from* transparent needs a colour to
|
|
159
|
+
* cross from and the row has none of its own — what is behind it is whatever
|
|
160
|
+
* the panel's background layer happens to be. Fading the row itself would
|
|
161
|
+
* take the label with it, so only the fill fades.
|
|
162
|
+
*/
|
|
163
|
+
const fillStyle = useAnimatedStyle(() => ({ opacity: pressed.value }));
|
|
164
|
+
|
|
165
|
+
const rowStyle = useAnimatedStyle(() => ({
|
|
166
|
+
transform: [{ scale: 1 - (1 - PRESS_SCALE) * pressed.value }],
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
rowStyle,
|
|
171
|
+
fillStyle,
|
|
172
|
+
fill,
|
|
173
|
+
onPressIn: () => {
|
|
174
|
+
pressed.value = reduced ? 1 : withTiming(1, { duration: PRESS_IN_DURATION });
|
|
175
|
+
},
|
|
176
|
+
onPressOut: () => {
|
|
177
|
+
pressed.value = reduced ? 0 : withTiming(0, { duration: PRESS_OUT_DURATION });
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface MenuContextValue {
|
|
183
|
+
close: () => void;
|
|
184
|
+
haptics: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const MenuContext = createContext<MenuContextValue | null>(null);
|
|
188
|
+
|
|
189
|
+
function useMenu(component: string): MenuContextValue {
|
|
190
|
+
const context = useContext(MenuContext);
|
|
191
|
+
if (!context) {
|
|
192
|
+
throw new Error(`${component} must be used within a <Menu>`);
|
|
193
|
+
}
|
|
194
|
+
return context;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface MenuProps extends PopoverProps {
|
|
198
|
+
/**
|
|
199
|
+
* Tick the haptic engine as a row is chosen. Needs the optional
|
|
200
|
+
* `expo-haptics`, and is silent without it.
|
|
201
|
+
*/
|
|
202
|
+
haptics?: boolean;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function MenuRoot({ children, open, onOpenChange, defaultOpen, haptics = false, ...props }: MenuProps) {
|
|
206
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen ?? false);
|
|
207
|
+
const isControlled = open !== undefined;
|
|
208
|
+
|
|
209
|
+
/*
|
|
210
|
+
* The open state is mirrored here as well as inside the popover, because a
|
|
211
|
+
* row has to be able to dismiss the panel it is sitting in and the popover
|
|
212
|
+
* only publishes that through `Popover.Close` — which wraps a child and so
|
|
213
|
+
* cannot be handed to a row that already owns its own press handler.
|
|
214
|
+
*/
|
|
215
|
+
const setOpen = useCallback(
|
|
216
|
+
(next: boolean) => {
|
|
217
|
+
if (!isControlled) setInternalOpen(next);
|
|
218
|
+
onOpenChange?.(next);
|
|
219
|
+
},
|
|
220
|
+
[isControlled, onOpenChange]
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const context = useMemo(
|
|
224
|
+
() => ({ close: () => setOpen(false), haptics }),
|
|
225
|
+
[setOpen, haptics]
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<MenuContext.Provider value={context}>
|
|
230
|
+
<Popover
|
|
231
|
+
open={isControlled ? open : internalOpen}
|
|
232
|
+
onOpenChange={setOpen}
|
|
233
|
+
{...props}
|
|
234
|
+
>
|
|
235
|
+
{children}
|
|
236
|
+
</Popover>
|
|
237
|
+
</MenuContext.Provider>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface MenuTriggerProps {
|
|
242
|
+
children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Wraps a single child and opens the menu on press. Also what gets measured. */
|
|
246
|
+
function MenuTrigger({ children }: MenuTriggerProps) {
|
|
247
|
+
return <Popover.Trigger>{children}</Popover.Trigger>;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface MenuContentProps
|
|
251
|
+
extends Omit<PopoverContentProps, 'children' | 'scrollable'> {
|
|
252
|
+
children?: ReactNode;
|
|
253
|
+
/**
|
|
254
|
+
* Scroll the rows when there are more of them than fit on screen. On by
|
|
255
|
+
* default, unlike the popover it is built on: a menu is a list, its length
|
|
256
|
+
* is usually a `map` over data rather than something written out by hand,
|
|
257
|
+
* and a row that cannot be reached is a row that may as well not exist.
|
|
258
|
+
*/
|
|
259
|
+
scrollable?: boolean;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The panel, and the thing screen readers announce as a menu.
|
|
264
|
+
*
|
|
265
|
+
* Its padding is the row gutter rather than the popover's content padding —
|
|
266
|
+
* rows run to the panel's inner edge so that a pressed row's highlight reads
|
|
267
|
+
* as part of the panel instead of a floating chip inside it.
|
|
268
|
+
*
|
|
269
|
+
* The surface is a layer rather than a background on the panel itself, so that
|
|
270
|
+
* a caller can put something *behind* the rows. Pass a `Menu.Background` of
|
|
271
|
+
* your own with a gradient, an image or a blur inside it and it replaces the
|
|
272
|
+
* default one; pass nothing and the default is drawn for you.
|
|
273
|
+
*/
|
|
274
|
+
function MenuContent({ className, children, scrollable = true, ...props }: MenuContentProps) {
|
|
275
|
+
const { content } = menuVariants();
|
|
276
|
+
const context = useMenu('Menu.Content');
|
|
277
|
+
|
|
278
|
+
/*
|
|
279
|
+
* A caller's own background replaces the default rather than stacking on top
|
|
280
|
+
* of it, and it is lifted out of the children so that it lands outside the
|
|
281
|
+
* scroller — a surface inside one scrolls away with the rows sitting on it.
|
|
282
|
+
*/
|
|
283
|
+
const items: ReactNode[] = [];
|
|
284
|
+
let background: ReactNode = null;
|
|
285
|
+
for (const child of Children.toArray(children)) {
|
|
286
|
+
if (isValidElement(child) && child.type === MenuBackground) {
|
|
287
|
+
background = child;
|
|
288
|
+
} else {
|
|
289
|
+
items.push(child);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<Popover.Content
|
|
295
|
+
accessibilityRole="menu"
|
|
296
|
+
scrollable={scrollable}
|
|
297
|
+
unstyled
|
|
298
|
+
background={background ?? <MenuBackground />}
|
|
299
|
+
className={cn(content(), 'shadow-lg', className)}
|
|
300
|
+
{...props}
|
|
301
|
+
>
|
|
302
|
+
{/*
|
|
303
|
+
The panel is portalled out of this subtree, and context follows the
|
|
304
|
+
render tree rather than the call site — so the value provided around
|
|
305
|
+
the root never reaches the rows. Re-provide it here, where the rows
|
|
306
|
+
actually mount, and Menu.Item keeps working in both presentations.
|
|
307
|
+
*/}
|
|
308
|
+
<MenuContext.Provider value={context}>{textChildren(items)}</MenuContext.Provider>
|
|
309
|
+
</Popover.Content>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface MenuBackgroundProps extends ViewProps {
|
|
314
|
+
className?: string;
|
|
315
|
+
/**
|
|
316
|
+
* What the panel is made of. A gradient, an image, a blur view — anything
|
|
317
|
+
* that fills. Left empty it is the plain overlay surface.
|
|
318
|
+
*/
|
|
319
|
+
children?: ReactNode;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The panel's surface, drawn behind every row.
|
|
324
|
+
*
|
|
325
|
+
* It exists as a part rather than as a background on the panel because a
|
|
326
|
+
* background cannot be got behind. A menu that wants to be frosted, tinted or
|
|
327
|
+
* gradient-filled needs something under the rows and over nothing, and
|
|
328
|
+
* `overflow-hidden` on the panel is what keeps whatever that is inside the
|
|
329
|
+
* corner radius.
|
|
330
|
+
*/
|
|
331
|
+
function MenuBackground({ className, children, ...props }: MenuBackgroundProps) {
|
|
332
|
+
const { background } = menuVariants();
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<View
|
|
336
|
+
accessibilityElementsHidden
|
|
337
|
+
importantForAccessibility="no-hide-descendants"
|
|
338
|
+
pointerEvents="none"
|
|
339
|
+
className={cn(background(), className)}
|
|
340
|
+
{...props}
|
|
341
|
+
>
|
|
342
|
+
{children}
|
|
343
|
+
</View>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface MenuLabelProps extends ViewProps {
|
|
348
|
+
className?: string;
|
|
349
|
+
/** Line the text up with rows that carry an icon or an indicator. */
|
|
350
|
+
inset?: boolean;
|
|
351
|
+
children?: ReactNode;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Non-interactive heading over a run of rows. */
|
|
355
|
+
function MenuLabel({ className, inset, children, ...props }: MenuLabelProps) {
|
|
356
|
+
const { label } = menuVariants({ inset });
|
|
357
|
+
|
|
358
|
+
return (
|
|
359
|
+
<View accessibilityRole="header" className={cn(label(), className)} {...props}>
|
|
360
|
+
{textChildren(children, (text) => (
|
|
361
|
+
<Text size="xs" weight="medium" muted className="uppercase tracking-wide">
|
|
362
|
+
{text}
|
|
363
|
+
</Text>
|
|
364
|
+
))}
|
|
365
|
+
</View>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export interface MenuSeparatorProps extends ViewProps {
|
|
370
|
+
className?: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Hairline between two runs of rows. */
|
|
374
|
+
function MenuSeparator({ className, ...props }: MenuSeparatorProps) {
|
|
375
|
+
const { separator } = menuVariants();
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<View
|
|
379
|
+
accessibilityElementsHidden
|
|
380
|
+
importantForAccessibility="no-hide-descendants"
|
|
381
|
+
className={cn(separator(), className)}
|
|
382
|
+
{...props}
|
|
383
|
+
/>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface MenuItemProps extends Omit<PressableProps, 'children' | 'style'> {
|
|
388
|
+
className?: string;
|
|
389
|
+
/** The row's label. */
|
|
390
|
+
children?: ReactNode;
|
|
391
|
+
/** Leading glyph, drawn in the indicator column. */
|
|
392
|
+
icon?: ReactNode;
|
|
393
|
+
/** Second line under the label, for a row whose effect needs a sentence. */
|
|
394
|
+
description?: string;
|
|
395
|
+
/** Right-aligned hint, for a row that also has a keyboard or gesture shortcut. */
|
|
396
|
+
shortcut?: string;
|
|
397
|
+
/**
|
|
398
|
+
* Element pinned to the row's trailing edge, after the shortcut. For the
|
|
399
|
+
* things a shortcut string cannot be — a chevron, a badge, a small avatar.
|
|
400
|
+
*/
|
|
401
|
+
trailing?: ReactNode;
|
|
402
|
+
/** `destructive` colours the row for an action that removes something. */
|
|
403
|
+
variant?: MenuItemVariant;
|
|
404
|
+
/** Line the label up with rows that carry an icon, without drawing one. */
|
|
405
|
+
inset?: boolean;
|
|
406
|
+
disabled?: boolean;
|
|
407
|
+
/**
|
|
408
|
+
* Dismiss the menu once the row has run. Default true — a menu of verbs has
|
|
409
|
+
* done its job the moment one is chosen. Turn it off for a row that toggles
|
|
410
|
+
* something the user is likely to toggle twice.
|
|
411
|
+
*/
|
|
412
|
+
closeOnSelect?: boolean;
|
|
413
|
+
onSelect?: () => void;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* One row of the menu.
|
|
418
|
+
*
|
|
419
|
+
* `onSelect` rather than `onPress` is the handler that closes the panel:
|
|
420
|
+
* `onPress` is still forwarded and still fires, so a row can keep whatever
|
|
421
|
+
* press behaviour it had, but the dismissal is tied to the semantic event so
|
|
422
|
+
* that a row which is disabled or which opts out via `closeOnSelect` behaves
|
|
423
|
+
* the same either way.
|
|
424
|
+
*/
|
|
425
|
+
function MenuItem({
|
|
426
|
+
className,
|
|
427
|
+
children,
|
|
428
|
+
icon,
|
|
429
|
+
description,
|
|
430
|
+
shortcut,
|
|
431
|
+
trailing,
|
|
432
|
+
variant = 'default',
|
|
433
|
+
inset,
|
|
434
|
+
disabled = false,
|
|
435
|
+
closeOnSelect = true,
|
|
436
|
+
onSelect,
|
|
437
|
+
onPress,
|
|
438
|
+
...props
|
|
439
|
+
}: MenuItemProps) {
|
|
440
|
+
const { close, haptics } = useMenu('Menu.Item');
|
|
441
|
+
const slots = menuVariants({ variant, disabled, inset: inset && !icon });
|
|
442
|
+
const press = useMenuPress(variant);
|
|
443
|
+
|
|
444
|
+
const handlePress = (...args: Parameters<NonNullable<PressableProps['onPress']>>) => {
|
|
445
|
+
if (disabled) return;
|
|
446
|
+
if (haptics) selectionTick();
|
|
447
|
+
onPress?.(...args);
|
|
448
|
+
onSelect?.();
|
|
449
|
+
if (closeOnSelect) close();
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
return (
|
|
453
|
+
<AnimatedPressable
|
|
454
|
+
accessibilityRole="menuitem"
|
|
455
|
+
accessibilityState={{ disabled }}
|
|
456
|
+
disabled={disabled}
|
|
457
|
+
onPress={handlePress}
|
|
458
|
+
onPressIn={press.onPressIn}
|
|
459
|
+
onPressOut={press.onPressOut}
|
|
460
|
+
style={press.rowStyle}
|
|
461
|
+
className={cn(slots.item(), 'overflow-hidden', className)}
|
|
462
|
+
{...props}
|
|
463
|
+
>
|
|
464
|
+
<Animated.View
|
|
465
|
+
pointerEvents="none"
|
|
466
|
+
className="absolute inset-0"
|
|
467
|
+
style={[press.fillStyle, { backgroundColor: press.fill }]}
|
|
468
|
+
/>
|
|
469
|
+
{icon ? <MenuIndicatorSlot>{icon}</MenuIndicatorSlot> : null}
|
|
470
|
+
<View className="flex-1">
|
|
471
|
+
{textChildren(children, (text) => (
|
|
472
|
+
<Text className={slots.itemLabel()}>{text}</Text>
|
|
473
|
+
))}
|
|
474
|
+
{description ? (
|
|
475
|
+
<Text size="xs" muted className={slots.itemDescription()}>
|
|
476
|
+
{description}
|
|
477
|
+
</Text>
|
|
478
|
+
) : null}
|
|
479
|
+
</View>
|
|
480
|
+
{shortcut ? (
|
|
481
|
+
<Text size="xs" muted className={slots.shortcut()}>
|
|
482
|
+
{shortcut}
|
|
483
|
+
</Text>
|
|
484
|
+
) : null}
|
|
485
|
+
{trailing}
|
|
486
|
+
</AnimatedPressable>
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Fixed-width column the leading glyph sits in.
|
|
492
|
+
*
|
|
493
|
+
* Fixed rather than sized to its contents so that a checkmark appearing and
|
|
494
|
+
* disappearing does not shift the label beside it, and so that rows with and
|
|
495
|
+
* without a glyph line their labels up in the same panel.
|
|
496
|
+
*/
|
|
497
|
+
function MenuIndicatorSlot({ children }: { children?: ReactNode }) {
|
|
498
|
+
const { indicator } = menuVariants();
|
|
499
|
+
|
|
500
|
+
return (
|
|
501
|
+
<View className={indicator()} style={{ width: INDICATOR_SIZE, height: INDICATOR_SIZE }}>
|
|
502
|
+
{children}
|
|
503
|
+
</View>
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Placeholder that keeps the indicator column open on an unmarked row.
|
|
509
|
+
*
|
|
510
|
+
* `Menu.Item` draws no column at all when it has no icon, which is right for a
|
|
511
|
+
* row that will never have one — but wrong for a row whose mark appears and
|
|
512
|
+
* disappears as it is toggled.
|
|
513
|
+
*/
|
|
514
|
+
function MenuIndicatorSpacer() {
|
|
515
|
+
return <View />;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export interface MenuCheckboxItemProps extends Omit<MenuItemProps, 'icon' | 'inset'> {
|
|
519
|
+
checked?: boolean;
|
|
520
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* A row that carries a state instead of running an action.
|
|
525
|
+
*
|
|
526
|
+
* It keeps the menu open by default, which is the opposite of `Menu.Item` and
|
|
527
|
+
* deliberately so: a set of toggles is nearly always set more than one at a
|
|
528
|
+
* time, and closing after each one turns three taps into six.
|
|
529
|
+
*/
|
|
530
|
+
function MenuCheckboxItem({
|
|
531
|
+
checked = false,
|
|
532
|
+
onCheckedChange,
|
|
533
|
+
onSelect,
|
|
534
|
+
closeOnSelect = false,
|
|
535
|
+
...props
|
|
536
|
+
}: MenuCheckboxItemProps) {
|
|
537
|
+
const tint = useTint('--color-popover-foreground');
|
|
538
|
+
|
|
539
|
+
return (
|
|
540
|
+
<MenuItem
|
|
541
|
+
accessibilityRole="menuitem"
|
|
542
|
+
accessibilityState={{ checked, disabled: props.disabled ?? false }}
|
|
543
|
+
closeOnSelect={closeOnSelect}
|
|
544
|
+
// An empty column rather than no column: the mark comes and goes as the
|
|
545
|
+
// row is toggled, and a row whose label steps sideways each time reads
|
|
546
|
+
// as a different row rather than the same one in a new state.
|
|
547
|
+
icon={checked ? <CheckIcon size={16} color={tint} /> : <MenuIndicatorSpacer />}
|
|
548
|
+
onSelect={() => {
|
|
549
|
+
onCheckedChange?.(!checked);
|
|
550
|
+
onSelect?.();
|
|
551
|
+
}}
|
|
552
|
+
{...props}
|
|
553
|
+
/>
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
interface MenuRadioContextValue {
|
|
558
|
+
value: string | undefined;
|
|
559
|
+
select: (value: string) => void;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const MenuRadioContext = createContext<MenuRadioContextValue | null>(null);
|
|
563
|
+
|
|
564
|
+
export interface MenuRadioGroupProps extends ViewProps {
|
|
565
|
+
className?: string;
|
|
566
|
+
value?: string;
|
|
567
|
+
onValueChange?: (value: string) => void;
|
|
568
|
+
children?: ReactNode;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** A run of rows of which exactly one is chosen. */
|
|
572
|
+
function MenuRadioGroup({ value, onValueChange, className, children, ...props }: MenuRadioGroupProps) {
|
|
573
|
+
const context = useMemo(
|
|
574
|
+
() => ({ value, select: (next: string) => onValueChange?.(next) }),
|
|
575
|
+
[value, onValueChange]
|
|
576
|
+
);
|
|
577
|
+
|
|
578
|
+
return (
|
|
579
|
+
<MenuRadioContext.Provider value={context}>
|
|
580
|
+
<View accessibilityRole="radiogroup" className={className} {...props}>
|
|
581
|
+
{children}
|
|
582
|
+
</View>
|
|
583
|
+
</MenuRadioContext.Provider>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export type MenuRadioIndicator = 'check' | 'dot';
|
|
588
|
+
|
|
589
|
+
export interface MenuRadioItemProps extends Omit<MenuItemProps, 'icon' | 'inset'> {
|
|
590
|
+
value: string;
|
|
591
|
+
/** `check` marks the chosen row, `dot` is quieter beside a list of nouns. */
|
|
592
|
+
indicator?: MenuRadioIndicator;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** One option inside a `Menu.RadioGroup`. */
|
|
596
|
+
function MenuRadioItem({
|
|
597
|
+
value,
|
|
598
|
+
indicator = 'check',
|
|
599
|
+
onSelect,
|
|
600
|
+
closeOnSelect = true,
|
|
601
|
+
...props
|
|
602
|
+
}: MenuRadioItemProps) {
|
|
603
|
+
const group = useContext(MenuRadioContext);
|
|
604
|
+
if (!group) {
|
|
605
|
+
throw new Error('Menu.RadioItem must be used within a <Menu.RadioGroup>');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const tint = useTint('--color-popover-foreground');
|
|
609
|
+
const selected = group.value === value;
|
|
610
|
+
|
|
611
|
+
return (
|
|
612
|
+
<MenuItem
|
|
613
|
+
accessibilityRole="menuitem"
|
|
614
|
+
accessibilityState={{ selected, disabled: props.disabled ?? false }}
|
|
615
|
+
closeOnSelect={closeOnSelect}
|
|
616
|
+
icon={
|
|
617
|
+
selected ? (
|
|
618
|
+
indicator === 'dot' ? (
|
|
619
|
+
<View className="h-2 w-2 rounded-full bg-popover-foreground" />
|
|
620
|
+
) : (
|
|
621
|
+
<CheckIcon size={16} color={tint} />
|
|
622
|
+
)
|
|
623
|
+
) : (
|
|
624
|
+
// Held open, so the options stay in one column as the choice moves
|
|
625
|
+
// between them.
|
|
626
|
+
<MenuIndicatorSpacer />
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
onSelect={() => {
|
|
630
|
+
group.select(value);
|
|
631
|
+
onSelect?.();
|
|
632
|
+
}}
|
|
633
|
+
{...props}
|
|
634
|
+
/>
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
interface MenuSubContextValue {
|
|
639
|
+
open: boolean;
|
|
640
|
+
toggle: () => void;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const MenuSubContext = createContext<MenuSubContextValue | null>(null);
|
|
644
|
+
|
|
645
|
+
function useMenuSub(component: string): MenuSubContextValue {
|
|
646
|
+
const context = useContext(MenuSubContext);
|
|
647
|
+
if (!context) {
|
|
648
|
+
throw new Error(`${component} must be used within a <Menu.Sub>`);
|
|
649
|
+
}
|
|
650
|
+
return context;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export interface MenuSubProps {
|
|
654
|
+
children?: ReactNode;
|
|
655
|
+
defaultOpen?: boolean;
|
|
656
|
+
open?: boolean;
|
|
657
|
+
onOpenChange?: (open: boolean) => void;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Groups a `Menu.SubTrigger` with the rows it reveals. */
|
|
661
|
+
function MenuSub({ children, defaultOpen = false, open, onOpenChange }: MenuSubProps) {
|
|
662
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
663
|
+
const isControlled = open !== undefined;
|
|
664
|
+
const resolved = isControlled ? open : internalOpen;
|
|
665
|
+
|
|
666
|
+
const context = useMemo(
|
|
667
|
+
() => ({
|
|
668
|
+
open: resolved,
|
|
669
|
+
toggle: () => {
|
|
670
|
+
if (!isControlled) setInternalOpen(!resolved);
|
|
671
|
+
onOpenChange?.(!resolved);
|
|
672
|
+
},
|
|
673
|
+
}),
|
|
674
|
+
[resolved, isControlled, onOpenChange]
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
return <MenuSubContext.Provider value={context}>{children}</MenuSubContext.Provider>;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export interface MenuSubTriggerProps
|
|
681
|
+
extends Omit<MenuItemProps, 'closeOnSelect' | 'shortcut' | 'trailing'> {}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* The row that opens a submenu.
|
|
685
|
+
*
|
|
686
|
+
* Its chevron points along the reading direction while closed and turns to
|
|
687
|
+
* point down once open, so the row states which way its rows will appear
|
|
688
|
+
* rather than only that it has some.
|
|
689
|
+
*/
|
|
690
|
+
function MenuSubTrigger({ className, children, icon, onSelect, ...props }: MenuSubTriggerProps) {
|
|
691
|
+
const { open, toggle } = useMenuSub('Menu.SubTrigger');
|
|
692
|
+
const sign = useDirectionSign();
|
|
693
|
+
const reducedMotion = useReducedMotion();
|
|
694
|
+
const chevronTint = useTint('--color-muted-foreground');
|
|
695
|
+
const progress = useSharedValue(open ? 1 : 0);
|
|
696
|
+
|
|
697
|
+
useEffect(() => {
|
|
698
|
+
progress.value = reducedMotion
|
|
699
|
+
? open
|
|
700
|
+
? 1
|
|
701
|
+
: 0
|
|
702
|
+
: withTiming(open ? 1 : 0, { duration: 180 });
|
|
703
|
+
}, [open, reducedMotion, progress]);
|
|
704
|
+
|
|
705
|
+
// 90° takes a chevron from pointing along the line of text to pointing down
|
|
706
|
+
// into the rows it is about to reveal. Mirrored under RTL, where the closed
|
|
707
|
+
// state points the other way to begin with.
|
|
708
|
+
const chevronStyle = useAnimatedStyle(() => ({
|
|
709
|
+
transform: [{ rotate: `${progress.value * 90 * sign}deg` }],
|
|
710
|
+
}));
|
|
711
|
+
|
|
712
|
+
return (
|
|
713
|
+
<MenuItem
|
|
714
|
+
accessibilityState={{ expanded: open, disabled: props.disabled ?? false }}
|
|
715
|
+
closeOnSelect={false}
|
|
716
|
+
icon={icon}
|
|
717
|
+
className={className}
|
|
718
|
+
trailing={
|
|
719
|
+
<Animated.View style={chevronStyle}>
|
|
720
|
+
<ChevronRightIcon size={16} color={chevronTint} />
|
|
721
|
+
</Animated.View>
|
|
722
|
+
}
|
|
723
|
+
onSelect={() => {
|
|
724
|
+
toggle();
|
|
725
|
+
onSelect?.();
|
|
726
|
+
}}
|
|
727
|
+
{...props}
|
|
728
|
+
>
|
|
729
|
+
{children}
|
|
730
|
+
</MenuItem>
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export interface MenuSubContentProps extends ViewProps {
|
|
735
|
+
className?: string;
|
|
736
|
+
children?: ReactNode;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* The rows a submenu reveals, opening in place.
|
|
741
|
+
*
|
|
742
|
+
* The height is animated from a measurement rather than left to a layout
|
|
743
|
+
* animation, and the measured copy is absolutely positioned so it always lays
|
|
744
|
+
* out at its natural size — a child of a view whose height is mid-animation
|
|
745
|
+
* would otherwise report the animated height back, and the panel would settle
|
|
746
|
+
* at whatever it happened to measure on the first frame.
|
|
747
|
+
*/
|
|
748
|
+
function MenuSubContent({ className, children, ...props }: MenuSubContentProps) {
|
|
749
|
+
const { open } = useMenuSub('Menu.SubContent');
|
|
750
|
+
const reducedMotion = useReducedMotion();
|
|
751
|
+
const [height, setHeight] = useState(0);
|
|
752
|
+
const progress = useSharedValue(open ? 1 : 0);
|
|
753
|
+
|
|
754
|
+
useEffect(() => {
|
|
755
|
+
progress.value = reducedMotion
|
|
756
|
+
? open
|
|
757
|
+
? 1
|
|
758
|
+
: 0
|
|
759
|
+
: withTiming(open ? 1 : 0, { duration: 200 });
|
|
760
|
+
}, [open, reducedMotion, progress]);
|
|
761
|
+
|
|
762
|
+
const style = useAnimatedStyle(() => ({
|
|
763
|
+
height: progress.value * height,
|
|
764
|
+
opacity: progress.value,
|
|
765
|
+
}));
|
|
766
|
+
|
|
767
|
+
return (
|
|
768
|
+
<Animated.View style={style} className="overflow-hidden">
|
|
769
|
+
{/*
|
|
770
|
+
Indented and ruled along its inner edge, so the rows read as belonging
|
|
771
|
+
to the trigger above them rather than as a second flat list. The rule
|
|
772
|
+
is logical-start, which puts it on the right under RTL.
|
|
773
|
+
*/}
|
|
774
|
+
<View
|
|
775
|
+
onLayout={(event) => setHeight(event.nativeEvent.layout.height)}
|
|
776
|
+
style={{ position: 'absolute', left: 0, right: 0, top: 0 }}
|
|
777
|
+
className={cn('ms-4 gap-0.5 border-s border-border ps-1', className)}
|
|
778
|
+
{...props}
|
|
779
|
+
>
|
|
780
|
+
{children}
|
|
781
|
+
</View>
|
|
782
|
+
</Animated.View>
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
MenuTrigger.displayName = 'Menu.Trigger';
|
|
787
|
+
MenuContent.displayName = 'Menu.Content';
|
|
788
|
+
MenuBackground.displayName = 'Menu.Background';
|
|
789
|
+
MenuLabel.displayName = 'Menu.Label';
|
|
790
|
+
MenuItem.displayName = 'Menu.Item';
|
|
791
|
+
MenuCheckboxItem.displayName = 'Menu.CheckboxItem';
|
|
792
|
+
MenuRadioGroup.displayName = 'Menu.RadioGroup';
|
|
793
|
+
MenuRadioItem.displayName = 'Menu.RadioItem';
|
|
794
|
+
MenuSeparator.displayName = 'Menu.Separator';
|
|
795
|
+
MenuSub.displayName = 'Menu.Sub';
|
|
796
|
+
MenuSubTrigger.displayName = 'Menu.SubTrigger';
|
|
797
|
+
MenuSubContent.displayName = 'Menu.SubContent';
|
|
798
|
+
|
|
799
|
+
export const Menu = Object.assign(MenuRoot, {
|
|
800
|
+
Trigger: MenuTrigger,
|
|
801
|
+
Content: MenuContent,
|
|
802
|
+
Background: MenuBackground,
|
|
803
|
+
Label: MenuLabel,
|
|
804
|
+
Item: MenuItem,
|
|
805
|
+
CheckboxItem: MenuCheckboxItem,
|
|
806
|
+
RadioGroup: MenuRadioGroup,
|
|
807
|
+
RadioItem: MenuRadioItem,
|
|
808
|
+
Separator: MenuSeparator,
|
|
809
|
+
Sub: MenuSub,
|
|
810
|
+
SubTrigger: MenuSubTrigger,
|
|
811
|
+
SubContent: MenuSubContent,
|
|
812
|
+
});
|