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.
Files changed (82) hide show
  1. package/README.md +18 -1
  2. package/lib/module/components/calendar/index.js +350 -115
  3. package/lib/module/components/calendar/index.js.map +1 -1
  4. package/lib/module/components/code-block/highlight.js +363 -0
  5. package/lib/module/components/code-block/highlight.js.map +1 -0
  6. package/lib/module/components/code-block/index.js +313 -0
  7. package/lib/module/components/code-block/index.js.map +1 -0
  8. package/lib/module/components/date-picker/index.js +8 -1
  9. package/lib/module/components/date-picker/index.js.map +1 -1
  10. package/lib/module/components/menu/index.js +714 -0
  11. package/lib/module/components/menu/index.js.map +1 -0
  12. package/lib/module/components/plan/index.js +296 -0
  13. package/lib/module/components/plan/index.js.map +1 -0
  14. package/lib/module/components/popover/index.js +35 -4
  15. package/lib/module/components/popover/index.js.map +1 -1
  16. package/lib/module/components/radio-group/index.js +13 -8
  17. package/lib/module/components/radio-group/index.js.map +1 -1
  18. package/lib/module/components/reasoning/index.js +244 -0
  19. package/lib/module/components/reasoning/index.js.map +1 -0
  20. package/lib/module/components/sources/index.js +221 -0
  21. package/lib/module/components/sources/index.js.map +1 -0
  22. package/lib/module/components/task/index.js +300 -0
  23. package/lib/module/components/task/index.js.map +1 -0
  24. package/lib/module/components/textarea/index.js +295 -0
  25. package/lib/module/components/textarea/index.js.map +1 -0
  26. package/lib/module/components/tooltip/index.js +179 -23
  27. package/lib/module/components/tooltip/index.js.map +1 -1
  28. package/lib/module/icons/index.js +139 -0
  29. package/lib/module/icons/index.js.map +1 -1
  30. package/lib/module/index.js +8 -0
  31. package/lib/module/index.js.map +1 -1
  32. package/lib/module/primitives/collapse.js +112 -0
  33. package/lib/module/primitives/collapse.js.map +1 -0
  34. package/lib/typescript/src/components/calendar/index.d.ts +68 -3
  35. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  36. package/lib/typescript/src/components/code-block/highlight.d.ts +39 -0
  37. package/lib/typescript/src/components/code-block/highlight.d.ts.map +1 -0
  38. package/lib/typescript/src/components/code-block/index.d.ts +113 -0
  39. package/lib/typescript/src/components/code-block/index.d.ts.map +1 -0
  40. package/lib/typescript/src/components/date-picker/index.d.ts +7 -1
  41. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/menu/index.d.ts +261 -0
  43. package/lib/typescript/src/components/menu/index.d.ts.map +1 -0
  44. package/lib/typescript/src/components/plan/index.d.ts +127 -0
  45. package/lib/typescript/src/components/plan/index.d.ts.map +1 -0
  46. package/lib/typescript/src/components/popover/index.d.ts +35 -1
  47. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/reasoning/index.d.ts +97 -0
  50. package/lib/typescript/src/components/reasoning/index.d.ts.map +1 -0
  51. package/lib/typescript/src/components/sources/index.d.ts +85 -0
  52. package/lib/typescript/src/components/sources/index.d.ts.map +1 -0
  53. package/lib/typescript/src/components/task/index.d.ts +103 -0
  54. package/lib/typescript/src/components/task/index.d.ts.map +1 -0
  55. package/lib/typescript/src/components/textarea/index.d.ts +175 -0
  56. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -0
  57. package/lib/typescript/src/components/tooltip/index.d.ts +63 -8
  58. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -1
  59. package/lib/typescript/src/icons/index.d.ts +14 -0
  60. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  61. package/lib/typescript/src/index.d.ts +10 -2
  62. package/lib/typescript/src/index.d.ts.map +1 -1
  63. package/lib/typescript/src/primitives/collapse.d.ts +42 -0
  64. package/lib/typescript/src/primitives/collapse.d.ts.map +1 -0
  65. package/package.json +1 -1
  66. package/src/components/calendar/index.tsx +389 -122
  67. package/src/components/code-block/highlight.ts +278 -0
  68. package/src/components/code-block/index.tsx +341 -0
  69. package/src/components/date-picker/index.tsx +19 -1
  70. package/src/components/menu/index.tsx +812 -0
  71. package/src/components/plan/index.tsx +325 -0
  72. package/src/components/popover/index.tsx +69 -2
  73. package/src/components/radio-group/index.tsx +11 -2
  74. package/src/components/reasoning/index.tsx +297 -0
  75. package/src/components/sources/index.tsx +278 -0
  76. package/src/components/task/index.tsx +329 -0
  77. package/src/components/textarea/index.tsx +381 -0
  78. package/src/components/tooltip/index.tsx +203 -20
  79. package/src/icons/index.tsx +102 -0
  80. package/src/index.ts +71 -0
  81. package/src/primitives/collapse.tsx +128 -0
  82. 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
+ });