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.
- package/lib/module/components/fab/index.js +798 -53
- package/lib/module/components/fab/index.js.map +1 -1
- package/lib/module/components/section-progress/index.js +637 -0
- package/lib/module/components/section-progress/index.js.map +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-scroll-sections.js +58 -4
- package/lib/module/hooks/use-scroll-sections.js.map +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/native/index.js +55 -0
- package/lib/module/native/index.js.map +1 -1
- package/lib/module/primitives/glass.js +92 -9
- package/lib/module/primitives/glass.js.map +1 -1
- package/lib/typescript/src/components/fab/index.d.ts +133 -1
- package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
- package/lib/typescript/src/components/section-progress/index.d.ts +156 -0
- package/lib/typescript/src/components/section-progress/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/index.d.ts +1 -1
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts +24 -0
- package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/native/index.d.ts +115 -0
- package/lib/typescript/src/native/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/glass.d.ts +40 -2
- package/lib/typescript/src/primitives/glass.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/fab/index.tsx +967 -66
- package/src/components/section-progress/index.tsx +813 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-scroll-sections.ts +84 -5
- package/src/index.ts +21 -1
- package/src/native/index.ts +146 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
104
|
-
|
|
105
|
-
|
|
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: '
|
|
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
|
-
|
|
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(
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
261
|
-
<
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
-
</
|
|
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
|
-
() =>
|
|
406
|
-
|
|
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
|
-
() => ({
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
473
|
-
the trigger would still be in the
|
|
474
|
-
screen reader would walk into four
|
|
475
|
-
|
|
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
|
-
</
|
|
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({
|
|
517
|
-
|
|
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
|
-
|
|
540
|
-
const
|
|
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
|
|
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
|
-
{
|
|
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 } =
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
<
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
}
|