panelui-native 0.89.0 → 0.91.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 (35) hide show
  1. package/lib/module/components/fab/index.js +798 -53
  2. package/lib/module/components/fab/index.js.map +1 -1
  3. package/lib/module/components/section-progress/index.js +637 -0
  4. package/lib/module/components/section-progress/index.js.map +1 -0
  5. package/lib/module/hooks/index.js.map +1 -1
  6. package/lib/module/hooks/use-scroll-sections.js +58 -4
  7. package/lib/module/hooks/use-scroll-sections.js.map +1 -1
  8. package/lib/module/index.js +2 -1
  9. package/lib/module/index.js.map +1 -1
  10. package/lib/module/native/index.js +55 -0
  11. package/lib/module/native/index.js.map +1 -1
  12. package/lib/module/primitives/glass.js +92 -9
  13. package/lib/module/primitives/glass.js.map +1 -1
  14. package/lib/typescript/src/components/fab/index.d.ts +133 -1
  15. package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
  16. package/lib/typescript/src/components/section-progress/index.d.ts +156 -0
  17. package/lib/typescript/src/components/section-progress/index.d.ts.map +1 -0
  18. package/lib/typescript/src/hooks/index.d.ts +1 -1
  19. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  20. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +24 -0
  21. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
  22. package/lib/typescript/src/index.d.ts +3 -2
  23. package/lib/typescript/src/index.d.ts.map +1 -1
  24. package/lib/typescript/src/native/index.d.ts +115 -0
  25. package/lib/typescript/src/native/index.d.ts.map +1 -1
  26. package/lib/typescript/src/primitives/glass.d.ts +40 -2
  27. package/lib/typescript/src/primitives/glass.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/components/fab/index.tsx +967 -66
  30. package/src/components/section-progress/index.tsx +813 -0
  31. package/src/hooks/index.ts +1 -0
  32. package/src/hooks/use-scroll-sections.ts +84 -5
  33. package/src/index.ts +21 -1
  34. package/src/native/index.ts +146 -0
  35. package/src/primitives/glass.tsx +116 -11
@@ -44,6 +44,45 @@
44
44
  * instead of a menu appearing. Every action carries its label beside it, because
45
45
  * a column of unlabelled circles is a quiz.
46
46
  *
47
+ * ## The menu
48
+ *
49
+ * ```tsx
50
+ * <Fab.Group layout="menu" glass icon={<PlusIcon size={24} />} accessibilityLabel="Add">
51
+ * <Fab.Action icon={<ImageIcon size={18} />} label="Photo" onPress={addPhoto} />
52
+ * <Fab.Action icon={<FileIcon size={18} />} label="File" onPress={addFile} />
53
+ * </Fab.Group>
54
+ * ```
55
+ *
56
+ * `layout="menu"` unfolds one panel of rows out of the button instead of a
57
+ * column of buttons — the shape the platform's own menus take. Each row is a
58
+ * label with its glyph, on the side the appearance puts it or `iconPlacement`
59
+ * says, and the panel springs out of the corner the
60
+ * trigger sits in, so it reads as the button opening rather than a sheet
61
+ * arriving. Drawn in glass, the panel is one piece of the material; the rows
62
+ * are content on it.
63
+ *
64
+ * ## Glass
65
+ *
66
+ * ```tsx
67
+ * <Fab glass icon={<PlusIcon size={24} />} accessibilityLabel="New note" />
68
+ * ```
69
+ *
70
+ * `glass` draws the button in the material iOS 26 uses for its own floating
71
+ * controls, in place of the variant's fill. A control floating over content is
72
+ * exactly what that material is for: it refracts what scrolls under it rather
73
+ * than covering it, and lifts its own edge, so the shadow goes too.
74
+ *
75
+ * Every variant takes the plain material, because a tint is a dimmer: measured
76
+ * over content, a colour laid on the glass turns it back into a fill and a
77
+ * monochrome one only greys it. The glyph reads in the ordinary foreground
78
+ * colour, and in red on `destructive` — the colour that carries meaning goes
79
+ * on the glyph, where it stays legible. Pressed, it answers the way the platform's
80
+ * own glass controls do: the material swells and brightens under the finger,
81
+ * in place of this component's press scale. The material exists on iOS 26 and
82
+ * above with the optional `expo-glass-effect` installed. Everywhere else — older iOS,
83
+ * Android, web, Reduce Transparency on — the flag is inert and the button keeps
84
+ * its ordinary fill, so nothing has to be written twice.
85
+ *
47
86
  * Opening also drops a scrim over the screen. Not for looks: an open dial is
48
87
  * modal — the next tap either picks something or closes it — and a scrim is what
49
88
  * says so, as well as what catches the tap that closes it.
@@ -73,22 +112,38 @@ import {
73
112
  type ReactElement,
74
113
  type ReactNode,
75
114
  } from 'react';
76
- import { Pressable, StyleSheet, View, type ViewProps } from 'react-native';
115
+ import {
116
+ Pressable,
117
+ StyleSheet,
118
+ View,
119
+ type GestureResponderEvent,
120
+ type ViewProps,
121
+ } from 'react-native';
77
122
  import Animated, {
123
+ Easing,
124
+ Extrapolation,
78
125
  interpolate,
126
+ runOnJS,
79
127
  useAnimatedStyle,
80
128
  useDerivedValue,
81
- withTiming,
129
+ withSpring,
82
130
  type SharedValue,
83
131
  } from 'react-native-reanimated';
84
132
  import { tv, type VariantProps } from 'tailwind-variants';
85
133
  import { useCSSVariable } from 'uniwind';
86
134
  import { IconColorProvider } from '../../icons';
87
135
  import { useBackHandler } from '../../hooks/use-back-handler';
136
+ import {
137
+ NativeHost,
138
+ getComposeMenu,
139
+ getSwiftUIMenu,
140
+ getSwiftUIModifiers,
141
+ } from '../../native';
88
142
  import {
89
143
  AnimatedPressable,
90
144
  type AnimatedPressableProps,
91
145
  } from '../../primitives/animated-pressable';
146
+ import { Glass, GlassContainer, useGlassMaterial } from '../../primitives/glass';
92
147
  import { Scrim } from '../../primitives/scrim';
93
148
  import { Text } from '../../primitives/text';
94
149
  import { cn } from '../../utils/cn';
@@ -100,19 +155,123 @@ export type FabPlacement = 'bottom-right' | 'bottom-center' | 'bottom-left';
100
155
  /** How far from the edges a floating button sits, in points. */
101
156
  const DEFAULT_OFFSET = 16;
102
157
 
103
- /** How long the dial takes to open, and how much each action lags the one above it. */
104
- const OPEN_DURATION = 220;
105
- const STAGGER = 45;
158
+ /**
159
+ * The spring the dial opens on. Underdamped enough to overshoot a little and
160
+ * settle, which is what makes buttons read as arriving rather than being
161
+ * placed; the stagger between actions comes from where each one reads the
162
+ * spring, not from a delay.
163
+ */
164
+ const OPEN_SPRING = { damping: 15, stiffness: 170, mass: 0.9 } as const;
165
+
166
+ /** How far behind the action above starts, as a share of the dial's opening. */
167
+ const STAGGER = 0.08;
106
168
 
107
169
  /** How far an action starts below its resting place, in points. */
108
170
  const ACTION_TRAVEL = 12;
109
171
 
172
+ /**
173
+ * How a glass dial's action starts: nothing, sitting on the trigger, and
174
+ * merging with it while the two are within this distance. Scale, not
175
+ * opacity — the material survives a zero scale and not a zero opacity. Shorter than the
176
+ * dial's gaps, so pieces at rest stay separate and only overlapping ones
177
+ * flow together.
178
+ */
179
+ const RISE_FROM_SCALE = 0;
180
+ const DIAL_BLEND = 6;
181
+
182
+ /** How far into an action's arrival its label grows out of it, and from how far aside. */
183
+ const LABEL_AFTER = 0.45;
184
+ const LABEL_TRAVEL = 16;
185
+
110
186
  /** A quarter turn on the trigger while the dial is open — a plus becomes a cross. */
111
187
  const OPEN_ROTATION = 45;
112
188
 
189
+ /** How the menu is drawn: the platform's own shape, or rows with a glyph well. */
190
+ export type FabMenuAppearance = 'platform' | 'wells';
191
+
192
+ /** Which side of a menu row the glyph sits on. */
193
+ export type FabMenuIconPlacement = 'leading' | 'trailing';
194
+
195
+ /**
196
+ * The metrics of each appearance, in points. Fixed rather than measured so
197
+ * the panel's height — and with it the point it grows from — is known on the
198
+ * first frame.
199
+ */
200
+ interface MenuMetrics {
201
+ width: number;
202
+ radius: number;
203
+ row: number;
204
+ hairline: number;
205
+ padding: number;
206
+ icon: FabMenuIconPlacement;
207
+ }
208
+
209
+ const MENU_METRICS: Record<FabMenuAppearance, MenuMetrics> = {
210
+ /* As wide and as round as the platform's own menus, with the glyph after
211
+ the label and a hairline between rows, the way they draw it. */
212
+ platform: { width: 250, radius: 26, row: 44, hairline: 1, padding: 6, icon: 'trailing' },
213
+ /* Tighter, with the glyph leading in a tinted well and each row its own
214
+ pill — the shape of a menu an app designed rather than the system. */
215
+ wells: { width: 224, radius: 22, row: 48, hairline: 0, padding: 6, icon: 'leading' },
216
+ };
217
+
218
+ /**
219
+ * How small the menu is once it has left the trigger, and how far into the
220
+ * dial's spring it gets there.
221
+ *
222
+ * A closed panel has to be *nothing*, not something small. Resting at a
223
+ * fraction of its size leaves a box above the trigger for as long as the
224
+ * spring takes to satisfy its completion threshold — which is most of a
225
+ * second, and is not a frame anyone would call an animation. So zero is where
226
+ * it starts and ends, and the pop to `MENU_FROM_SCALE` happens over the first
227
+ * sliver of the spring: the panel still reads as coming out of the button at a
228
+ * size rather than growing from a point.
229
+ */
230
+ const MENU_FROM_SCALE = 0.3;
231
+ const MENU_POP_AT = 0.06;
232
+
233
+ /** The gap between the panel and the trigger — the group's `gap-3`. */
234
+ const GROUP_GAP = 12;
235
+
236
+ /**
237
+ * Where the menu grows from: the centre of the trigger, in the panel's own
238
+ * coordinates.
239
+ *
240
+ * Not the panel's corner. The panel sits a gap above the trigger, and a panel
241
+ * scaling from its own corner grows out of thin air just above the button.
242
+ * Scaling about the trigger's centre — below the panel, and in from its
243
+ * edge by half a button — is what makes it come out of the button.
244
+ */
245
+ function menuOrigin(
246
+ placement: FabPlacement,
247
+ metrics: MenuMetrics,
248
+ width: number,
249
+ count: number,
250
+ size: FabSize
251
+ ): [number, number, number] {
252
+ const height =
253
+ metrics.padding * 2 + metrics.row * count + metrics.hairline * Math.max(0, count - 1);
254
+ const half = SIZE_PX[size] / 2;
255
+ const y = height + GROUP_GAP + half;
256
+ if (placement === 'bottom-left') return [half, y, 0];
257
+ if (placement === 'bottom-center') return [width / 2, y, 0];
258
+ return [width - half, y, 0];
259
+ }
260
+
261
+ /** How the group lines its parts up under each placement. */
262
+ const GROUP_ALIGN: Record<FabPlacement, string> = {
263
+ 'bottom-right': 'items-end',
264
+ 'bottom-center': 'items-center',
265
+ 'bottom-left': 'items-start',
266
+ };
267
+
268
+ /** The diameter of each size, in points — the radius the material rounds itself to. */
269
+ const SIZE_PX = { sm: 44, md: 56, lg: 64 } as const;
270
+
113
271
  const fabVariants = tv({
114
272
  slots: {
115
- root: 'flex-row items-center justify-center gap-2 rounded-full bg-primary shadow-lg',
273
+ root: 'items-center justify-center rounded-full bg-primary shadow-lg',
274
+ content: 'flex-row items-center justify-center gap-2',
116
275
  label: 'font-medium text-primary-foreground',
117
276
  },
118
277
  variants: {
@@ -140,8 +299,17 @@ const fabVariants = tv({
140
299
  disabled: {
141
300
  true: { root: 'opacity-[0.64] shadow-none' },
142
301
  },
302
+ /* The material replaces the fill, the border and the shadow: it draws its
303
+ own edge, and a shadow under a translucent surface is a smudge behind it.
304
+ Only set when the material is really being drawn — see `useGlassMaterial`. */
305
+ glass: {
306
+ true: { root: 'border-0 bg-transparent shadow-none' },
307
+ },
143
308
  },
144
309
  compoundVariants: [
310
+ /* A faded material stops being one, so a disabled glass button dims what
311
+ sits on the material rather than the material itself. */
312
+ { glass: true, disabled: true, class: { root: 'opacity-100', content: 'opacity-[0.64]' } },
145
313
  { extended: true, size: 'sm', class: { root: 'px-4' } },
146
314
  { extended: true, size: 'md', class: { root: 'px-5' } },
147
315
  { extended: true, size: 'lg', class: { root: 'px-6' } },
@@ -153,6 +321,7 @@ const fabVariants = tv({
153
321
  size: 'md',
154
322
  variant: 'primary',
155
323
  extended: false,
324
+ glass: false,
156
325
  },
157
326
  });
158
327
 
@@ -171,6 +340,22 @@ const CONTENT_COLOR_VAR: Record<FabVariant, string> = {
171
340
  surface: '--color-foreground',
172
341
  };
173
342
 
343
+ /**
344
+ * The token each variant's glyph reads in over the glass.
345
+ *
346
+ * The material is never tinted: a tint is a dimmer, and at any strength it
347
+ * turns the glass back into a fill or greys it. So the glass is neither light
348
+ * nor dark and the glyph reads in the ordinary foreground — except on
349
+ * `destructive`, where the colour that carries meaning goes on the glyph
350
+ * instead, the way a menu's destructive row is red text on the same panel.
351
+ */
352
+ const GLASS_CONTENT_COLOR_VAR: Record<FabVariant, string> = {
353
+ primary: '--color-foreground',
354
+ destructive: '--color-destructive',
355
+ secondary: '--color-foreground',
356
+ surface: '--color-foreground',
357
+ };
358
+
174
359
  /** Where a floating button parks itself, given its offset. */
175
360
  function anchor(placement: FabPlacement, offset: number) {
176
361
  const base = { position: 'absolute' as const, bottom: offset };
@@ -214,6 +399,18 @@ export interface FabProps
214
399
  * is silent without it.
215
400
  */
216
401
  haptics?: boolean;
402
+ /**
403
+ * Draw it in Liquid Glass — the material iOS 26 uses for its own floating
404
+ * controls — instead of the variant's fill. Every variant takes the plain
405
+ * material, with its glyph in the foreground colour and in red on
406
+ * `destructive`. Pressed, the material swells and brightens the way the
407
+ * platform's own glass controls do.
408
+ *
409
+ * Needs iOS 26 and the optional `expo-glass-effect`. Below that, on Android,
410
+ * on web, or with Reduce Transparency on, it does nothing and the button
411
+ * keeps its ordinary fill.
412
+ */
413
+ glass?: boolean;
217
414
  /** Required for an icon-only button. A lone glyph reads out as nothing. */
218
415
  accessibilityLabel?: string;
219
416
  }
@@ -231,23 +428,31 @@ const FabRoot = forwardRef<View, FabProps>(
231
428
  variant,
232
429
  disabled = false,
233
430
  haptics = false,
431
+ glass = false,
234
432
  onPress,
235
433
  style,
434
+ accessibilityState,
236
435
  ...props
237
436
  },
238
437
  ref
239
438
  ) => {
240
439
  const isDisabled = Boolean(disabled);
241
- const { root, label } = fabVariants({
440
+ // Asked for *and* drawable. Where the material cannot be drawn the flag
441
+ // changes nothing, so the fill, border and shadow all stay.
442
+ const material = useGlassMaterial() && glass;
443
+ const resolvedVariant = variant ?? 'primary';
444
+ const { root, content, label } = fabVariants({
242
445
  size,
243
446
  variant,
244
447
  extended: extended && !!children,
245
448
  disabled: isDisabled,
449
+ glass: material,
246
450
  });
247
451
 
248
- const themed = useCSSVariable(CONTENT_COLOR_VAR[variant ?? 'primary']);
249
- const contentColor =
250
- typeof themed === 'string' ? themed : undefined;
452
+ const themed = useCSSVariable(
453
+ (material ? GLASS_CONTENT_COLOR_VAR : CONTENT_COLOR_VAR)[resolvedVariant]
454
+ );
455
+ const contentColor = typeof themed === 'string' ? themed : undefined;
251
456
 
252
457
  const handlePress = useCallback<NonNullable<AnimatedPressableProps['onPress']>>(
253
458
  (event) => {
@@ -257,23 +462,64 @@ const FabRoot = forwardRef<View, FabProps>(
257
462
  [haptics, onPress]
258
463
  );
259
464
 
260
- return (
261
- <IconColorProvider color={contentColor}>
262
- <AnimatedPressable
263
- ref={ref}
264
- {...props}
265
- accessibilityRole="button"
266
- accessibilityState={{ disabled: isDisabled }}
267
- disabled={isDisabled}
268
- onPress={handlePress}
269
- className={root({ className })}
270
- style={[placement ? anchor(placement, offset) : null, style]}
271
- >
465
+ const pressable = (
466
+ <AnimatedPressable
467
+ ref={ref}
468
+ {...props}
469
+ accessibilityRole="button"
470
+ // Whatever the caller says about the button — a group's trigger says
471
+ // whether it is expanded — with the disabled state kept authoritative.
472
+ accessibilityState={{ ...accessibilityState, disabled: isDisabled }}
473
+ disabled={isDisabled}
474
+ onPress={handlePress}
475
+ // The platform animates the glass under a touch; a second scale on
476
+ // top of it would fight it.
477
+ pressScale={material ? 1 : undefined}
478
+ className={material ? 'h-full w-full items-center justify-center' : root({ className })}
479
+ style={material ? undefined : [placement ? anchor(placement, offset) : null, style]}
480
+ >
481
+ <View className={content()}>
272
482
  {icon}
273
483
  {extended && children ? (
274
- <Text className={label()}>{children}</Text>
484
+ <Text
485
+ className={label({
486
+ className: material
487
+ ? resolvedVariant === 'destructive'
488
+ ? 'text-destructive'
489
+ : 'text-foreground'
490
+ : undefined,
491
+ })}
492
+ >
493
+ {children}
494
+ </Text>
275
495
  ) : null}
276
- </AnimatedPressable>
496
+ </View>
497
+ </AnimatedPressable>
498
+ );
499
+
500
+ /*
501
+ * Drawn in glass, the material is the box and the pressable fills it. The
502
+ * platform only tracks a touch that lands inside the glass view, and
503
+ * tracking it is what makes the button swell and glow under the finger,
504
+ * so the pressable has to be inside rather than under it. It rounds
505
+ * itself to the button's own radius: the material clipped by a rounded
506
+ * parent loses its lit edge.
507
+ */
508
+ return (
509
+ <IconColorProvider color={contentColor}>
510
+ {material ? (
511
+ <Glass
512
+ interactive
513
+ variant="regular"
514
+ radius={SIZE_PX[size ?? 'md'] / 2}
515
+ className={root({ className })}
516
+ style={[placement ? anchor(placement, offset) : null, style]}
517
+ >
518
+ {pressable}
519
+ </Glass>
520
+ ) : (
521
+ pressable
522
+ )}
277
523
  </IconColorProvider>
278
524
  );
279
525
  }
@@ -286,13 +532,36 @@ FabRoot.displayName = 'Fab';
286
532
  * -------------------------------------------------------------------------- */
287
533
 
288
534
  /** Whatever was written as a dial child, and the one prop a slot reaches for. */
289
- type PressableChild = ReactElement<{ onPress?: () => void }>;
535
+ type PressableChild = ReactElement<{ onPress?: (event: GestureResponderEvent) => void }>;
536
+
537
+ /**
538
+ * What opens out of the trigger: a column of buttons, one panel of rows, or
539
+ * the platform's own menu.
540
+ */
541
+ export type FabGroupLayout = 'dial' | 'menu' | 'native';
542
+
543
+ /**
544
+ * How big a hosted glyph is drawn in a platform menu row, in points.
545
+ *
546
+ * Stated rather than measured because it has to be: anything of ours inside
547
+ * the native tree needs a definite size on both axes above it, and an icon
548
+ * whose box the platform is left to work out is the crash this library has
549
+ * already paid for twice.
550
+ */
551
+ const NATIVE_ROW_ICON = 24;
290
552
 
291
553
  interface FabGroupContextValue {
292
554
  /** 0 closed, 1 open. Every action reads it and its own index off it. */
293
555
  progress: SharedValue<number>;
294
556
  count: number;
295
557
  size: FabSize;
558
+ /** The trigger's material, which the actions unfold in too. */
559
+ glass: boolean;
560
+ layout: FabGroupLayout;
561
+ /** The menu's appearance and where its rows put the glyph. */
562
+ appearance: FabMenuAppearance;
563
+ iconPlacement: FabMenuIconPlacement;
564
+ rowClassName?: string;
296
565
  close: () => void;
297
566
  }
298
567
 
@@ -319,10 +588,56 @@ export interface FabGroupProps extends Omit<ViewProps, 'children'> {
319
588
  placement?: FabPlacement;
320
589
  /** Distance from the screen's edges, in points. Add your safe-area inset. */
321
590
  offset?: number;
591
+ /**
592
+ * What opens out of the trigger. `dial`, the default, is a column of round
593
+ * buttons with their labels beside them. `menu` is one panel of rows — a
594
+ * label with its glyph, on the side the appearance puts it — that springs
595
+ * out of the trigger's corner, the way the platform's own menus do.
596
+ * `native` hands the menu to the platform: SwiftUI on iOS, Jetpack Compose
597
+ * on Android.
598
+ *
599
+ * A native menu is drawn by the platform, so `className` and the theme
600
+ * tokens do not reach it, and the rows take `label` and `systemImage` rather
601
+ * than an `icon` element. `blur` still applies: the scrim behind the menu is
602
+ * ours, so the page recedes the way it does behind the dial and the panel.
603
+ *
604
+ * On iOS the platform owns the menu's open state, so `open` and
605
+ * `onOpenChange` do nothing there. Android's menu is controlled and honours
606
+ * both.
607
+ *
608
+ * Where the platform menu cannot be drawn — on the web, or without
609
+ * `@expo/ui` installed — this falls back to `menu`.
610
+ */
611
+ layout?: FabGroupLayout;
612
+ /**
613
+ * How a menu is drawn. `platform`, the default, is the shape the platform's
614
+ * own menus take: a hairline between rows and the glyph after the label.
615
+ * `wells` is tighter, with the glyph leading in a tinted well and each row
616
+ * its own pill — a menu the app designed rather than the system.
617
+ *
618
+ * Menu layout only.
619
+ */
620
+ appearance?: FabMenuAppearance;
621
+ /** Which side of a menu row the glyph sits on. Each appearance has its own default. */
622
+ iconPlacement?: FabMenuIconPlacement;
623
+ /** The menu panel's width in points. Each appearance has its own default. */
624
+ menuWidth?: number;
625
+ /** The menu panel's corner radius in points. Each appearance has its own default. */
626
+ menuRadius?: number;
627
+ /** Extra classes for the menu panel. */
628
+ menuClassName?: string;
629
+ /** Extra classes for every menu row. A row's own `className` comes after. */
630
+ rowClassName?: string;
322
631
  size?: FabSize;
323
632
  variant?: FabVariant;
324
633
  disabled?: boolean;
325
634
  haptics?: boolean;
635
+ /**
636
+ * Draw the trigger and its actions in Liquid Glass. The same flag as on
637
+ * `Fab`, with the same floor: iOS 26 and `expo-glass-effect`, inert
638
+ * elsewhere.
639
+ */
640
+ glass?: boolean;
326
641
  /** Frost the screen behind the open dial instead of dimming it. */
327
642
  blur?: boolean;
328
643
  /** Required — the trigger is a lone glyph until it is opened. */
@@ -358,10 +673,18 @@ const FabGroup = forwardRef<View, FabGroupProps>(
358
673
  onOpenChange,
359
674
  placement = 'bottom-right',
360
675
  offset = DEFAULT_OFFSET,
676
+ layout = 'dial',
677
+ appearance = 'platform',
678
+ iconPlacement,
679
+ menuWidth,
680
+ menuRadius,
681
+ menuClassName,
682
+ rowClassName,
361
683
  size = 'md',
362
684
  variant,
363
685
  disabled = false,
364
686
  haptics = false,
687
+ glass = false,
365
688
  blur = false,
366
689
  rotateOnOpen = true,
367
690
  accessibilityLabel,
@@ -372,6 +695,10 @@ const FabGroup = forwardRef<View, FabGroupProps>(
372
695
  ) => {
373
696
  const [internalOpen, setInternalOpen] = useState(false);
374
697
  const open = openProp ?? internalOpen;
698
+ // Asked for *and* drawable, decided once here so the trigger, the actions
699
+ // and their motion all agree; where the material cannot be drawn the flag
700
+ // is inert everywhere rather than in the trigger alone.
701
+ const material = useGlassMaterial() && glass;
375
702
 
376
703
  const setOpen = useCallback(
377
704
  (next: boolean) => {
@@ -393,6 +720,19 @@ const FabGroup = forwardRef<View, FabGroupProps>(
393
720
  */
394
721
  const actionCount = actions.length;
395
722
 
723
+ /*
724
+ * The platform's menu, when one was asked for and the toolkit is there.
725
+ *
726
+ * Resolved before anything below reads `layout`, because a native menu
727
+ * that cannot be drawn is not an error — it falls back to the panel, and
728
+ * every decision after this one has to be made against the layout that is
729
+ * really being drawn rather than the one that was requested.
730
+ */
731
+ const swiftMenu = layout === 'native' ? getSwiftUIMenu() : null;
732
+ const composeMenu = layout === 'native' && !swiftMenu ? getComposeMenu() : null;
733
+ const resolvedLayout: FabGroupLayout =
734
+ layout === 'native' && !swiftMenu && !composeMenu ? 'menu' : layout;
735
+
396
736
  /*
397
737
  * One shared value for the whole dial rather than one per action.
398
738
  *
@@ -401,10 +741,30 @@ const FabGroup = forwardRef<View, FabGroupProps>(
401
741
  * driving every row, and there is no chain of JavaScript timeouts to get
402
742
  * out of step with itself when the dial is closed halfway through opening.
403
743
  */
744
+ /*
745
+ * The actions stay mounted while the spring runs back down, so closing is
746
+ * the opening in reverse rather than a cut. `closing` is set the moment
747
+ * `open` drops and cleared by the spring when it comes to rest; the
748
+ * mounted-only-while-open rule below still holds once it has.
749
+ */
750
+ const [closing, setClosing] = useState(false);
751
+ // Noted during render rather than in an effect: an effect lands a frame
752
+ // after the render that dropped `open`, and that frame would have no
753
+ // actions in it — a blink, and a remount of the material mid-flight.
754
+ const [wasOpen, setWasOpen] = useState(open);
755
+ if (wasOpen !== open) {
756
+ setWasOpen(open);
757
+ if (!open) setClosing(true);
758
+ }
759
+ const settle = useCallback(() => setClosing(false), []);
404
760
  const progress = useDerivedValue<number>(
405
- () => withTiming(open ? 1 : 0, { duration: OPEN_DURATION + STAGGER * actionCount }),
406
- [open, actionCount]
761
+ () =>
762
+ withSpring(open ? 1 : 0, OPEN_SPRING, (finished) => {
763
+ if (finished && !open) runOnJS(settle)();
764
+ }),
765
+ [open, settle]
407
766
  );
767
+ const present = open || closing;
408
768
 
409
769
  const rotation = useAnimatedStyle(() => ({
410
770
  transform: [
@@ -415,8 +775,18 @@ const FabGroup = forwardRef<View, FabGroupProps>(
415
775
  const close = useCallback(() => setOpen(false), [setOpen]);
416
776
 
417
777
  const context = useMemo<FabGroupContextValue>(
418
- () => ({ progress, count: actionCount, size, close }),
419
- [progress, actionCount, size, close]
778
+ () => ({
779
+ progress,
780
+ count: actionCount,
781
+ size,
782
+ glass: material,
783
+ layout: resolvedLayout,
784
+ appearance,
785
+ iconPlacement: iconPlacement ?? MENU_METRICS[appearance].icon,
786
+ rowClassName,
787
+ close,
788
+ }),
789
+ [progress, actionCount, size, material, resolvedLayout, appearance, iconPlacement, rowClassName, close]
420
790
  );
421
791
 
422
792
  const toggle = useCallback(() => {
@@ -428,6 +798,237 @@ const FabGroup = forwardRef<View, FabGroupProps>(
428
798
  // standing over the screen underneath.
429
799
  useBackHandler(open, close);
430
800
 
801
+ const themedDestructive = useCSSVariable('--color-destructive');
802
+
803
+ const Group = material && resolvedLayout === 'dial' ? GlassContainer : View;
804
+
805
+ /*
806
+ * The trigger as the platform hosts it.
807
+ *
808
+ * Sized here rather than left to the platform, on both axes where both are
809
+ * known. An axis handed to `matchContents` is written back to for good, and
810
+ * a hosted view with nothing definite above it is the crash this library
811
+ * has already paid for twice — so a circle states its diameter, and only an
812
+ * extended button's width, which is its label's, is the platform's to
813
+ * measure.
814
+ */
815
+ const triggerSize = SIZE_PX[size];
816
+ const triggerExtended = !!label;
817
+ const triggerFrame = triggerExtended
818
+ ? { height: triggerSize }
819
+ : { width: triggerSize, height: triggerSize };
820
+
821
+ const nativeTrigger = (
822
+ <View
823
+ // On iOS the menu owns the tap: a pressable in front of it would eat
824
+ // the touch and the menu would never open. Android's is controlled, so
825
+ // there the button is what opens it.
826
+ pointerEvents={swiftMenu ? 'none' : 'auto'}
827
+ style={triggerFrame}
828
+ >
829
+ <FabRoot
830
+ icon={icon}
831
+ extended={triggerExtended}
832
+ size={size}
833
+ variant={variant}
834
+ disabled={disabled}
835
+ glass={glass}
836
+ accessibilityLabel={accessibilityLabel}
837
+ accessibilityState={{ disabled, expanded: open }}
838
+ onPress={composeMenu ? toggle : undefined}
839
+ >
840
+ {label}
841
+ </FabRoot>
842
+ </View>
843
+ );
844
+
845
+ /*
846
+ * The screen behind an open platform menu, frosted the way it is behind
847
+ * the dial and the panel — the page is what recedes, and a native menu
848
+ * over an untouched page reads as a control belonging to something else.
849
+ *
850
+ * It closes on a tap, which is the safety net rather than the mechanism.
851
+ * The platform dismisses its own menu from its own window, so this never
852
+ * sees that tap and never competes for it; what it catches is the next
853
+ * one. Menu content is not promised an `onDisappear`, and a frosted page
854
+ * with no way back would be worse than no scrim at all.
855
+ */
856
+ const nativeScrim = open ? (
857
+ <Pressable
858
+ accessibilityRole="button"
859
+ accessibilityLabel="Close"
860
+ onPress={close}
861
+ style={StyleSheet.absoluteFill}
862
+ >
863
+ <Scrim blur={blur} dimClassName="bg-black/32" />
864
+ </Pressable>
865
+ ) : null;
866
+
867
+ /*
868
+ * SwiftUI's menu. The trigger goes through the `label` slot, which takes a
869
+ * view as readily as a string — so the button stays ours and only the menu
870
+ * is the platform's.
871
+ *
872
+ * The open state is not passed and cannot be: SwiftUI owns it. `open` and
873
+ * `onOpenChange` are inert on this path, which is stated on the prop.
874
+ */
875
+ if (swiftMenu) {
876
+ const { Host, RNHostView, Menu, Button } = swiftMenu;
877
+ const swiftModifiers = getSwiftUIModifiers();
878
+
879
+ /*
880
+ * What each row carries beyond its label.
881
+ *
882
+ * The first one also carries the menu's open state. SwiftUI's menu owns
883
+ * that state and reports nothing about it, but it builds its content
884
+ * only when it presents it — so the first row appearing is the menu
885
+ * appearing, and that is what puts the screen behind it under the
886
+ * scrim.
887
+ */
888
+ const itemModifiers = (item: Partial<FabActionProps>, index: number) => {
889
+ const list: unknown[] = [];
890
+ if (item.disabled && swiftModifiers?.disabled) {
891
+ list.push(swiftModifiers.disabled(true));
892
+ }
893
+ if (index === 0 && swiftModifiers?.onAppear) {
894
+ list.push(swiftModifiers.onAppear(() => setOpen(true)));
895
+ }
896
+ /*
897
+ * Every row carries the teardown, not just the first.
898
+ *
899
+ * The one that reports the menu opening is enough to raise the scrim,
900
+ * but taking it down cannot rest on a single view: whichever row
901
+ * SwiftUI tears down first has to be able to say so, or a menu
902
+ * dismissed from anywhere else leaves the page frosted behind it.
903
+ */
904
+ if (swiftModifiers?.onDisappear) {
905
+ list.push(swiftModifiers.onDisappear(() => setOpen(false)));
906
+ }
907
+ return list.length > 0 ? list : undefined;
908
+ };
909
+
910
+ return (
911
+ <>
912
+ {nativeScrim}
913
+ <NativeHost
914
+ host={Host}
915
+ matchContents={triggerExtended ? { horizontal: true } : false}
916
+ ignoreSafeArea="keyboard"
917
+ style={[anchor(placement, offset), triggerFrame, style]}
918
+ {...props}
919
+ >
920
+ <Menu label={<RNHostView matchContents>{nativeTrigger}</RNHostView>}>
921
+ {actions.map((action, index) => {
922
+ const item = action.props as Partial<FabActionProps>;
923
+ return (
924
+ <Button
925
+ key={index}
926
+ label={item.label ?? ''}
927
+ systemImage={item.systemImage}
928
+ role={item.destructive ? 'destructive' : 'default'}
929
+ // Picking a row closes the menu, so the scrim has to come
930
+ // down with it rather than waiting to be told the content
931
+ // went away.
932
+ onPress={
933
+ item.disabled
934
+ ? undefined
935
+ : () => {
936
+ close();
937
+ item.onPress?.();
938
+ }
939
+ }
940
+ modifiers={itemModifiers(item, index)}
941
+ />
942
+ );
943
+ })}
944
+ </Menu>
945
+ </NativeHost>
946
+ </>
947
+ );
948
+ }
949
+
950
+ /*
951
+ * Compose's dropdown menu — the same instruction, a different control.
952
+ *
953
+ * This one takes the open state rather than owning it, so the group's own
954
+ * state drives it and `open`/`onOpenChange` work here. Its rows take a text
955
+ * slot rather than a label prop, and a glyph of ours goes in the leading
956
+ * slot at a stated size.
957
+ */
958
+ if (composeMenu) {
959
+ const {
960
+ Host,
961
+ RNHostView,
962
+ Text: ComposeText,
963
+ DropdownMenu,
964
+ DropdownMenuItem,
965
+ } = composeMenu;
966
+ const destructiveColor =
967
+ typeof themedDestructive === 'string' ? themedDestructive : undefined;
968
+
969
+ return (
970
+ <>
971
+ {nativeScrim}
972
+ <NativeHost
973
+ host={Host}
974
+ matchContents={triggerExtended ? { horizontal: true } : false}
975
+ ignoreSafeArea="keyboard"
976
+ style={[anchor(placement, offset), triggerFrame, style]}
977
+ {...props}
978
+ >
979
+ <DropdownMenu expanded={open} onDismissRequest={close}>
980
+ <DropdownMenu.Trigger>
981
+ <RNHostView matchContents>{nativeTrigger}</RNHostView>
982
+ </DropdownMenu.Trigger>
983
+ <DropdownMenu.Items>
984
+ {actions.map((action, index) => {
985
+ const item = action.props as Partial<FabActionProps>;
986
+ return (
987
+ <DropdownMenuItem
988
+ key={index}
989
+ enabled={!item.disabled}
990
+ elementColors={
991
+ item.destructive && destructiveColor
992
+ ? { textColor: destructiveColor }
993
+ : undefined
994
+ }
995
+ onClick={() => {
996
+ close();
997
+ item.onPress?.();
998
+ }}
999
+ >
1000
+ {item.icon ? (
1001
+ <DropdownMenuItem.LeadingIcon>
1002
+ <RNHostView
1003
+ matchContents
1004
+ style={{ width: NATIVE_ROW_ICON, height: NATIVE_ROW_ICON }}
1005
+ >
1006
+ <View
1007
+ style={{
1008
+ width: NATIVE_ROW_ICON,
1009
+ height: NATIVE_ROW_ICON,
1010
+ alignItems: 'center',
1011
+ justifyContent: 'center',
1012
+ }}
1013
+ >
1014
+ {item.icon}
1015
+ </View>
1016
+ </RNHostView>
1017
+ </DropdownMenuItem.LeadingIcon>
1018
+ ) : null}
1019
+ <DropdownMenuItem.Text>
1020
+ <ComposeText>{item.label ?? ''}</ComposeText>
1021
+ </DropdownMenuItem.Text>
1022
+ </DropdownMenuItem>
1023
+ );
1024
+ })}
1025
+ </DropdownMenu.Items>
1026
+ </DropdownMenu>
1027
+ </NativeHost>
1028
+ </>
1029
+ );
1030
+ }
1031
+
431
1032
  /*
432
1033
  * Two absolutely positioned siblings, scrim first, both in the group's own
433
1034
  * parent — which is why a group belongs in the screen's root container.
@@ -463,16 +1064,38 @@ const FabGroup = forwardRef<View, FabGroupProps>(
463
1064
  ) : null}
464
1065
 
465
1066
  <FabGroupContext.Provider value={context}>
466
- <View
1067
+ {/* A glass dial's pieces merge while they overlap — the actions are
1068
+ one blob with the trigger until they rise clear of it. */}
1069
+ <Group
467
1070
  ref={ref}
468
- className={cn('items-end gap-3', className)}
1071
+ spacing={material && resolvedLayout === 'dial' ? DIAL_BLEND : undefined}
1072
+ className={cn(
1073
+ resolvedLayout === 'menu' ? GROUP_ALIGN[placement] : 'items-end',
1074
+ 'gap-3',
1075
+ className
1076
+ )}
469
1077
  style={[anchor(placement, offset), style]}
470
1078
  {...props}
471
1079
  >
472
- {/* Mounted only while open: a column of actions kept alive behind
473
- the trigger would still be in the accessibility tree, and a
474
- screen reader would walk into four buttons nobody can see. */}
475
- {open
1080
+ {/* Mounted only while open, and while closing: a column of
1081
+ actions kept alive behind the trigger would still be in the
1082
+ accessibility tree, and a screen reader would walk into four
1083
+ buttons nobody can see. */}
1084
+ {present && resolvedLayout === 'menu' ? (
1085
+ <FabMenu
1086
+ progress={progress}
1087
+ placement={placement}
1088
+ size={size}
1089
+ glass={material}
1090
+ appearance={appearance}
1091
+ width={menuWidth}
1092
+ radius={menuRadius}
1093
+ className={menuClassName}
1094
+ >
1095
+ {actions}
1096
+ </FabMenu>
1097
+ ) : null}
1098
+ {present && resolvedLayout === 'dial'
476
1099
  ? actions.map((action, index) => (
477
1100
  <FabActionSlot key={index} index={index}>
478
1101
  {action}
@@ -492,13 +1115,14 @@ const FabGroup = forwardRef<View, FabGroupProps>(
492
1115
  size={size}
493
1116
  variant={variant}
494
1117
  disabled={disabled}
1118
+ glass={glass}
495
1119
  accessibilityLabel={accessibilityLabel}
496
1120
  accessibilityState={{ disabled, expanded: open }}
497
1121
  onPress={toggle}
498
1122
  >
499
1123
  {label}
500
1124
  </FabRoot>
501
- </View>
1125
+ </Group>
502
1126
  </FabGroupContext.Provider>
503
1127
  </>
504
1128
  );
@@ -507,14 +1131,142 @@ const FabGroup = forwardRef<View, FabGroupProps>(
507
1131
 
508
1132
  FabGroup.displayName = 'Fab.Group';
509
1133
 
1134
+ /**
1135
+ * The menu panel: one surface, springing out of the trigger's corner.
1136
+ *
1137
+ * It scales up from that corner rather than fading in, and the two are not
1138
+ * interchangeable: the material cannot be faded — at zero it stops drawing
1139
+ * and does not come back — and a panel growing out of the button is what
1140
+ * says the button opened. The rows inside fade in on the dial's own stagger,
1141
+ * nearest the trigger first.
1142
+ */
1143
+ function FabMenu({
1144
+ progress,
1145
+ placement,
1146
+ size,
1147
+ glass,
1148
+ appearance,
1149
+ width: widthProp,
1150
+ radius: radiusProp,
1151
+ className,
1152
+ children,
1153
+ }: {
1154
+ /** The dial's own 0-to-1, so the panel and its unmount cannot disagree. */
1155
+ progress: SharedValue<number>;
1156
+ placement: FabPlacement;
1157
+ size: FabSize;
1158
+ glass: boolean;
1159
+ appearance: FabMenuAppearance;
1160
+ width?: number;
1161
+ radius?: number;
1162
+ className?: string;
1163
+ children: PressableChild[];
1164
+ }) {
1165
+ const metrics = MENU_METRICS[appearance];
1166
+ const width = widthProp ?? metrics.width;
1167
+ const radius = radiusProp ?? metrics.radius;
1168
+
1169
+ /*
1170
+ * The panel rides the group's own spring rather than one of its own.
1171
+ *
1172
+ * It used to run a second, faster spring, and closing raced it against the
1173
+ * one that decides when the panel unmounts: the panel reached its collapsed
1174
+ * scale early and then sat there, a small box above the trigger, until the
1175
+ * slower spring came to rest and took it away. One value cannot race
1176
+ * itself, so the panel is gone exactly when the dial says it is.
1177
+ *
1178
+ * Scale and not opacity, for the same reason the dial's actions are: the
1179
+ * material survives a zero scale and does not come back from a zero
1180
+ * opacity.
1181
+ */
1182
+ const style = useAnimatedStyle(() => ({
1183
+ transform: [
1184
+ {
1185
+ scale: interpolate(
1186
+ progress.value,
1187
+ [0, MENU_POP_AT, 1],
1188
+ [0, MENU_FROM_SCALE, 1],
1189
+ Extrapolation.CLAMP
1190
+ ),
1191
+ },
1192
+ ],
1193
+ }));
1194
+
1195
+ return (
1196
+ <Animated.View
1197
+ style={[
1198
+ style,
1199
+ { transformOrigin: menuOrigin(placement, metrics, width, children.length, size) },
1200
+ ]}
1201
+ >
1202
+ <Glass
1203
+ radius={radius}
1204
+ // Without the material the panel is the same surface a popover is.
1205
+ fallbackClassName="border border-border bg-popover shadow-lg"
1206
+ className={cn(appearance === 'wells' ? 'p-1.5' : 'py-1.5', glass ? null : 'shadow-lg', className)}
1207
+ style={{ width }}
1208
+ >
1209
+ {children.map((action, index) => (
1210
+ <FabActionSlot key={index} index={index} separator={metrics.hairline > 0 && index > 0}>
1211
+ {action}
1212
+ </FabActionSlot>
1213
+ ))}
1214
+ </Glass>
1215
+ </Animated.View>
1216
+ );
1217
+ }
1218
+
1219
+ /**
1220
+ * How far along its own arrival an action is, from the dial's one progress.
1221
+ *
1222
+ * The stagger runs bottom-up: the action nearest the trigger arrives first,
1223
+ * which is the order a hand travelling away from the button meets them in.
1224
+ */
1225
+ function slotProgress(progress: number, count: number, index: number): number {
1226
+ 'worklet';
1227
+ // A short lag per action rather than a full turn each: the actions leave
1228
+ // the trigger nearly together and settle nearly together, so a later one
1229
+ // does not rise through a slot an earlier one is already sitting in.
1230
+ const from = (count - 1 - index) * STAGGER;
1231
+ const to = from + 1 - STAGGER * Math.max(0, count - 1);
1232
+ // The spring's overshoot past its target is passed on to every action, so
1233
+ // a button arrives with a little bounce rather than stopping dead. It is
1234
+ // added on top of the clamped window rather than read through it: a window
1235
+ // that ends before the dial's does would otherwise leave its action past
1236
+ // its slot for good.
1237
+ const within = interpolate(progress, [from, Math.min(1, to)], [0, 1], Extrapolation.CLAMP);
1238
+ return within + Math.max(0, progress - 1);
1239
+ }
1240
+
1241
+ /** The same progress, decelerating into 1 and never past it — for size and opacity. */
1242
+ function settled(t: number): number {
1243
+ 'worklet';
1244
+ return Easing.out(Easing.cubic)(Math.min(1, Math.max(0, t)));
1245
+ }
1246
+
1247
+ /** Which slot an action is in — what it needs to know to animate itself. */
1248
+ const FabSlotContext = createContext<number>(0);
1249
+
510
1250
  /**
511
1251
  * One action's slot in the unfolding.
512
1252
  *
513
1253
  * The stagger runs bottom-up: the action nearest the trigger arrives first,
514
1254
  * which is the order a hand travelling away from the button meets them in.
515
1255
  */
516
- function FabActionSlot({ index, children }: { index: number; children: PressableChild }) {
517
- const { progress, count, close } = useFabGroup('Fab.Action');
1256
+ function FabActionSlot({
1257
+ index,
1258
+ separator = false,
1259
+ children,
1260
+ }: {
1261
+ index: number;
1262
+ /** A hairline above the slot — a menu row's, never a dial button's. */
1263
+ separator?: boolean;
1264
+ children: PressableChild;
1265
+ }) {
1266
+ const { progress, count, glass, layout, close } = useFabGroup('Fab.Action');
1267
+ // A menu's rows are content on one panel, not glass of their own, so they
1268
+ // may fade; a dial's actions are each their own material.
1269
+ const material = glass && layout === 'dial';
518
1270
 
519
1271
  /*
520
1272
  * Whatever is in the slot closes the dial when it is pressed.
@@ -528,25 +1280,38 @@ function FabActionSlot({ index, children }: { index: number; children: Pressable
528
1280
  const { onPress } = children.props;
529
1281
  const child = onPress
530
1282
  ? cloneElement(children, {
531
- onPress: () => {
1283
+ onPress: (event: GestureResponderEvent) => {
532
1284
  close();
533
- onPress();
1285
+ onPress(event);
534
1286
  },
535
1287
  })
536
1288
  : children;
537
1289
 
1290
+ /*
1291
+ * A glass action never fades. The material stops drawing under an ancestor
1292
+ * at zero opacity and does not come back when the opacity does, so a fade
1293
+ * from zero is a button that sometimes never appears — whichever ones got
1294
+ * their first frame at zero. A glass dial's action animates its own parts
1295
+ * instead, rising out of the trigger; see `Fab.Action`. The slot only
1296
+ * fades a plain action, where it reads better on a flat surface.
1297
+ */
538
1298
  const style = useAnimatedStyle(() => {
539
- const steps = Math.max(1, count);
540
- const from = (count - 1 - index) / (steps + 1);
541
- const to = from + 1 / (steps + 1) + 0.35;
542
- const t = interpolate(progress.value, [from, Math.min(1, to)], [0, 1], 'clamp');
1299
+ if (material) return {};
1300
+ const t = slotProgress(progress.value, count, index);
543
1301
  return {
544
- opacity: t,
1302
+ opacity: settled(t),
545
1303
  transform: [{ translateY: interpolate(t, [0, 1], [ACTION_TRAVEL, 0]) }],
546
1304
  };
547
1305
  });
548
1306
 
549
- return <Animated.View style={style}>{child}</Animated.View>;
1307
+ return (
1308
+ <FabSlotContext.Provider value={index}>
1309
+ <Animated.View style={style}>
1310
+ {separator ? <View className="mx-4 h-px bg-border" /> : null}
1311
+ {child}
1312
+ </Animated.View>
1313
+ </FabSlotContext.Provider>
1314
+ );
550
1315
  }
551
1316
 
552
1317
  /* -------------------------------------------------------------------------- *
@@ -557,13 +1322,21 @@ export interface FabActionProps extends Omit<ViewProps, 'children'> {
557
1322
  className?: string;
558
1323
  /** The glyph. */
559
1324
  icon?: ReactNode;
1325
+ /**
1326
+ * The glyph for a native menu row, as an SF Symbol name.
1327
+ *
1328
+ * `layout="native"` only, and iOS only — SwiftUI names its symbols rather
1329
+ * than taking a view for them, so `icon` cannot cross over. Ignored
1330
+ * everywhere else, so a group can carry both and be right on either path.
1331
+ */
1332
+ systemImage?: string;
560
1333
  /** What it does, beside the glyph. A column of unlabelled circles is a quiz. */
561
1334
  label?: string;
562
1335
  onPress?: () => void;
563
1336
  disabled?: boolean;
564
1337
  /** Draws it in the destructive colour, for the one that removes something. */
565
1338
  destructive?: boolean;
566
- /** Extra classes for the label chip. */
1339
+ /** Extra classes for the label — the chip in a dial, the row's text in a menu. */
567
1340
  labelClassName?: string;
568
1341
  }
569
1342
 
@@ -580,37 +1353,165 @@ export interface FabActionProps extends Omit<ViewProps, 'children'> {
580
1353
  */
581
1354
  const FabAction = forwardRef<View, FabActionProps>(
582
1355
  (
583
- { className, icon, label, onPress, disabled = false, destructive = false, labelClassName, ...props },
1356
+ {
1357
+ className,
1358
+ icon,
1359
+ // Read off the element by a native group, never rendered here.
1360
+ systemImage: _systemImage,
1361
+ label,
1362
+ onPress,
1363
+ disabled = false,
1364
+ destructive = false,
1365
+ labelClassName,
1366
+ ...props
1367
+ },
584
1368
  ref
585
1369
  ) => {
586
- const { size, close } = useFabGroup('Fab.Action');
1370
+ const { progress, count, size, glass, layout, appearance, iconPlacement, rowClassName, close } =
1371
+ useFabGroup('Fab.Action');
1372
+ const index = useContext(FabSlotContext);
1373
+ const [pressed, setPressed] = useState(false);
1374
+
1375
+ /*
1376
+ * A glass dial's action rises out of the trigger.
1377
+ *
1378
+ * It starts small and sitting on the button, and springs up to its slot
1379
+ * on the dial's stagger; inside the group's glass container the two
1380
+ * materials are one blob until it pulls free. The distance is known from
1381
+ * the fixed sizes, so nothing is measured. The label follows once the
1382
+ * button is most of the way there, sliding in from the button's side —
1383
+ * it is glass too, so it moves rather than fades.
1384
+ */
1385
+ const actionSize: FabSize = size === 'lg' ? 'md' : 'sm';
1386
+ const rise =
1387
+ (count - 1 - index) * (SIZE_PX[actionSize] + GROUP_GAP) +
1388
+ GROUP_GAP +
1389
+ SIZE_PX[size] / 2 +
1390
+ SIZE_PX[actionSize] / 2;
1391
+ // Travel follows the spring, overshoot and all; size settles without it.
1392
+ const buttonStyle = useAnimatedStyle(() => {
1393
+ const t = slotProgress(progress.value, count, index);
1394
+ return {
1395
+ transform: [
1396
+ { translateY: interpolate(t, [0, 1], [rise, 0]) },
1397
+ { scale: interpolate(settled(t), [0, 1], [RISE_FROM_SCALE, 1]) },
1398
+ ],
1399
+ };
1400
+ });
1401
+ const chipStyle = useAnimatedStyle(() => {
1402
+ const t = slotProgress(progress.value, count, index);
1403
+ // Rides up with its button, then grows out of it once the button is
1404
+ // most of the way to its slot.
1405
+ const late = settled(interpolate(t, [LABEL_AFTER, 1], [0, 1], 'clamp'));
1406
+ return {
1407
+ transform: [
1408
+ { translateY: interpolate(t, [0, 1], [rise, 0]) },
1409
+ { translateX: interpolate(late, [0, 1], [LABEL_TRAVEL, 0]) },
1410
+ { scale: interpolate(late, [0, 1], [RISE_FROM_SCALE, 1]) },
1411
+ ],
1412
+ };
1413
+ });
587
1414
 
588
1415
  const handlePress = useCallback(() => {
589
1416
  close();
590
1417
  onPress?.();
591
1418
  }, [close, onPress]);
592
1419
 
1420
+ const themedRow = useCSSVariable(destructive ? '--color-destructive' : '--color-foreground');
1421
+ const rowColor = typeof themedRow === 'string' ? themedRow : undefined;
1422
+
1423
+ /*
1424
+ * In a menu the action is a row on the panel. The press tints the row
1425
+ * rather than scaling it — a row that shrinks inside a panel that does
1426
+ * not looks detached from it — and the tint is React state rather than a
1427
+ * worklet because it is a colour toggle, not a curve.
1428
+ */
1429
+ if (layout === 'menu') {
1430
+ const wells = appearance === 'wells';
1431
+ const glyph = wells ? (
1432
+ <View
1433
+ className={cn(
1434
+ 'h-8 w-8 items-center justify-center rounded-full',
1435
+ destructive ? 'bg-destructive/15' : 'bg-foreground/10'
1436
+ )}
1437
+ >
1438
+ {icon}
1439
+ </View>
1440
+ ) : (
1441
+ icon
1442
+ );
1443
+ return (
1444
+ <IconColorProvider color={rowColor}>
1445
+ <AnimatedPressable
1446
+ ref={ref}
1447
+ accessibilityRole="button"
1448
+ accessibilityState={{ disabled }}
1449
+ disabled={disabled}
1450
+ onPress={handlePress}
1451
+ onPressIn={() => setPressed(true)}
1452
+ onPressOut={() => setPressed(false)}
1453
+ pressScale={1}
1454
+ className={cn(
1455
+ 'flex-row items-center gap-3',
1456
+ wells ? 'h-12 rounded-2xl px-2' : 'h-11 px-4',
1457
+ iconPlacement === 'trailing' ? 'justify-between' : null,
1458
+ pressed && 'bg-foreground/10',
1459
+ disabled && 'opacity-40',
1460
+ rowClassName,
1461
+ className
1462
+ )}
1463
+ {...props}
1464
+ >
1465
+ {iconPlacement === 'leading' ? glyph : null}
1466
+ <Text
1467
+ className={cn('shrink', destructive ? 'text-destructive' : 'text-foreground', labelClassName)}
1468
+ numberOfLines={1}
1469
+ >
1470
+ {label}
1471
+ </Text>
1472
+ {iconPlacement === 'trailing' ? glyph : null}
1473
+ </AnimatedPressable>
1474
+ </IconColorProvider>
1475
+ );
1476
+ }
1477
+
593
1478
  return (
594
1479
  <View className="flex-row items-center justify-end gap-3" {...props}>
595
1480
  {label ? (
596
- <View className="rounded-lg bg-popover px-2.5 py-1 shadow-sm">
597
- <Text size="sm" className={cn('text-foreground', labelClassName)}>
598
- {label}
599
- </Text>
600
- </View>
1481
+ // The chip is in the same material as the button beside it, so a
1482
+ // glass dial is glass all the way across and not glass with paper
1483
+ // labels. Without the material it is the popover surface it was.
1484
+ glass ? (
1485
+ <Animated.View style={chipStyle}>
1486
+ <Glass radius={8} fallbackClassName="bg-popover shadow-sm" className="px-2.5 py-1">
1487
+ <Text size="sm" className={cn('text-foreground', labelClassName)}>
1488
+ {label}
1489
+ </Text>
1490
+ </Glass>
1491
+ </Animated.View>
1492
+ ) : (
1493
+ <View className="rounded-lg bg-popover px-2.5 py-1 shadow-sm">
1494
+ <Text size="sm" className={cn('text-foreground', labelClassName)}>
1495
+ {label}
1496
+ </Text>
1497
+ </View>
1498
+ )
601
1499
  ) : null}
602
- <FabRoot
603
- ref={ref}
604
- icon={icon}
605
- // A step down from the trigger, so the trigger stays the one that
606
- // leads even while the dial it opened is on screen.
607
- size={size === 'lg' ? 'md' : 'sm'}
608
- variant={destructive ? 'destructive' : 'surface'}
609
- disabled={disabled}
610
- accessibilityLabel={label}
611
- onPress={handlePress}
612
- className={className}
613
- />
1500
+ <Animated.View style={glass ? buttonStyle : undefined}>
1501
+ <FabRoot
1502
+ ref={ref}
1503
+ icon={icon}
1504
+ // A step down from the trigger, so the trigger stays the one that
1505
+ // leads even while the dial it opened is on screen.
1506
+ size={actionSize}
1507
+ variant={destructive ? 'destructive' : 'surface'}
1508
+ disabled={disabled}
1509
+ glass={glass}
1510
+ accessibilityLabel={label}
1511
+ onPress={handlePress}
1512
+ className={className}
1513
+ />
1514
+ </Animated.View>
614
1515
  </View>
615
1516
  );
616
1517
  }