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