@bug-on/md3-react 3.0.1 → 3.0.3
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/.turbo/turbo-build.log +42 -42
- package/CHANGELOG.md +10 -0
- package/dist/index.css +107 -0
- package/dist/index.d.mts +1491 -1053
- package/dist/index.d.ts +1491 -1053
- package/dist/index.js +4457 -3156
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4394 -3109
- package/dist/index.mjs.map +1 -1
- package/package.json +11 -6
- package/scripts/copy-assets.js +113 -8
- package/src/index.ts +66 -18
- package/src/test/button.test.tsx +1 -1
- package/src/ui/app-bar/app-bar.tokens.ts +5 -24
- package/src/ui/badge.tsx +2 -1
- package/src/ui/buttons/button/button-tokens.ts +118 -0
- package/src/ui/{button.test.tsx → buttons/button/button.test.tsx} +0 -21
- package/src/ui/buttons/button/button.tsx +381 -0
- package/src/ui/buttons/button/index.ts +3 -0
- package/src/ui/buttons/button/types.ts +90 -0
- package/src/ui/buttons/button-group/button-group-defaults.ts +95 -0
- package/src/ui/buttons/button-group/button-group-tokens.ts +20 -0
- package/src/ui/{button-group.test.tsx → buttons/button-group/button-group.test.tsx} +9 -10
- package/src/ui/buttons/button-group/button-group.tsx +699 -0
- package/src/ui/buttons/button-group/index.ts +8 -0
- package/src/ui/buttons/button-group/types.ts +77 -0
- package/src/ui/{fab.tsx → buttons/fabs/fab/fab.tsx} +6 -6
- package/src/ui/buttons/fabs/fab/index.ts +1 -0
- package/src/ui/{fab-menu.tsx → buttons/fabs/fab-menu/fab-menu.tsx} +7 -4
- package/src/ui/buttons/fabs/fab-menu/index.ts +1 -0
- package/src/ui/buttons/fabs/index.ts +2 -0
- package/src/ui/{icon-button.tsx → buttons/icon-button/icon-button.tsx} +6 -6
- package/src/ui/buttons/icon-button/index.ts +1 -0
- package/src/ui/buttons/index.ts +4 -0
- package/src/ui/code-block.tsx +1 -1
- package/src/ui/dialog.tsx +4 -7
- package/src/ui/drawer.tsx +4 -7
- package/src/ui/menu/menu-animations.ts +14 -20
- package/src/ui/menu/menu-tokens.ts +7 -5
- package/src/ui/menu/menu.test.tsx +9 -4
- package/src/ui/navigation-bar.test.tsx +111 -0
- package/src/ui/navigation-bar.tsx +464 -0
- package/src/ui/navigation-rail.test.tsx +5 -4
- package/src/ui/navigation-rail.tsx +32 -23
- package/src/ui/scroll-area.tsx +4 -0
- package/src/ui/search/search-view-fullscreen.tsx +1 -1
- package/src/ui/search/search.tokens.ts +9 -43
- package/src/ui/search/trailing-action.tsx +1 -1
- package/src/ui/shared/constants.ts +25 -27
- package/src/ui/shared/motion-tokens.ts +238 -0
- package/src/ui/snackbar/snackbar.tsx +4 -6
- package/src/ui/switch/switch.tsx +12 -18
- package/src/ui/text-field/text-field.tokens.ts +12 -12
- package/src/ui/text-field/text-field.tsx +31 -19
- package/src/ui/theme-provider/index.tsx +1 -5
- package/src/ui/toc.tsx +1 -1
- package/src/ui/toolbar/__snapshots__/bottom-docked-toolbar.test.tsx.snap +51 -0
- package/src/ui/toolbar/__snapshots__/floating-toolbar-with-fab.test.tsx.snap +113 -0
- package/src/ui/toolbar/__snapshots__/floating-toolbar.test.tsx.snap +169 -0
- package/src/ui/toolbar/bottom-docked-toolbar.test.tsx +114 -0
- package/src/ui/toolbar/docked-toolbar.tsx +186 -0
- package/src/ui/toolbar/floating-toolbar-with-fab.test.tsx +139 -0
- package/src/ui/toolbar/floating-toolbar-with-fab.tsx +199 -0
- package/src/ui/toolbar/floating-toolbar.test.tsx +230 -0
- package/src/ui/toolbar/floating-toolbar.tsx +344 -0
- package/src/ui/toolbar/index.ts +35 -0
- package/src/ui/toolbar/toolbar-colors.ts +37 -0
- package/src/ui/toolbar/toolbar-context.tsx +13 -0
- package/src/ui/toolbar/toolbar-divider.test.tsx +54 -0
- package/src/ui/toolbar/toolbar-divider.tsx +73 -0
- package/src/ui/toolbar/toolbar-icon-button.test.tsx +68 -0
- package/src/ui/toolbar/toolbar-icon-button.tsx +136 -0
- package/src/ui/toolbar/toolbar-scroll-behavior.ts +140 -0
- package/src/ui/toolbar/toolbar-tokens.ts +51 -0
- package/test-clip.html +31 -0
- package/test-shadow.html +5 -1
- package/test-width.html +34 -0
- package/src/ui/button-group.tsx +0 -350
- package/src/ui/button.tsx +0 -665
- package/test-render.tsx +0 -4
- package/test_output.txt +0 -164
- package/test_output_v2.txt +0 -5
- /package/src/ui/{fab.test.tsx → buttons/fabs/fab/fab.test.tsx} +0 -0
- /package/src/ui/{fab-menu.test.tsx → buttons/fabs/fab-menu/fab-menu.test.tsx} +0 -0
- /package/src/ui/{icon-button.test.tsx → buttons/icon-button/icon-button.test.tsx} +0 -0
- /package/src/ui/{Text.tsx → text.tsx} +0 -0
package/src/ui/button.tsx
DELETED
|
@@ -1,665 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file button.tsx
|
|
3
|
-
*
|
|
4
|
-
* MD3 Expressive Button component.
|
|
5
|
-
*
|
|
6
|
-
* Spec: https://m3.material.io/components/buttons/overview
|
|
7
|
-
* Sizing (May 2025):
|
|
8
|
-
* XS → h:32dp | px: 12dp | icon: 18dp | gap: 8dp
|
|
9
|
-
* SM → h:40dp | px: 16dp | icon: 20dp | gap: 8dp
|
|
10
|
-
* MD → h:56dp | px: 24dp | icon: 24dp | gap: 8dp
|
|
11
|
-
* LG → h:96dp | px: 48dp | icon: 32dp | gap: 12dp
|
|
12
|
-
* XL → h:136dp | px: 48dp | icon: 40dp | gap: 12dp
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { Slot } from "@radix-ui/react-slot";
|
|
16
|
-
import { cva } from "class-variance-authority";
|
|
17
|
-
import type { HTMLMotionProps } from "motion/react";
|
|
18
|
-
import {
|
|
19
|
-
AnimatePresence,
|
|
20
|
-
animate,
|
|
21
|
-
domMax,
|
|
22
|
-
LazyMotion,
|
|
23
|
-
m,
|
|
24
|
-
useMotionValue,
|
|
25
|
-
} from "motion/react";
|
|
26
|
-
import * as React from "react";
|
|
27
|
-
import { cn } from "../lib/utils";
|
|
28
|
-
import { LoadingIndicator } from "./loading-indicator";
|
|
29
|
-
import { ProgressIndicator } from "./progress-indicator";
|
|
30
|
-
import { Ripple, useRippleState } from "./ripple";
|
|
31
|
-
import { SPRING_TRANSITION, SPRING_TRANSITION_FAST } from "./shared/constants";
|
|
32
|
-
import { TouchTarget } from "./shared/touch-target";
|
|
33
|
-
|
|
34
|
-
// ─── Design Tokens ────────────────────────────────────────────────────────────
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Per-size layout styles.
|
|
38
|
-
* Heights and paddings are taken directly from the MD3 Expressive spec (May 2025).
|
|
39
|
-
*/
|
|
40
|
-
const SIZE_STYLES: Record<string, React.CSSProperties> = {
|
|
41
|
-
xs: {
|
|
42
|
-
height: "2rem",
|
|
43
|
-
minWidth: "4rem",
|
|
44
|
-
paddingInline: "0.75rem",
|
|
45
|
-
gap: "0.5rem",
|
|
46
|
-
},
|
|
47
|
-
sm: {
|
|
48
|
-
height: "2.5rem",
|
|
49
|
-
minWidth: "5rem",
|
|
50
|
-
paddingInline: "1rem",
|
|
51
|
-
gap: "0.5rem",
|
|
52
|
-
},
|
|
53
|
-
md: {
|
|
54
|
-
height: "3.5rem",
|
|
55
|
-
minWidth: "7rem",
|
|
56
|
-
paddingInline: "1.5rem",
|
|
57
|
-
gap: "0.5rem",
|
|
58
|
-
},
|
|
59
|
-
lg: {
|
|
60
|
-
height: "6rem",
|
|
61
|
-
minWidth: "11rem",
|
|
62
|
-
paddingInline: "3rem",
|
|
63
|
-
gap: "0.75rem",
|
|
64
|
-
},
|
|
65
|
-
xl: {
|
|
66
|
-
height: "8.5rem",
|
|
67
|
-
minWidth: "14rem",
|
|
68
|
-
paddingInline: "3rem",
|
|
69
|
-
gap: "0.75rem",
|
|
70
|
-
},
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/** Per-size label typography classes. */
|
|
74
|
-
const SIZE_TEXT_CLASS: Record<string, string> = {
|
|
75
|
-
xs: "text-xs font-medium tracking-wide",
|
|
76
|
-
sm: "text-sm font-medium tracking-wide",
|
|
77
|
-
md: "text-base font-medium tracking-wide",
|
|
78
|
-
lg: "text-lg font-medium tracking-wide",
|
|
79
|
-
xl: "text-xl font-medium tracking-wide",
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Per-size icon container Tailwind classes.
|
|
84
|
-
* MD3 icon sizes: XS=18dp, SM=20dp, MD=24dp, LG=32dp, XL=40dp.
|
|
85
|
-
*/
|
|
86
|
-
const SIZE_ICON_CLASS: Record<string, string> = {
|
|
87
|
-
xs: "size-[1.125rem]",
|
|
88
|
-
sm: "size-5",
|
|
89
|
-
md: "size-6",
|
|
90
|
-
lg: "size-8",
|
|
91
|
-
xl: "size-10",
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Icon pixel-sizes for given button sizes.
|
|
96
|
-
* MD3 icon sizes: XS=18, SM=20, MD=24, LG=32, XL=40.
|
|
97
|
-
*/
|
|
98
|
-
const SIZE_ICON_PX: Record<string, number> = {
|
|
99
|
-
xs: 18,
|
|
100
|
-
sm: 20,
|
|
101
|
-
md: 24,
|
|
102
|
-
lg: 32,
|
|
103
|
-
xl: 40,
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
// ─── Shape Morphing ────────────────────────────────────────────────────────────
|
|
107
|
-
//
|
|
108
|
-
// IMPORTANT: Do NOT use 9999 for the "pill" default radius.
|
|
109
|
-
// CSS clips any border-radius > height/2 identically, so animating from
|
|
110
|
-
// 9999 → small value creates a perceptual dead zone (nothing looks different
|
|
111
|
-
// until the value drops below height/2). This makes the animation feel like
|
|
112
|
-
// it snaps/jerks. Use exact half-height values instead for truly smooth morph.
|
|
113
|
-
//
|
|
114
|
-
// Size heights: xs=32dp, sm=40dp, md=56dp, lg=96dp, xl=136dp
|
|
115
|
-
|
|
116
|
-
/** Per-size border radius values for a given shape state. */
|
|
117
|
-
type MorphRadius = { default: number; pressed: number };
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Border-radius token map for the "round" (pill) shape variant.
|
|
121
|
-
* Values equal `height / 2` for each size to ensure the pill stays perceptually
|
|
122
|
-
* smooth during spring animation (no dead zone artefact).
|
|
123
|
-
*/
|
|
124
|
-
const ROUND_RADIUS: Record<string, MorphRadius> = {
|
|
125
|
-
xs: { default: 16, pressed: 8 },
|
|
126
|
-
sm: { default: 20, pressed: 10 },
|
|
127
|
-
md: { default: 28, pressed: 16 },
|
|
128
|
-
lg: { default: 48, pressed: 28 },
|
|
129
|
-
xl: { default: 68, pressed: 40 },
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Border-radius token map for the "square" (rounded-square) shape variant.
|
|
134
|
-
* Pressed values compress inward following MD3 Expressive morphing spec.
|
|
135
|
-
*/
|
|
136
|
-
const SQUARE_RADIUS: Record<string, MorphRadius> = {
|
|
137
|
-
xs: { default: 4, pressed: 2 },
|
|
138
|
-
sm: { default: 8, pressed: 4 },
|
|
139
|
-
md: { default: 16, pressed: 10 },
|
|
140
|
-
lg: { default: 28, pressed: 20 },
|
|
141
|
-
xl: { default: 40, pressed: 28 },
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
// ─── Color Variants (CVA) ──────────────────────────────────────────────────────
|
|
145
|
-
|
|
146
|
-
const buttonColorVariants = cva(
|
|
147
|
-
[
|
|
148
|
-
"relative w-fit shrink-0 inline-flex flex-row items-center justify-center",
|
|
149
|
-
"whitespace-nowrap select-none cursor-pointer",
|
|
150
|
-
"transition-[background-color,color,border-color,box-shadow,opacity,filter] duration-200",
|
|
151
|
-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-m3-primary focus-visible:ring-offset-2",
|
|
152
|
-
"disabled:pointer-events-none disabled:opacity-[0.38] disabled:shadow-none",
|
|
153
|
-
],
|
|
154
|
-
{
|
|
155
|
-
variants: {
|
|
156
|
-
colorStyle: {
|
|
157
|
-
elevated: [
|
|
158
|
-
"bg-m3-surface-container-low text-m3-primary shadow-md",
|
|
159
|
-
"hover:bg-m3-primary/8",
|
|
160
|
-
"active:bg-m3-primary/12 active:shadow-sm",
|
|
161
|
-
"disabled:bg-m3-on-surface/12 disabled:text-m3-on-surface/[0.38]",
|
|
162
|
-
],
|
|
163
|
-
// filled = default + toggle-selected state (routes here via effectiveColorStyle)
|
|
164
|
-
filled: [
|
|
165
|
-
"bg-m3-primary text-m3-on-primary",
|
|
166
|
-
"hover:brightness-95",
|
|
167
|
-
"active:brightness-90 active:shadow-none",
|
|
168
|
-
"disabled:bg-m3-on-surface/12 disabled:text-m3-on-surface/[0.38]",
|
|
169
|
-
],
|
|
170
|
-
tonal: [
|
|
171
|
-
"bg-m3-secondary-container text-m3-on-secondary-container",
|
|
172
|
-
"hover:bg-m3-on-secondary-container/8",
|
|
173
|
-
"active:bg-m3-on-secondary-container/12 active:shadow-none",
|
|
174
|
-
"disabled:bg-m3-on-surface/12 disabled:text-m3-on-surface/[0.38]",
|
|
175
|
-
],
|
|
176
|
-
outlined: [
|
|
177
|
-
"bg-transparent border border-m3-outline text-m3-primary",
|
|
178
|
-
"hover:bg-m3-primary/8",
|
|
179
|
-
"active:bg-m3-primary/12",
|
|
180
|
-
"disabled:border-m3-on-surface/12 disabled:text-m3-on-surface/[0.38]",
|
|
181
|
-
],
|
|
182
|
-
text: [
|
|
183
|
-
"bg-transparent text-m3-primary px-3",
|
|
184
|
-
"hover:bg-m3-primary/8",
|
|
185
|
-
"active:bg-m3-primary/12",
|
|
186
|
-
"disabled:text-m3-on-surface/[0.38]",
|
|
187
|
-
],
|
|
188
|
-
},
|
|
189
|
-
},
|
|
190
|
-
defaultVariants: { colorStyle: "filled" },
|
|
191
|
-
},
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
// ─── Types ─────────────────────────────────────────────────────────────────────
|
|
195
|
-
// Use HTMLMotionProps<"button"> as the base to avoid onDrag / event handler
|
|
196
|
-
// conflicts between native React and Framer Motion's extended prop types.
|
|
197
|
-
|
|
198
|
-
type MotionButtonProps = Omit<HTMLMotionProps<"button">, "children" | "color">;
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Base props shared between the standard and toggle button variants.
|
|
202
|
-
*
|
|
203
|
-
* @see {@link ButtonProps} for the complete discriminated union type.
|
|
204
|
-
* @see https://m3.material.io/components/buttons/overview
|
|
205
|
-
*/
|
|
206
|
-
export interface BaseButtonProps extends MotionButtonProps {
|
|
207
|
-
/**
|
|
208
|
-
* Visual style variant following MD3 color roles.
|
|
209
|
-
* @default "filled"
|
|
210
|
-
*/
|
|
211
|
-
colorStyle?: "elevated" | "filled" | "tonal" | "outlined" | "text";
|
|
212
|
-
/**
|
|
213
|
-
* Color style applied when the toggle button is in the *selected* state.
|
|
214
|
-
* Only meaningful when `variant="toggle"`.
|
|
215
|
-
* Falls back to `"filled"` when not specified.
|
|
216
|
-
*/
|
|
217
|
-
selectedColorStyle?: "elevated" | "filled" | "tonal" | "outlined" | "text";
|
|
218
|
-
/**
|
|
219
|
-
* Button size following MD3 Expressive size scale.
|
|
220
|
-
* @default "sm"
|
|
221
|
-
*/
|
|
222
|
-
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
|
223
|
-
/**
|
|
224
|
-
* Container shape — controls border-radius morphing.
|
|
225
|
-
* - `round`: pill shape (CornerFull), morphs to rounded-square when toggled.
|
|
226
|
-
* - `square`: rounded-square, morphs to pill when toggled.
|
|
227
|
-
* @default "round"
|
|
228
|
-
*/
|
|
229
|
-
shape?: "round" | "square";
|
|
230
|
-
/**
|
|
231
|
-
* Optional leading or trailing icon node.
|
|
232
|
-
* Size is automatically scaled to match the button's `size` prop.
|
|
233
|
-
*/
|
|
234
|
-
icon?: React.ReactNode;
|
|
235
|
-
/**
|
|
236
|
-
* Position of the icon relative to the label text.
|
|
237
|
-
* @default "leading"
|
|
238
|
-
*/
|
|
239
|
-
iconPosition?: "leading" | "trailing";
|
|
240
|
-
/**
|
|
241
|
-
* When `true`, replaces the icon with an animated loading indicator
|
|
242
|
-
* and prevents interaction.
|
|
243
|
-
* @default false
|
|
244
|
-
*/
|
|
245
|
-
loading?: boolean;
|
|
246
|
-
/**
|
|
247
|
-
* Controls which loading spinner is shown while `loading={true}`.
|
|
248
|
-
* - `loading-indicator`: MD3 Expressive morphing shape (default).
|
|
249
|
-
* - `circular`: Classic circular spinner.
|
|
250
|
-
* @default "loading-indicator"
|
|
251
|
-
*/
|
|
252
|
-
loadingVariant?: "loading-indicator" | "circular";
|
|
253
|
-
/**
|
|
254
|
-
* When `true`, the Button renders its child element directly (using Radix Slot),
|
|
255
|
-
* merging all button props (className, style, event handlers) onto it.
|
|
256
|
-
* Useful for rendering a Next.js `<Link>` with Button styles.
|
|
257
|
-
*
|
|
258
|
-
* @example
|
|
259
|
-
* ```tsx
|
|
260
|
-
* <Button asChild size="lg">
|
|
261
|
-
* <Link href="/components">Explore Components</Link>
|
|
262
|
-
* </Button>
|
|
263
|
-
* ```
|
|
264
|
-
* @default false
|
|
265
|
-
*/
|
|
266
|
-
asChild?: boolean;
|
|
267
|
-
/** Button label — any React content, typically a string. */
|
|
268
|
-
children: React.ReactNode;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Complete `Button` props — discriminated union that enforces
|
|
273
|
-
* `selected` is only valid for `variant="toggle"`.
|
|
274
|
-
*
|
|
275
|
-
* @example
|
|
276
|
-
* ```tsx
|
|
277
|
-
* // Standard button
|
|
278
|
-
* <Button colorStyle="filled" size="md">Confirm</Button>
|
|
279
|
-
*
|
|
280
|
-
* // Toggle button (selected state required)
|
|
281
|
-
* <Button variant="toggle" selected={isActive} onClick={toggle}>Filter</Button>
|
|
282
|
-
*
|
|
283
|
-
* // With leading icon and loading state
|
|
284
|
-
* <Button icon={<CheckIcon />} loading={isSubmitting}>Save</Button>
|
|
285
|
-
* ```
|
|
286
|
-
*
|
|
287
|
-
* @see https://m3.material.io/components/buttons/overview
|
|
288
|
-
*/
|
|
289
|
-
export type ButtonProps = BaseButtonProps &
|
|
290
|
-
(
|
|
291
|
-
| { variant?: "default"; selected?: never }
|
|
292
|
-
| { variant: "toggle"; selected: boolean }
|
|
293
|
-
);
|
|
294
|
-
|
|
295
|
-
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
296
|
-
|
|
297
|
-
function resolveLabel(
|
|
298
|
-
children: React.ReactNode,
|
|
299
|
-
asChild: boolean,
|
|
300
|
-
): React.ReactNode {
|
|
301
|
-
if (asChild) {
|
|
302
|
-
const child = React.Children.only(children) as React.ReactElement<{
|
|
303
|
-
children?: React.ReactNode;
|
|
304
|
-
}>;
|
|
305
|
-
return child.props.children;
|
|
306
|
-
}
|
|
307
|
-
return children;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/** Framer Motion-specific props to strip before forwarding to a plain DOM element. */
|
|
311
|
-
const MOTION_PROP_KEYS = [
|
|
312
|
-
"animate",
|
|
313
|
-
"exit",
|
|
314
|
-
"initial",
|
|
315
|
-
"transition",
|
|
316
|
-
"variants",
|
|
317
|
-
"whileHover",
|
|
318
|
-
"whileTap",
|
|
319
|
-
"whileFocus",
|
|
320
|
-
"whileDrag",
|
|
321
|
-
"whileInView",
|
|
322
|
-
"onAnimationStart",
|
|
323
|
-
"onAnimationComplete",
|
|
324
|
-
"onUpdate",
|
|
325
|
-
"onDragStart",
|
|
326
|
-
"onDragEnd",
|
|
327
|
-
"onDrag",
|
|
328
|
-
"onDirectionLock",
|
|
329
|
-
"onDragTransitionEnd",
|
|
330
|
-
"layout",
|
|
331
|
-
"layoutId",
|
|
332
|
-
"onLayoutAnimationComplete",
|
|
333
|
-
] as const;
|
|
334
|
-
|
|
335
|
-
function stripMotionProps(
|
|
336
|
-
props: Record<string, unknown>,
|
|
337
|
-
): Record<string, unknown> {
|
|
338
|
-
const result = { ...props };
|
|
339
|
-
for (const key of MOTION_PROP_KEYS) delete result[key];
|
|
340
|
-
return result;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function springAnimate(
|
|
344
|
-
value: ReturnType<typeof useMotionValue<number>>,
|
|
345
|
-
to: number,
|
|
346
|
-
) {
|
|
347
|
-
animate(value, to, { ...SPRING_TRANSITION_FAST, type: "spring" });
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// ─── Sub-components ────────────────────────────────────────────────────────────
|
|
351
|
-
|
|
352
|
-
interface LoadingSpinnerProps {
|
|
353
|
-
size: number;
|
|
354
|
-
variant: "loading-indicator" | "circular";
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function LoadingSpinner({ size, variant }: LoadingSpinnerProps) {
|
|
358
|
-
if (variant === "loading-indicator") {
|
|
359
|
-
return (
|
|
360
|
-
<LoadingIndicator size={size} color="currentColor" aria-label="Loading" />
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
return (
|
|
364
|
-
<ProgressIndicator
|
|
365
|
-
variant="circular"
|
|
366
|
-
size={size}
|
|
367
|
-
color="currentColor"
|
|
368
|
-
trackColor="transparent"
|
|
369
|
-
aria-label="Loading"
|
|
370
|
-
/>
|
|
371
|
-
);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
interface AnimatedIconSlotProps {
|
|
375
|
-
iconClass: string;
|
|
376
|
-
children: React.ReactNode;
|
|
377
|
-
ariaHidden?: boolean;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function AnimatedIconSlot({
|
|
381
|
-
iconClass,
|
|
382
|
-
children,
|
|
383
|
-
ariaHidden,
|
|
384
|
-
}: AnimatedIconSlotProps) {
|
|
385
|
-
return (
|
|
386
|
-
<m.span
|
|
387
|
-
initial={{ width: 0, opacity: 0, scale: 0.5 }}
|
|
388
|
-
animate={{ width: "auto", opacity: 1, scale: 1 }}
|
|
389
|
-
exit={{ width: 0, opacity: 0, scale: 0.5 }}
|
|
390
|
-
transition={SPRING_TRANSITION}
|
|
391
|
-
aria-hidden={ariaHidden ? "true" : undefined}
|
|
392
|
-
className={cn(
|
|
393
|
-
"flex items-center justify-center shrink-0 [&>svg]:w-full [&>svg]:h-full overflow-hidden",
|
|
394
|
-
iconClass,
|
|
395
|
-
)}
|
|
396
|
-
>
|
|
397
|
-
{children}
|
|
398
|
-
</m.span>
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// ─── Component ─────────────────────────────────────────────────────────────────
|
|
403
|
-
|
|
404
|
-
const ButtonComponent = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
405
|
-
(
|
|
406
|
-
{
|
|
407
|
-
className,
|
|
408
|
-
style,
|
|
409
|
-
variant = "default",
|
|
410
|
-
colorStyle = "filled",
|
|
411
|
-
selectedColorStyle,
|
|
412
|
-
size = "sm",
|
|
413
|
-
shape = "round",
|
|
414
|
-
selected,
|
|
415
|
-
icon,
|
|
416
|
-
iconPosition = "leading",
|
|
417
|
-
loading = false,
|
|
418
|
-
loadingVariant = "loading-indicator",
|
|
419
|
-
asChild = false,
|
|
420
|
-
children,
|
|
421
|
-
onClick,
|
|
422
|
-
onKeyDown,
|
|
423
|
-
"aria-label": ariaLabelProp,
|
|
424
|
-
...restProps
|
|
425
|
-
},
|
|
426
|
-
ref,
|
|
427
|
-
) => {
|
|
428
|
-
const isToggle = variant === "toggle";
|
|
429
|
-
const isSelected = isToggle ? !!selected : false;
|
|
430
|
-
|
|
431
|
-
// When toggle is selected, shape flips (round ↔ square).
|
|
432
|
-
const effectiveShape = isSelected
|
|
433
|
-
? shape === "round"
|
|
434
|
-
? "square"
|
|
435
|
-
: "round"
|
|
436
|
-
: shape;
|
|
437
|
-
|
|
438
|
-
// effectiveColorStyle is the single source of truth for color.
|
|
439
|
-
// Avoids CSS specificity battles between two bg-* classes.
|
|
440
|
-
const effectiveColorStyle =
|
|
441
|
-
isToggle && isSelected ? (selectedColorStyle ?? "filled") : colorStyle;
|
|
442
|
-
|
|
443
|
-
const radiusMap = effectiveShape === "round" ? ROUND_RADIUS : SQUARE_RADIUS;
|
|
444
|
-
const { default: animateRadius } = radiusMap[size] ?? radiusMap.sm;
|
|
445
|
-
const { pressed: pressedRadius } = radiusMap[size] ?? radiusMap.sm;
|
|
446
|
-
|
|
447
|
-
const iconClass = SIZE_ICON_CLASS[size] ?? "size-5";
|
|
448
|
-
const mergedStyle = { ...SIZE_STYLES[size], ...style };
|
|
449
|
-
const labelText = React.useMemo(
|
|
450
|
-
() => resolveLabel(children, asChild),
|
|
451
|
-
[children, asChild],
|
|
452
|
-
);
|
|
453
|
-
const computedAriaLabel =
|
|
454
|
-
ariaLabelProp || (typeof children === "string" ? children : undefined);
|
|
455
|
-
const needsTouchTarget = size === "xs" || size === "sm";
|
|
456
|
-
|
|
457
|
-
// Shape morphing motion value for asChild mode.
|
|
458
|
-
// Radix Slot clones the child, so Framer Motion loses DOM tracking.
|
|
459
|
-
// Instead we subscribe to motionRadius.on("change") and update style.borderRadius imperatively.
|
|
460
|
-
const motionRadius = useMotionValue(animateRadius);
|
|
461
|
-
const asChildRef = React.useRef<HTMLElement | null>(null);
|
|
462
|
-
|
|
463
|
-
// Merge forwardRef + asChildRef into a single callback ref (Slot accepts only one ref).
|
|
464
|
-
const mergedRef = React.useCallback(
|
|
465
|
-
(node: HTMLElement | null) => {
|
|
466
|
-
asChildRef.current = node;
|
|
467
|
-
if (typeof ref === "function") ref(node as HTMLButtonElement);
|
|
468
|
-
else if (ref)
|
|
469
|
-
(ref as React.MutableRefObject<HTMLButtonElement | null>).current =
|
|
470
|
-
node as HTMLButtonElement;
|
|
471
|
-
},
|
|
472
|
-
[ref],
|
|
473
|
-
);
|
|
474
|
-
|
|
475
|
-
// Keep DOM borderRadius synced with motionRadius.
|
|
476
|
-
React.useEffect(
|
|
477
|
-
() =>
|
|
478
|
-
motionRadius.on("change", (v) => {
|
|
479
|
-
if (asChildRef.current)
|
|
480
|
-
asChildRef.current.style.borderRadius = `${v}px`;
|
|
481
|
-
}),
|
|
482
|
-
[motionRadius],
|
|
483
|
-
);
|
|
484
|
-
|
|
485
|
-
// Animate to new target radius when toggle state or size changes.
|
|
486
|
-
React.useEffect(() => {
|
|
487
|
-
springAnimate(motionRadius, animateRadius);
|
|
488
|
-
}, [animateRadius, motionRadius]);
|
|
489
|
-
|
|
490
|
-
const { ripples, onPointerDown, removeRipple } = useRippleState({
|
|
491
|
-
disabled: loading,
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
const handleClick = React.useCallback(
|
|
495
|
-
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
496
|
-
if (loading) return e.preventDefault();
|
|
497
|
-
onClick?.(e);
|
|
498
|
-
},
|
|
499
|
-
[loading, onClick],
|
|
500
|
-
);
|
|
501
|
-
|
|
502
|
-
const handleKeyDown = React.useCallback(
|
|
503
|
-
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
504
|
-
if (loading) return;
|
|
505
|
-
if (onClick && (e.key === "Enter" || e.key === " ")) {
|
|
506
|
-
e.preventDefault();
|
|
507
|
-
(e.currentTarget as HTMLButtonElement).click();
|
|
508
|
-
}
|
|
509
|
-
onKeyDown?.(e);
|
|
510
|
-
},
|
|
511
|
-
[loading, onClick, onKeyDown],
|
|
512
|
-
);
|
|
513
|
-
|
|
514
|
-
const buttonClassName = cn(
|
|
515
|
-
buttonColorVariants({ colorStyle: effectiveColorStyle }),
|
|
516
|
-
// overflow-hidden clips Ripple to match the morphing border-radius
|
|
517
|
-
"overflow-hidden",
|
|
518
|
-
SIZE_TEXT_CLASS[size],
|
|
519
|
-
needsTouchTarget && "relative",
|
|
520
|
-
loading && "pointer-events-none opacity-75 cursor-not-allowed",
|
|
521
|
-
className,
|
|
522
|
-
);
|
|
523
|
-
|
|
524
|
-
const innerContent = (
|
|
525
|
-
<>
|
|
526
|
-
{/* Invisible touch-target expander (min 48×48dp) for small buttons */}
|
|
527
|
-
{needsTouchTarget && <TouchTarget />}
|
|
528
|
-
|
|
529
|
-
{/* MD3 Expressive Ripple layer */}
|
|
530
|
-
<Ripple ripples={ripples} onRippleDone={removeRipple} />
|
|
531
|
-
|
|
532
|
-
<AnimatePresence initial={false}>
|
|
533
|
-
{(loading || (icon && iconPosition === "leading")) && (
|
|
534
|
-
<AnimatedIconSlot iconClass={iconClass} ariaHidden={!loading}>
|
|
535
|
-
{loading ? (
|
|
536
|
-
<LoadingSpinner
|
|
537
|
-
size={SIZE_ICON_PX[size] ?? 20}
|
|
538
|
-
variant={loadingVariant}
|
|
539
|
-
/>
|
|
540
|
-
) : (
|
|
541
|
-
icon
|
|
542
|
-
)}
|
|
543
|
-
</AnimatedIconSlot>
|
|
544
|
-
)}
|
|
545
|
-
</AnimatePresence>
|
|
546
|
-
|
|
547
|
-
<m.span
|
|
548
|
-
layout="size"
|
|
549
|
-
className="inline-flex items-center h-full gap-[inherit]"
|
|
550
|
-
transition={SPRING_TRANSITION}
|
|
551
|
-
>
|
|
552
|
-
{labelText}
|
|
553
|
-
</m.span>
|
|
554
|
-
|
|
555
|
-
<AnimatePresence initial={false}>
|
|
556
|
-
{icon && iconPosition === "trailing" && (
|
|
557
|
-
<AnimatedIconSlot iconClass={iconClass} ariaHidden>
|
|
558
|
-
{icon}
|
|
559
|
-
</AnimatedIconSlot>
|
|
560
|
-
)}
|
|
561
|
-
</AnimatePresence>
|
|
562
|
-
</>
|
|
563
|
-
);
|
|
564
|
-
|
|
565
|
-
// asChild: render Slot with imperative motion value driving borderRadius.
|
|
566
|
-
// Framer Motion works imperatively here because Radix Slot clones the child,
|
|
567
|
-
// breaking Framer Motion's internal DOM tracking.
|
|
568
|
-
if (asChild) {
|
|
569
|
-
const htmlProps = stripMotionProps(restProps as Record<string, unknown>);
|
|
570
|
-
const child = React.Children.only(children) as React.ReactElement<{
|
|
571
|
-
children?: React.ReactNode;
|
|
572
|
-
}>;
|
|
573
|
-
|
|
574
|
-
const handleAsChildPointerDown = (e: React.PointerEvent<HTMLElement>) => {
|
|
575
|
-
springAnimate(motionRadius, pressedRadius);
|
|
576
|
-
(onPointerDown as React.PointerEventHandler<HTMLElement>)?.(e);
|
|
577
|
-
};
|
|
578
|
-
|
|
579
|
-
const handleAsChildPointerUp = () => {
|
|
580
|
-
springAnimate(motionRadius, animateRadius);
|
|
581
|
-
};
|
|
582
|
-
|
|
583
|
-
return (
|
|
584
|
-
<LazyMotion features={domMax} strict>
|
|
585
|
-
<Slot
|
|
586
|
-
ref={mergedRef as React.Ref<HTMLButtonElement>}
|
|
587
|
-
aria-label={computedAriaLabel}
|
|
588
|
-
onClick={handleClick as React.MouseEventHandler<HTMLElement>}
|
|
589
|
-
onPointerDown={handleAsChildPointerDown}
|
|
590
|
-
onPointerUp={handleAsChildPointerUp}
|
|
591
|
-
onPointerLeave={handleAsChildPointerUp}
|
|
592
|
-
onPointerCancel={handleAsChildPointerUp}
|
|
593
|
-
onKeyDown={handleKeyDown as React.KeyboardEventHandler<HTMLElement>}
|
|
594
|
-
style={{
|
|
595
|
-
...(mergedStyle as React.CSSProperties),
|
|
596
|
-
borderRadius: `${animateRadius}px`,
|
|
597
|
-
}}
|
|
598
|
-
className={buttonClassName}
|
|
599
|
-
{...htmlProps}
|
|
600
|
-
>
|
|
601
|
-
{React.cloneElement(child, { children: innerContent })}
|
|
602
|
-
</Slot>
|
|
603
|
-
</LazyMotion>
|
|
604
|
-
);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Default: animated m.button
|
|
608
|
-
return (
|
|
609
|
-
<LazyMotion features={domMax} strict>
|
|
610
|
-
<m.button
|
|
611
|
-
ref={ref}
|
|
612
|
-
type="button"
|
|
613
|
-
aria-pressed={isToggle ? isSelected : undefined}
|
|
614
|
-
aria-label={computedAriaLabel}
|
|
615
|
-
aria-busy={loading ? true : undefined}
|
|
616
|
-
aria-disabled={loading ? true : restProps.disabled}
|
|
617
|
-
onClick={handleClick}
|
|
618
|
-
onPointerDown={onPointerDown}
|
|
619
|
-
onKeyDown={handleKeyDown}
|
|
620
|
-
style={mergedStyle}
|
|
621
|
-
animate={{ borderRadius: animateRadius }}
|
|
622
|
-
whileTap={{ borderRadius: pressedRadius }}
|
|
623
|
-
transition={{ borderRadius: SPRING_TRANSITION_FAST }}
|
|
624
|
-
className={buttonClassName}
|
|
625
|
-
{...restProps}
|
|
626
|
-
>
|
|
627
|
-
{innerContent}
|
|
628
|
-
</m.button>
|
|
629
|
-
</LazyMotion>
|
|
630
|
-
);
|
|
631
|
-
},
|
|
632
|
-
);
|
|
633
|
-
|
|
634
|
-
ButtonComponent.displayName = "Button";
|
|
635
|
-
|
|
636
|
-
/**
|
|
637
|
-
* MD3 Expressive Button component.
|
|
638
|
-
*
|
|
639
|
-
* Supports all five MD3 color styles, five sizes, shape morphing on toggle,
|
|
640
|
-
* leading/trailing icons, and an animated loading state.
|
|
641
|
-
*
|
|
642
|
-
* @remarks
|
|
643
|
-
* - `variant="toggle"` requires `selected: boolean` — enforced by the type system.
|
|
644
|
-
* - When `loading={true}`, the button is visually dimmed, pointer events are
|
|
645
|
-
* blocked, and `aria-busy` is set for screen readers.
|
|
646
|
-
* - Shape morphs smoothly between pill ↔ rounded-square when toggle state changes,
|
|
647
|
-
* using a critically-damped spring (no overshoot artefacts).
|
|
648
|
-
*
|
|
649
|
-
* @example
|
|
650
|
-
* ```tsx
|
|
651
|
-
* // Standard filled button
|
|
652
|
-
* <Button colorStyle="filled" size="md">Confirm</Button>
|
|
653
|
-
*
|
|
654
|
-
* // Button with icon
|
|
655
|
-
* <Button icon={<CheckIcon />} loading={isSubmitting}>Save</Button>
|
|
656
|
-
*
|
|
657
|
-
* // Toggle button
|
|
658
|
-
* <Button variant="toggle" selected={isActive} onClick={toggle}>
|
|
659
|
-
* Filter
|
|
660
|
-
* </Button>
|
|
661
|
-
* ```
|
|
662
|
-
*
|
|
663
|
-
* @see https://m3.material.io/components/buttons/overview
|
|
664
|
-
*/
|
|
665
|
-
export const Button = React.memo(ButtonComponent);
|
package/test-render.tsx
DELETED