panelui-native 0.90.0 → 0.92.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.
@@ -46,6 +46,45 @@
46
46
  * instead of a menu appearing. Every action carries its label beside it, because
47
47
  * a column of unlabelled circles is a quiz.
48
48
  *
49
+ * ## The menu
50
+ *
51
+ * ```tsx
52
+ * <Fab.Group layout="menu" glass icon={<PlusIcon size={24} />} accessibilityLabel="Add">
53
+ * <Fab.Action icon={<ImageIcon size={18} />} label="Photo" onPress={addPhoto} />
54
+ * <Fab.Action icon={<FileIcon size={18} />} label="File" onPress={addFile} />
55
+ * </Fab.Group>
56
+ * ```
57
+ *
58
+ * `layout="menu"` unfolds one panel of rows out of the button instead of a
59
+ * column of buttons — the shape the platform's own menus take. Each row is a
60
+ * label with its glyph, on the side the appearance puts it or `iconPlacement`
61
+ * says, and the panel springs out of the corner the
62
+ * trigger sits in, so it reads as the button opening rather than a sheet
63
+ * arriving. Drawn in glass, the panel is one piece of the material; the rows
64
+ * are content on it.
65
+ *
66
+ * ## Glass
67
+ *
68
+ * ```tsx
69
+ * <Fab glass icon={<PlusIcon size={24} />} accessibilityLabel="New note" />
70
+ * ```
71
+ *
72
+ * `glass` draws the button in the material iOS 26 uses for its own floating
73
+ * controls, in place of the variant's fill. A control floating over content is
74
+ * exactly what that material is for: it refracts what scrolls under it rather
75
+ * than covering it, and lifts its own edge, so the shadow goes too.
76
+ *
77
+ * Every variant takes the plain material, because a tint is a dimmer: measured
78
+ * over content, a colour laid on the glass turns it back into a fill and a
79
+ * monochrome one only greys it. The glyph reads in the ordinary foreground
80
+ * colour, and in red on `destructive` — the colour that carries meaning goes
81
+ * on the glyph, where it stays legible. Pressed, it answers the way the platform's
82
+ * own glass controls do: the material swells and brightens under the finger,
83
+ * in place of this component's press scale. The material exists on iOS 26 and
84
+ * above with the optional `expo-glass-effect` installed. Everywhere else — older iOS,
85
+ * Android, web, Reduce Transparency on — the flag is inert and the button keeps
86
+ * its ordinary fill, so nothing has to be written twice.
87
+ *
49
88
  * Opening also drops a scrim over the screen. Not for looks: an open dial is
50
89
  * modal — the next tap either picks something or closes it — and a scrim is what
51
90
  * says so, as well as what catches the tap that closes it.
@@ -64,12 +103,14 @@
64
103
  */
65
104
  import { Children, cloneElement, createContext, forwardRef, isValidElement, useCallback, useContext, useMemo, useState } from 'react';
66
105
  import { Pressable, StyleSheet, View } from 'react-native';
67
- import Animated, { interpolate, useAnimatedStyle, useDerivedValue, withTiming } from 'react-native-reanimated';
106
+ import Animated, { Easing, Extrapolation, interpolate, runOnJS, useAnimatedStyle, useDerivedValue, withSpring } from 'react-native-reanimated';
68
107
  import { tv } from 'tailwind-variants';
69
108
  import { useCSSVariable } from 'uniwind';
70
109
  import { IconColorProvider } from "../../icons/index.js";
71
110
  import { useBackHandler } from "../../hooks/use-back-handler.js";
111
+ import { NativeHost, getComposeMenu, getSwiftUIMenu, getSwiftUIModifiers } from "../../native/index.js";
72
112
  import { AnimatedPressable } from "../../primitives/animated-pressable.js";
113
+ import { Glass, GlassContainer, useGlassMaterial } from "../../primitives/glass.js";
73
114
  import { Scrim } from "../../primitives/scrim.js";
74
115
  import { Text } from "../../primitives/text.js";
75
116
  import { cn } from "../../utils/cn.js";
@@ -80,18 +121,127 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
80
121
  /** How far from the edges a floating button sits, in points. */
81
122
  const DEFAULT_OFFSET = 16;
82
123
 
83
- /** How long the dial takes to open, and how much each action lags the one above it. */
84
- const OPEN_DURATION = 220;
85
- const STAGGER = 45;
124
+ /**
125
+ * The spring the dial opens on. Underdamped enough to overshoot a little and
126
+ * settle, which is what makes buttons read as arriving rather than being
127
+ * placed; the stagger between actions comes from where each one reads the
128
+ * spring, not from a delay.
129
+ */
130
+ const OPEN_SPRING = {
131
+ damping: 15,
132
+ stiffness: 170,
133
+ mass: 0.9
134
+ };
135
+
136
+ /** How far behind the action above starts, as a share of the dial's opening. */
137
+ const STAGGER = 0.08;
86
138
 
87
139
  /** How far an action starts below its resting place, in points. */
88
140
  const ACTION_TRAVEL = 12;
89
141
 
142
+ /**
143
+ * How a glass dial's action starts: nothing, sitting on the trigger, and
144
+ * merging with it while the two are within this distance. Scale, not
145
+ * opacity — the material survives a zero scale and not a zero opacity. Shorter than the
146
+ * dial's gaps, so pieces at rest stay separate and only overlapping ones
147
+ * flow together.
148
+ */
149
+ const RISE_FROM_SCALE = 0;
150
+ const DIAL_BLEND = 6;
151
+
152
+ /** How far into an action's arrival its label grows out of it, and from how far aside. */
153
+ const LABEL_AFTER = 0.45;
154
+ const LABEL_TRAVEL = 16;
155
+
90
156
  /** A quarter turn on the trigger while the dial is open — a plus becomes a cross. */
91
157
  const OPEN_ROTATION = 45;
158
+
159
+ /** How the menu is drawn: the platform's own shape, or rows with a glyph well. */
160
+
161
+ /** Which side of a menu row the glyph sits on. */
162
+
163
+ /**
164
+ * The metrics of each appearance, in points. Fixed rather than measured so
165
+ * the panel's height — and with it the point it grows from — is known on the
166
+ * first frame.
167
+ */
168
+
169
+ const MENU_METRICS = {
170
+ /* As wide and as round as the platform's own menus, with the glyph after
171
+ the label and a hairline between rows, the way they draw it. */
172
+ platform: {
173
+ width: 250,
174
+ radius: 26,
175
+ row: 44,
176
+ hairline: 1,
177
+ padding: 6,
178
+ icon: 'trailing'
179
+ },
180
+ /* Tighter, with the glyph leading in a tinted well and each row its own
181
+ pill — the shape of a menu an app designed rather than the system. */
182
+ wells: {
183
+ width: 224,
184
+ radius: 22,
185
+ row: 48,
186
+ hairline: 0,
187
+ padding: 6,
188
+ icon: 'leading'
189
+ }
190
+ };
191
+
192
+ /**
193
+ * How small the menu is once it has left the trigger, and how far into the
194
+ * dial's spring it gets there.
195
+ *
196
+ * A closed panel has to be *nothing*, not something small. Resting at a
197
+ * fraction of its size leaves a box above the trigger for as long as the
198
+ * spring takes to satisfy its completion threshold — which is most of a
199
+ * second, and is not a frame anyone would call an animation. So zero is where
200
+ * it starts and ends, and the pop to `MENU_FROM_SCALE` happens over the first
201
+ * sliver of the spring: the panel still reads as coming out of the button at a
202
+ * size rather than growing from a point.
203
+ */
204
+ const MENU_FROM_SCALE = 0.3;
205
+ const MENU_POP_AT = 0.06;
206
+
207
+ /** The gap between the panel and the trigger — the group's `gap-3`. */
208
+ const GROUP_GAP = 12;
209
+
210
+ /**
211
+ * Where the menu grows from: the centre of the trigger, in the panel's own
212
+ * coordinates.
213
+ *
214
+ * Not the panel's corner. The panel sits a gap above the trigger, and a panel
215
+ * scaling from its own corner grows out of thin air just above the button.
216
+ * Scaling about the trigger's centre — below the panel, and in from its
217
+ * edge by half a button — is what makes it come out of the button.
218
+ */
219
+ function menuOrigin(placement, metrics, width, count, size) {
220
+ const height = metrics.padding * 2 + metrics.row * count + metrics.hairline * Math.max(0, count - 1);
221
+ const half = SIZE_PX[size] / 2;
222
+ const y = height + GROUP_GAP + half;
223
+ if (placement === 'bottom-left') return [half, y, 0];
224
+ if (placement === 'bottom-center') return [width / 2, y, 0];
225
+ return [width - half, y, 0];
226
+ }
227
+
228
+ /** How the group lines its parts up under each placement. */
229
+ const GROUP_ALIGN = {
230
+ 'bottom-right': 'items-end',
231
+ 'bottom-center': 'items-center',
232
+ 'bottom-left': 'items-start'
233
+ };
234
+
235
+ /** The diameter of each size, in points — the radius the material rounds itself to. */
236
+ const SIZE_PX = {
237
+ sm: 44,
238
+ md: 56,
239
+ lg: 64
240
+ };
92
241
  const fabVariants = tv({
93
242
  slots: {
94
- root: 'flex-row items-center justify-center gap-2 rounded-full bg-primary shadow-lg',
243
+ root: 'items-center justify-center rounded-full bg-primary shadow-lg',
244
+ content: 'flex-row items-center justify-center gap-2',
95
245
  label: 'font-medium text-primary-foreground'
96
246
  },
97
247
  variants: {
@@ -138,9 +288,27 @@ const fabVariants = tv({
138
288
  true: {
139
289
  root: 'opacity-[0.64] shadow-none'
140
290
  }
291
+ },
292
+ /* The material replaces the fill, the border and the shadow: it draws its
293
+ own edge, and a shadow under a translucent surface is a smudge behind it.
294
+ Only set when the material is really being drawn — see `useGlassMaterial`. */
295
+ glass: {
296
+ true: {
297
+ root: 'border-0 bg-transparent shadow-none'
298
+ }
141
299
  }
142
300
  },
143
- compoundVariants: [{
301
+ compoundVariants: [
302
+ /* A faded material stops being one, so a disabled glass button dims what
303
+ sits on the material rather than the material itself. */
304
+ {
305
+ glass: true,
306
+ disabled: true,
307
+ class: {
308
+ root: 'opacity-100',
309
+ content: 'opacity-[0.64]'
310
+ }
311
+ }, {
144
312
  extended: true,
145
313
  size: 'sm',
146
314
  class: {
@@ -180,7 +348,8 @@ const fabVariants = tv({
180
348
  defaultVariants: {
181
349
  size: 'md',
182
350
  variant: 'primary',
183
- extended: false
351
+ extended: false,
352
+ glass: false
184
353
  }
185
354
  });
186
355
 
@@ -196,6 +365,22 @@ const CONTENT_COLOR_VAR = {
196
365
  surface: '--color-foreground'
197
366
  };
198
367
 
368
+ /**
369
+ * The token each variant's glyph reads in over the glass.
370
+ *
371
+ * The material is never tinted: a tint is a dimmer, and at any strength it
372
+ * turns the glass back into a fill or greys it. So the glass is neither light
373
+ * nor dark and the glyph reads in the ordinary foreground — except on
374
+ * `destructive`, where the colour that carries meaning goes on the glyph
375
+ * instead, the way a menu's destructive row is red text on the same panel.
376
+ */
377
+ const GLASS_CONTENT_COLOR_VAR = {
378
+ primary: '--color-foreground',
379
+ destructive: '--color-destructive',
380
+ secondary: '--color-foreground',
381
+ surface: '--color-foreground'
382
+ };
383
+
199
384
  /** Where a floating button parks itself, given its offset. */
200
385
  function anchor(placement, offset) {
201
386
  const base = {
@@ -231,46 +416,86 @@ const FabRoot = /*#__PURE__*/forwardRef(({
231
416
  variant,
232
417
  disabled = false,
233
418
  haptics = false,
419
+ glass = false,
234
420
  onPress,
235
421
  style,
422
+ accessibilityState,
236
423
  ...props
237
424
  }, ref) => {
238
425
  const isDisabled = Boolean(disabled);
426
+ // Asked for *and* drawable. Where the material cannot be drawn the flag
427
+ // changes nothing, so the fill, border and shadow all stay.
428
+ const material = useGlassMaterial() && glass;
429
+ const resolvedVariant = variant ?? 'primary';
239
430
  const {
240
431
  root,
432
+ content,
241
433
  label
242
434
  } = fabVariants({
243
435
  size,
244
436
  variant,
245
437
  extended: extended && !!children,
246
- disabled: isDisabled
438
+ disabled: isDisabled,
439
+ glass: material
247
440
  });
248
- const themed = useCSSVariable(CONTENT_COLOR_VAR[variant ?? 'primary']);
441
+ const themed = useCSSVariable((material ? GLASS_CONTENT_COLOR_VAR : CONTENT_COLOR_VAR)[resolvedVariant]);
249
442
  const contentColor = typeof themed === 'string' ? themed : undefined;
250
443
  const handlePress = useCallback(event => {
251
444
  if (haptics) selectionTick();
252
445
  onPress?.(event);
253
446
  }, [haptics, onPress]);
447
+ const pressable = /*#__PURE__*/_jsx(AnimatedPressable, {
448
+ ref: ref,
449
+ ...props,
450
+ accessibilityRole: "button"
451
+ // Whatever the caller says about the button — a group's trigger says
452
+ // whether it is expanded — with the disabled state kept authoritative.
453
+ ,
454
+ accessibilityState: {
455
+ ...accessibilityState,
456
+ disabled: isDisabled
457
+ },
458
+ disabled: isDisabled,
459
+ onPress: handlePress
460
+ // The platform animates the glass under a touch; a second scale on
461
+ // top of it would fight it.
462
+ ,
463
+ pressScale: material ? 1 : undefined,
464
+ className: material ? 'h-full w-full items-center justify-center' : root({
465
+ className
466
+ }),
467
+ style: material ? undefined : [placement ? anchor(placement, offset) : null, style],
468
+ children: /*#__PURE__*/_jsxs(View, {
469
+ className: content(),
470
+ children: [icon, extended && children ? /*#__PURE__*/_jsx(Text, {
471
+ className: label({
472
+ className: material ? resolvedVariant === 'destructive' ? 'text-destructive' : 'text-foreground' : undefined
473
+ }),
474
+ children: children
475
+ }) : null]
476
+ })
477
+ });
478
+
479
+ /*
480
+ * Drawn in glass, the material is the box and the pressable fills it. The
481
+ * platform only tracks a touch that lands inside the glass view, and
482
+ * tracking it is what makes the button swell and glow under the finger,
483
+ * so the pressable has to be inside rather than under it. It rounds
484
+ * itself to the button's own radius: the material clipped by a rounded
485
+ * parent loses its lit edge.
486
+ */
254
487
  return /*#__PURE__*/_jsx(IconColorProvider, {
255
488
  color: contentColor,
256
- children: /*#__PURE__*/_jsxs(AnimatedPressable, {
257
- ref: ref,
258
- ...props,
259
- accessibilityRole: "button",
260
- accessibilityState: {
261
- disabled: isDisabled
262
- },
263
- disabled: isDisabled,
264
- onPress: handlePress,
489
+ children: material ? /*#__PURE__*/_jsx(Glass, {
490
+ interactive: true,
491
+ variant: "regular",
492
+ radius: SIZE_PX[size ?? 'md'] / 2,
265
493
  className: root({
266
494
  className
267
495
  }),
268
496
  style: [placement ? anchor(placement, offset) : null, style],
269
- children: [icon, extended && children ? /*#__PURE__*/_jsx(Text, {
270
- className: label(),
271
- children: children
272
- }) : null]
273
- })
497
+ children: pressable
498
+ }) : pressable
274
499
  });
275
500
  });
276
501
  FabRoot.displayName = 'Fab';
@@ -281,6 +506,20 @@ FabRoot.displayName = 'Fab';
281
506
 
282
507
  /** Whatever was written as a dial child, and the one prop a slot reaches for. */
283
508
 
509
+ /**
510
+ * What opens out of the trigger: a column of buttons, one panel of rows, or
511
+ * the platform's own menu.
512
+ */
513
+
514
+ /**
515
+ * How big a hosted glyph is drawn in a platform menu row, in points.
516
+ *
517
+ * Stated rather than measured because it has to be: anything of ours inside
518
+ * the native tree needs a definite size on both axes above it, and an icon
519
+ * whose box the platform is left to work out is the crash this library has
520
+ * already paid for twice.
521
+ */
522
+ const NATIVE_ROW_ICON = 24;
284
523
  const FabGroupContext = /*#__PURE__*/createContext(null);
285
524
  function useFabGroup(part) {
286
525
  const ctx = useContext(FabGroupContext);
@@ -305,10 +544,18 @@ const FabGroup = /*#__PURE__*/forwardRef(({
305
544
  onOpenChange,
306
545
  placement = 'bottom-right',
307
546
  offset = DEFAULT_OFFSET,
547
+ layout = 'dial',
548
+ appearance = 'platform',
549
+ iconPlacement,
550
+ menuWidth,
551
+ menuRadius,
552
+ menuClassName,
553
+ rowClassName,
308
554
  size = 'md',
309
555
  variant,
310
556
  disabled = false,
311
557
  haptics = false,
558
+ glass = false,
312
559
  blur = false,
313
560
  rotateOnOpen = true,
314
561
  accessibilityLabel,
@@ -317,6 +564,10 @@ const FabGroup = /*#__PURE__*/forwardRef(({
317
564
  }, ref) => {
318
565
  const [internalOpen, setInternalOpen] = useState(false);
319
566
  const open = openProp ?? internalOpen;
567
+ // Asked for *and* drawable, decided once here so the trigger, the actions
568
+ // and their motion all agree; where the material cannot be drawn the flag
569
+ // is inert everywhere rather than in the trigger alone.
570
+ const material = useGlassMaterial() && glass;
320
571
  const setOpen = useCallback(next => {
321
572
  if (openProp === undefined) setInternalOpen(next);
322
573
  onOpenChange?.(next);
@@ -333,6 +584,18 @@ const FabGroup = /*#__PURE__*/forwardRef(({
333
584
  */
334
585
  const actionCount = actions.length;
335
586
 
587
+ /*
588
+ * The platform's menu, when one was asked for and the toolkit is there.
589
+ *
590
+ * Resolved before anything below reads `layout`, because a native menu
591
+ * that cannot be drawn is not an error — it falls back to the panel, and
592
+ * every decision after this one has to be made against the layout that is
593
+ * really being drawn rather than the one that was requested.
594
+ */
595
+ const swiftMenu = layout === 'native' ? getSwiftUIMenu() : null;
596
+ const composeMenu = layout === 'native' && !swiftMenu ? getComposeMenu() : null;
597
+ const resolvedLayout = layout === 'native' && !swiftMenu && !composeMenu ? 'menu' : layout;
598
+
336
599
  /*
337
600
  * One shared value for the whole dial rather than one per action.
338
601
  *
@@ -341,9 +604,26 @@ const FabGroup = /*#__PURE__*/forwardRef(({
341
604
  * driving every row, and there is no chain of JavaScript timeouts to get
342
605
  * out of step with itself when the dial is closed halfway through opening.
343
606
  */
344
- const progress = useDerivedValue(() => withTiming(open ? 1 : 0, {
345
- duration: OPEN_DURATION + STAGGER * actionCount
346
- }), [open, actionCount]);
607
+ /*
608
+ * The actions stay mounted while the spring runs back down, so closing is
609
+ * the opening in reverse rather than a cut. `closing` is set the moment
610
+ * `open` drops and cleared by the spring when it comes to rest; the
611
+ * mounted-only-while-open rule below still holds once it has.
612
+ */
613
+ const [closing, setClosing] = useState(false);
614
+ // Noted during render rather than in an effect: an effect lands a frame
615
+ // after the render that dropped `open`, and that frame would have no
616
+ // actions in it — a blink, and a remount of the material mid-flight.
617
+ const [wasOpen, setWasOpen] = useState(open);
618
+ if (wasOpen !== open) {
619
+ setWasOpen(open);
620
+ if (!open) setClosing(true);
621
+ }
622
+ const settle = useCallback(() => setClosing(false), []);
623
+ const progress = useDerivedValue(() => withSpring(open ? 1 : 0, OPEN_SPRING, finished => {
624
+ if (finished && !open) runOnJS(settle)();
625
+ }), [open, settle]);
626
+ const present = open || closing;
347
627
  const rotation = useAnimatedStyle(() => ({
348
628
  transform: [{
349
629
  rotate: `${interpolate(progress.value, [0, 1], [0, OPEN_ROTATION])}deg`
@@ -354,8 +634,13 @@ const FabGroup = /*#__PURE__*/forwardRef(({
354
634
  progress,
355
635
  count: actionCount,
356
636
  size,
637
+ glass: material,
638
+ layout: resolvedLayout,
639
+ appearance,
640
+ iconPlacement: iconPlacement ?? MENU_METRICS[appearance].icon,
641
+ rowClassName,
357
642
  close
358
- }), [progress, actionCount, size, close]);
643
+ }), [progress, actionCount, size, material, resolvedLayout, appearance, iconPlacement, rowClassName, close]);
359
644
  const toggle = useCallback(() => {
360
645
  if (haptics) selectionTick();
361
646
  setOpen(!open);
@@ -364,6 +649,231 @@ const FabGroup = /*#__PURE__*/forwardRef(({
364
649
  // An open dial owns the back button: back should shut it, not leave it
365
650
  // standing over the screen underneath.
366
651
  useBackHandler(open, close);
652
+ const themedDestructive = useCSSVariable('--color-destructive');
653
+ const Group = material && resolvedLayout === 'dial' ? GlassContainer : View;
654
+
655
+ /*
656
+ * The trigger as the platform hosts it.
657
+ *
658
+ * Sized here rather than left to the platform, on both axes where both are
659
+ * known. An axis handed to `matchContents` is written back to for good, and
660
+ * a hosted view with nothing definite above it is the crash this library
661
+ * has already paid for twice — so a circle states its diameter, and only an
662
+ * extended button's width, which is its label's, is the platform's to
663
+ * measure.
664
+ */
665
+ const triggerSize = SIZE_PX[size];
666
+ const triggerExtended = !!label;
667
+ const triggerFrame = triggerExtended ? {
668
+ height: triggerSize
669
+ } : {
670
+ width: triggerSize,
671
+ height: triggerSize
672
+ };
673
+ const nativeTrigger = /*#__PURE__*/_jsx(View
674
+ // On iOS the menu owns the tap: a pressable in front of it would eat
675
+ // the touch and the menu would never open. Android's is controlled, so
676
+ // there the button is what opens it.
677
+ , {
678
+ pointerEvents: swiftMenu ? 'none' : 'auto',
679
+ style: triggerFrame,
680
+ children: /*#__PURE__*/_jsx(FabRoot, {
681
+ icon: icon,
682
+ extended: triggerExtended,
683
+ size: size,
684
+ variant: variant,
685
+ disabled: disabled,
686
+ glass: glass,
687
+ accessibilityLabel: accessibilityLabel,
688
+ accessibilityState: {
689
+ disabled,
690
+ expanded: open
691
+ },
692
+ onPress: composeMenu ? toggle : undefined,
693
+ children: label
694
+ })
695
+ });
696
+
697
+ /*
698
+ * The screen behind an open platform menu, frosted the way it is behind
699
+ * the dial and the panel — the page is what recedes, and a native menu
700
+ * over an untouched page reads as a control belonging to something else.
701
+ *
702
+ * It closes on a tap, which is the safety net rather than the mechanism.
703
+ * The platform dismisses its own menu from its own window, so this never
704
+ * sees that tap and never competes for it; what it catches is the next
705
+ * one. Menu content is not promised an `onDisappear`, and a frosted page
706
+ * with no way back would be worse than no scrim at all.
707
+ */
708
+ const nativeScrim = open ? /*#__PURE__*/_jsx(Pressable, {
709
+ accessibilityRole: "button",
710
+ accessibilityLabel: "Close",
711
+ onPress: close,
712
+ style: StyleSheet.absoluteFill,
713
+ children: /*#__PURE__*/_jsx(Scrim, {
714
+ blur: blur,
715
+ dimClassName: "bg-black/32"
716
+ })
717
+ }) : null;
718
+
719
+ /*
720
+ * SwiftUI's menu. The trigger goes through the `label` slot, which takes a
721
+ * view as readily as a string — so the button stays ours and only the menu
722
+ * is the platform's.
723
+ *
724
+ * The open state is not passed and cannot be: SwiftUI owns it. `open` and
725
+ * `onOpenChange` are inert on this path, which is stated on the prop.
726
+ */
727
+ if (swiftMenu) {
728
+ const {
729
+ Host,
730
+ RNHostView,
731
+ Menu,
732
+ Button
733
+ } = swiftMenu;
734
+ const swiftModifiers = getSwiftUIModifiers();
735
+
736
+ /*
737
+ * What each row carries beyond its label.
738
+ *
739
+ * The first one also carries the menu's open state. SwiftUI's menu owns
740
+ * that state and reports nothing about it, but it builds its content
741
+ * only when it presents it — so the first row appearing is the menu
742
+ * appearing, and that is what puts the screen behind it under the
743
+ * scrim.
744
+ */
745
+ const itemModifiers = (item, index) => {
746
+ const list = [];
747
+ if (item.disabled && swiftModifiers?.disabled) {
748
+ list.push(swiftModifiers.disabled(true));
749
+ }
750
+ if (index === 0 && swiftModifiers?.onAppear) {
751
+ list.push(swiftModifiers.onAppear(() => setOpen(true)));
752
+ }
753
+ /*
754
+ * Every row carries the teardown, not just the first.
755
+ *
756
+ * The one that reports the menu opening is enough to raise the scrim,
757
+ * but taking it down cannot rest on a single view: whichever row
758
+ * SwiftUI tears down first has to be able to say so, or a menu
759
+ * dismissed from anywhere else leaves the page frosted behind it.
760
+ */
761
+ if (swiftModifiers?.onDisappear) {
762
+ list.push(swiftModifiers.onDisappear(() => setOpen(false)));
763
+ }
764
+ return list.length > 0 ? list : undefined;
765
+ };
766
+ return /*#__PURE__*/_jsxs(_Fragment, {
767
+ children: [nativeScrim, /*#__PURE__*/_jsx(NativeHost, {
768
+ host: Host,
769
+ matchContents: triggerExtended ? {
770
+ horizontal: true
771
+ } : false,
772
+ ignoreSafeArea: "keyboard",
773
+ style: [anchor(placement, offset), triggerFrame, style],
774
+ ...props,
775
+ children: /*#__PURE__*/_jsx(Menu, {
776
+ label: /*#__PURE__*/_jsx(RNHostView, {
777
+ matchContents: true,
778
+ children: nativeTrigger
779
+ }),
780
+ children: actions.map((action, index) => {
781
+ const item = action.props;
782
+ return /*#__PURE__*/_jsx(Button, {
783
+ label: item.label ?? '',
784
+ systemImage: item.systemImage,
785
+ role: item.destructive ? 'destructive' : 'default'
786
+ // Picking a row closes the menu, so the scrim has to come
787
+ // down with it rather than waiting to be told the content
788
+ // went away.
789
+ ,
790
+ onPress: item.disabled ? undefined : () => {
791
+ close();
792
+ item.onPress?.();
793
+ },
794
+ modifiers: itemModifiers(item, index)
795
+ }, index);
796
+ })
797
+ })
798
+ })]
799
+ });
800
+ }
801
+
802
+ /*
803
+ * Compose's dropdown menu — the same instruction, a different control.
804
+ *
805
+ * This one takes the open state rather than owning it, so the group's own
806
+ * state drives it and `open`/`onOpenChange` work here. Its rows take a text
807
+ * slot rather than a label prop, and a glyph of ours goes in the leading
808
+ * slot at a stated size.
809
+ */
810
+ if (composeMenu) {
811
+ const {
812
+ Host,
813
+ RNHostView,
814
+ Text: ComposeText,
815
+ DropdownMenu,
816
+ DropdownMenuItem
817
+ } = composeMenu;
818
+ const destructiveColor = typeof themedDestructive === 'string' ? themedDestructive : undefined;
819
+ return /*#__PURE__*/_jsxs(_Fragment, {
820
+ children: [nativeScrim, /*#__PURE__*/_jsx(NativeHost, {
821
+ host: Host,
822
+ matchContents: triggerExtended ? {
823
+ horizontal: true
824
+ } : false,
825
+ ignoreSafeArea: "keyboard",
826
+ style: [anchor(placement, offset), triggerFrame, style],
827
+ ...props,
828
+ children: /*#__PURE__*/_jsxs(DropdownMenu, {
829
+ expanded: open,
830
+ onDismissRequest: close,
831
+ children: [/*#__PURE__*/_jsx(DropdownMenu.Trigger, {
832
+ children: /*#__PURE__*/_jsx(RNHostView, {
833
+ matchContents: true,
834
+ children: nativeTrigger
835
+ })
836
+ }), /*#__PURE__*/_jsx(DropdownMenu.Items, {
837
+ children: actions.map((action, index) => {
838
+ const item = action.props;
839
+ return /*#__PURE__*/_jsxs(DropdownMenuItem, {
840
+ enabled: !item.disabled,
841
+ elementColors: item.destructive && destructiveColor ? {
842
+ textColor: destructiveColor
843
+ } : undefined,
844
+ onClick: () => {
845
+ close();
846
+ item.onPress?.();
847
+ },
848
+ children: [item.icon ? /*#__PURE__*/_jsx(DropdownMenuItem.LeadingIcon, {
849
+ children: /*#__PURE__*/_jsx(RNHostView, {
850
+ matchContents: true,
851
+ style: {
852
+ width: NATIVE_ROW_ICON,
853
+ height: NATIVE_ROW_ICON
854
+ },
855
+ children: /*#__PURE__*/_jsx(View, {
856
+ style: {
857
+ width: NATIVE_ROW_ICON,
858
+ height: NATIVE_ROW_ICON,
859
+ alignItems: 'center',
860
+ justifyContent: 'center'
861
+ },
862
+ children: item.icon
863
+ })
864
+ })
865
+ }) : null, /*#__PURE__*/_jsx(DropdownMenuItem.Text, {
866
+ children: /*#__PURE__*/_jsx(ComposeText, {
867
+ children: item.label ?? ''
868
+ })
869
+ })]
870
+ }, index);
871
+ })
872
+ })]
873
+ })
874
+ })]
875
+ });
876
+ }
367
877
 
368
878
  /*
369
879
  * Two absolutely positioned siblings, scrim first, both in the group's own
@@ -400,12 +910,23 @@ const FabGroup = /*#__PURE__*/forwardRef(({
400
910
  })
401
911
  }) : null, /*#__PURE__*/_jsx(FabGroupContext.Provider, {
402
912
  value: context,
403
- children: /*#__PURE__*/_jsxs(View, {
913
+ children: /*#__PURE__*/_jsxs(Group, {
404
914
  ref: ref,
405
- className: cn('items-end gap-3', className),
915
+ spacing: material && resolvedLayout === 'dial' ? DIAL_BLEND : undefined,
916
+ className: cn(resolvedLayout === 'menu' ? GROUP_ALIGN[placement] : 'items-end', 'gap-3', className),
406
917
  style: [anchor(placement, offset), style],
407
918
  ...props,
408
- children: [open ? actions.map((action, index) => /*#__PURE__*/_jsx(FabActionSlot, {
919
+ children: [present && resolvedLayout === 'menu' ? /*#__PURE__*/_jsx(FabMenu, {
920
+ progress: progress,
921
+ placement: placement,
922
+ size: size,
923
+ glass: material,
924
+ appearance: appearance,
925
+ width: menuWidth,
926
+ radius: menuRadius,
927
+ className: menuClassName,
928
+ children: actions
929
+ }) : null, present && resolvedLayout === 'dial' ? actions.map((action, index) => /*#__PURE__*/_jsx(FabActionSlot, {
409
930
  index: index,
410
931
  children: action
411
932
  }, index)) : null, /*#__PURE__*/_jsx(FabRoot, {
@@ -417,6 +938,7 @@ const FabGroup = /*#__PURE__*/forwardRef(({
417
938
  size: size,
418
939
  variant: variant,
419
940
  disabled: disabled,
941
+ glass: glass,
420
942
  accessibilityLabel: accessibilityLabel,
421
943
  accessibilityState: {
422
944
  disabled,
@@ -431,6 +953,103 @@ const FabGroup = /*#__PURE__*/forwardRef(({
431
953
  });
432
954
  FabGroup.displayName = 'Fab.Group';
433
955
 
956
+ /**
957
+ * The menu panel: one surface, springing out of the trigger's corner.
958
+ *
959
+ * It scales up from that corner rather than fading in, and the two are not
960
+ * interchangeable: the material cannot be faded — at zero it stops drawing
961
+ * and does not come back — and a panel growing out of the button is what
962
+ * says the button opened. The rows inside fade in on the dial's own stagger,
963
+ * nearest the trigger first.
964
+ */
965
+ function FabMenu({
966
+ progress,
967
+ placement,
968
+ size,
969
+ glass,
970
+ appearance,
971
+ width: widthProp,
972
+ radius: radiusProp,
973
+ className,
974
+ children
975
+ }) {
976
+ const metrics = MENU_METRICS[appearance];
977
+ const width = widthProp ?? metrics.width;
978
+ const radius = radiusProp ?? metrics.radius;
979
+
980
+ /*
981
+ * The panel rides the group's own spring rather than one of its own.
982
+ *
983
+ * It used to run a second, faster spring, and closing raced it against the
984
+ * one that decides when the panel unmounts: the panel reached its collapsed
985
+ * scale early and then sat there, a small box above the trigger, until the
986
+ * slower spring came to rest and took it away. One value cannot race
987
+ * itself, so the panel is gone exactly when the dial says it is.
988
+ *
989
+ * Scale and not opacity, for the same reason the dial's actions are: the
990
+ * material survives a zero scale and does not come back from a zero
991
+ * opacity.
992
+ */
993
+ const style = useAnimatedStyle(() => ({
994
+ transform: [{
995
+ scale: interpolate(progress.value, [0, MENU_POP_AT, 1], [0, MENU_FROM_SCALE, 1], Extrapolation.CLAMP)
996
+ }]
997
+ }));
998
+ return /*#__PURE__*/_jsx(Animated.View, {
999
+ style: [style, {
1000
+ transformOrigin: menuOrigin(placement, metrics, width, children.length, size)
1001
+ }],
1002
+ children: /*#__PURE__*/_jsx(Glass, {
1003
+ radius: radius
1004
+ // Without the material the panel is the same surface a popover is.
1005
+ ,
1006
+ fallbackClassName: "border border-border bg-popover shadow-lg",
1007
+ className: cn(appearance === 'wells' ? 'p-1.5' : 'py-1.5', glass ? null : 'shadow-lg', className),
1008
+ style: {
1009
+ width
1010
+ },
1011
+ children: children.map((action, index) => /*#__PURE__*/_jsx(FabActionSlot, {
1012
+ index: index,
1013
+ separator: metrics.hairline > 0 && index > 0,
1014
+ children: action
1015
+ }, index))
1016
+ })
1017
+ });
1018
+ }
1019
+
1020
+ /**
1021
+ * How far along its own arrival an action is, from the dial's one progress.
1022
+ *
1023
+ * The stagger runs bottom-up: the action nearest the trigger arrives first,
1024
+ * which is the order a hand travelling away from the button meets them in.
1025
+ */
1026
+ function slotProgress(progress, count, index) {
1027
+ 'worklet';
1028
+
1029
+ // A short lag per action rather than a full turn each: the actions leave
1030
+ // the trigger nearly together and settle nearly together, so a later one
1031
+ // does not rise through a slot an earlier one is already sitting in.
1032
+ const from = (count - 1 - index) * STAGGER;
1033
+ const to = from + 1 - STAGGER * Math.max(0, count - 1);
1034
+ // The spring's overshoot past its target is passed on to every action, so
1035
+ // a button arrives with a little bounce rather than stopping dead. It is
1036
+ // added on top of the clamped window rather than read through it: a window
1037
+ // that ends before the dial's does would otherwise leave its action past
1038
+ // its slot for good.
1039
+ const within = interpolate(progress, [from, Math.min(1, to)], [0, 1], Extrapolation.CLAMP);
1040
+ return within + Math.max(0, progress - 1);
1041
+ }
1042
+
1043
+ /** The same progress, decelerating into 1 and never past it — for size and opacity. */
1044
+ function settled(t) {
1045
+ 'worklet';
1046
+
1047
+ return Easing.out(Easing.cubic)(Math.min(1, Math.max(0, t)));
1048
+ }
1049
+
1050
+ /** Which slot an action is in — what it needs to know to animate itself. */
1051
+ const FabSlotContext = /*#__PURE__*/createContext(0);
1052
+
434
1053
  /**
435
1054
  * One action's slot in the unfolding.
436
1055
  *
@@ -439,13 +1058,19 @@ FabGroup.displayName = 'Fab.Group';
439
1058
  */
440
1059
  function FabActionSlot({
441
1060
  index,
1061
+ separator = false,
442
1062
  children
443
1063
  }) {
444
1064
  const {
445
1065
  progress,
446
1066
  count,
1067
+ glass,
1068
+ layout,
447
1069
  close
448
1070
  } = useFabGroup('Fab.Action');
1071
+ // A menu's rows are content on one panel, not glass of their own, so they
1072
+ // may fade; a dial's actions are each their own material.
1073
+ const material = glass && layout === 'dial';
449
1074
 
450
1075
  /*
451
1076
  * Whatever is in the slot closes the dial when it is pressed.
@@ -460,26 +1085,38 @@ function FabActionSlot({
460
1085
  onPress
461
1086
  } = children.props;
462
1087
  const child = onPress ? /*#__PURE__*/cloneElement(children, {
463
- onPress: () => {
1088
+ onPress: event => {
464
1089
  close();
465
- onPress();
1090
+ onPress(event);
466
1091
  }
467
1092
  }) : children;
1093
+
1094
+ /*
1095
+ * A glass action never fades. The material stops drawing under an ancestor
1096
+ * at zero opacity and does not come back when the opacity does, so a fade
1097
+ * from zero is a button that sometimes never appears — whichever ones got
1098
+ * their first frame at zero. A glass dial's action animates its own parts
1099
+ * instead, rising out of the trigger; see `Fab.Action`. The slot only
1100
+ * fades a plain action, where it reads better on a flat surface.
1101
+ */
468
1102
  const style = useAnimatedStyle(() => {
469
- const steps = Math.max(1, count);
470
- const from = (count - 1 - index) / (steps + 1);
471
- const to = from + 1 / (steps + 1) + 0.35;
472
- const t = interpolate(progress.value, [from, Math.min(1, to)], [0, 1], 'clamp');
1103
+ if (material) return {};
1104
+ const t = slotProgress(progress.value, count, index);
473
1105
  return {
474
- opacity: t,
1106
+ opacity: settled(t),
475
1107
  transform: [{
476
1108
  translateY: interpolate(t, [0, 1], [ACTION_TRAVEL, 0])
477
1109
  }]
478
1110
  };
479
1111
  });
480
- return /*#__PURE__*/_jsx(Animated.View, {
481
- style: style,
482
- children: child
1112
+ return /*#__PURE__*/_jsx(FabSlotContext.Provider, {
1113
+ value: index,
1114
+ children: /*#__PURE__*/_jsxs(Animated.View, {
1115
+ style: style,
1116
+ children: [separator ? /*#__PURE__*/_jsx(View, {
1117
+ className: "mx-4 h-px bg-border"
1118
+ }) : null, child]
1119
+ })
483
1120
  });
484
1121
  }
485
1122
 
@@ -501,6 +1138,8 @@ function FabActionSlot({
501
1138
  const FabAction = /*#__PURE__*/forwardRef(({
502
1139
  className,
503
1140
  icon,
1141
+ // Read off the element by a native group, never rendered here.
1142
+ systemImage: _systemImage,
504
1143
  label,
505
1144
  onPress,
506
1145
  disabled = false,
@@ -509,35 +1148,141 @@ const FabAction = /*#__PURE__*/forwardRef(({
509
1148
  ...props
510
1149
  }, ref) => {
511
1150
  const {
1151
+ progress,
1152
+ count,
512
1153
  size,
1154
+ glass,
1155
+ layout,
1156
+ appearance,
1157
+ iconPlacement,
1158
+ rowClassName,
513
1159
  close
514
1160
  } = useFabGroup('Fab.Action');
1161
+ const index = useContext(FabSlotContext);
1162
+ const [pressed, setPressed] = useState(false);
1163
+
1164
+ /*
1165
+ * A glass dial's action rises out of the trigger.
1166
+ *
1167
+ * It starts small and sitting on the button, and springs up to its slot
1168
+ * on the dial's stagger; inside the group's glass container the two
1169
+ * materials are one blob until it pulls free. The distance is known from
1170
+ * the fixed sizes, so nothing is measured. The label follows once the
1171
+ * button is most of the way there, sliding in from the button's side —
1172
+ * it is glass too, so it moves rather than fades.
1173
+ */
1174
+ const actionSize = size === 'lg' ? 'md' : 'sm';
1175
+ const rise = (count - 1 - index) * (SIZE_PX[actionSize] + GROUP_GAP) + GROUP_GAP + SIZE_PX[size] / 2 + SIZE_PX[actionSize] / 2;
1176
+ // Travel follows the spring, overshoot and all; size settles without it.
1177
+ const buttonStyle = useAnimatedStyle(() => {
1178
+ const t = slotProgress(progress.value, count, index);
1179
+ return {
1180
+ transform: [{
1181
+ translateY: interpolate(t, [0, 1], [rise, 0])
1182
+ }, {
1183
+ scale: interpolate(settled(t), [0, 1], [RISE_FROM_SCALE, 1])
1184
+ }]
1185
+ };
1186
+ });
1187
+ const chipStyle = useAnimatedStyle(() => {
1188
+ const t = slotProgress(progress.value, count, index);
1189
+ // Rides up with its button, then grows out of it once the button is
1190
+ // most of the way to its slot.
1191
+ const late = settled(interpolate(t, [LABEL_AFTER, 1], [0, 1], 'clamp'));
1192
+ return {
1193
+ transform: [{
1194
+ translateY: interpolate(t, [0, 1], [rise, 0])
1195
+ }, {
1196
+ translateX: interpolate(late, [0, 1], [LABEL_TRAVEL, 0])
1197
+ }, {
1198
+ scale: interpolate(late, [0, 1], [RISE_FROM_SCALE, 1])
1199
+ }]
1200
+ };
1201
+ });
515
1202
  const handlePress = useCallback(() => {
516
1203
  close();
517
1204
  onPress?.();
518
1205
  }, [close, onPress]);
1206
+ const themedRow = useCSSVariable(destructive ? '--color-destructive' : '--color-foreground');
1207
+ const rowColor = typeof themedRow === 'string' ? themedRow : undefined;
1208
+
1209
+ /*
1210
+ * In a menu the action is a row on the panel. The press tints the row
1211
+ * rather than scaling it — a row that shrinks inside a panel that does
1212
+ * not looks detached from it — and the tint is React state rather than a
1213
+ * worklet because it is a colour toggle, not a curve.
1214
+ */
1215
+ if (layout === 'menu') {
1216
+ const wells = appearance === 'wells';
1217
+ const glyph = wells ? /*#__PURE__*/_jsx(View, {
1218
+ className: cn('h-8 w-8 items-center justify-center rounded-full', destructive ? 'bg-destructive/15' : 'bg-foreground/10'),
1219
+ children: icon
1220
+ }) : icon;
1221
+ return /*#__PURE__*/_jsx(IconColorProvider, {
1222
+ color: rowColor,
1223
+ children: /*#__PURE__*/_jsxs(AnimatedPressable, {
1224
+ ref: ref,
1225
+ accessibilityRole: "button",
1226
+ accessibilityState: {
1227
+ disabled
1228
+ },
1229
+ disabled: disabled,
1230
+ onPress: handlePress,
1231
+ onPressIn: () => setPressed(true),
1232
+ onPressOut: () => setPressed(false),
1233
+ pressScale: 1,
1234
+ className: cn('flex-row items-center gap-3', wells ? 'h-12 rounded-2xl px-2' : 'h-11 px-4', iconPlacement === 'trailing' ? 'justify-between' : null, pressed && 'bg-foreground/10', disabled && 'opacity-40', rowClassName, className),
1235
+ ...props,
1236
+ children: [iconPlacement === 'leading' ? glyph : null, /*#__PURE__*/_jsx(Text, {
1237
+ className: cn('shrink', destructive ? 'text-destructive' : 'text-foreground', labelClassName),
1238
+ numberOfLines: 1,
1239
+ children: label
1240
+ }), iconPlacement === 'trailing' ? glyph : null]
1241
+ })
1242
+ });
1243
+ }
519
1244
  return /*#__PURE__*/_jsxs(View, {
520
1245
  className: "flex-row items-center justify-end gap-3",
521
1246
  ...props,
522
- children: [label ? /*#__PURE__*/_jsx(View, {
1247
+ children: [label ?
1248
+ // The chip is in the same material as the button beside it, so a
1249
+ // glass dial is glass all the way across and not glass with paper
1250
+ // labels. Without the material it is the popover surface it was.
1251
+ glass ? /*#__PURE__*/_jsx(Animated.View, {
1252
+ style: chipStyle,
1253
+ children: /*#__PURE__*/_jsx(Glass, {
1254
+ radius: 8,
1255
+ fallbackClassName: "bg-popover shadow-sm",
1256
+ className: "px-2.5 py-1",
1257
+ children: /*#__PURE__*/_jsx(Text, {
1258
+ size: "sm",
1259
+ className: cn('text-foreground', labelClassName),
1260
+ children: label
1261
+ })
1262
+ })
1263
+ }) : /*#__PURE__*/_jsx(View, {
523
1264
  className: "rounded-lg bg-popover px-2.5 py-1 shadow-sm",
524
1265
  children: /*#__PURE__*/_jsx(Text, {
525
1266
  size: "sm",
526
1267
  className: cn('text-foreground', labelClassName),
527
1268
  children: label
528
1269
  })
529
- }) : null, /*#__PURE__*/_jsx(FabRoot, {
530
- ref: ref,
531
- icon: icon
532
- // A step down from the trigger, so the trigger stays the one that
533
- // leads even while the dial it opened is on screen.
534
- ,
535
- size: size === 'lg' ? 'md' : 'sm',
536
- variant: destructive ? 'destructive' : 'surface',
537
- disabled: disabled,
538
- accessibilityLabel: label,
539
- onPress: handlePress,
540
- className: className
1270
+ }) : null, /*#__PURE__*/_jsx(Animated.View, {
1271
+ style: glass ? buttonStyle : undefined,
1272
+ children: /*#__PURE__*/_jsx(FabRoot, {
1273
+ ref: ref,
1274
+ icon: icon
1275
+ // A step down from the trigger, so the trigger stays the one that
1276
+ // leads even while the dial it opened is on screen.
1277
+ ,
1278
+ size: actionSize,
1279
+ variant: destructive ? 'destructive' : 'surface',
1280
+ disabled: disabled,
1281
+ glass: glass,
1282
+ accessibilityLabel: label,
1283
+ onPress: handlePress,
1284
+ className: className
1285
+ })
541
1286
  })]
542
1287
  });
543
1288
  });