@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.
Files changed (86) hide show
  1. package/.turbo/turbo-build.log +42 -42
  2. package/CHANGELOG.md +10 -0
  3. package/dist/index.css +107 -0
  4. package/dist/index.d.mts +1491 -1053
  5. package/dist/index.d.ts +1491 -1053
  6. package/dist/index.js +4457 -3156
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +4394 -3109
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +11 -6
  11. package/scripts/copy-assets.js +113 -8
  12. package/src/index.ts +66 -18
  13. package/src/test/button.test.tsx +1 -1
  14. package/src/ui/app-bar/app-bar.tokens.ts +5 -24
  15. package/src/ui/badge.tsx +2 -1
  16. package/src/ui/buttons/button/button-tokens.ts +118 -0
  17. package/src/ui/{button.test.tsx → buttons/button/button.test.tsx} +0 -21
  18. package/src/ui/buttons/button/button.tsx +381 -0
  19. package/src/ui/buttons/button/index.ts +3 -0
  20. package/src/ui/buttons/button/types.ts +90 -0
  21. package/src/ui/buttons/button-group/button-group-defaults.ts +95 -0
  22. package/src/ui/buttons/button-group/button-group-tokens.ts +20 -0
  23. package/src/ui/{button-group.test.tsx → buttons/button-group/button-group.test.tsx} +9 -10
  24. package/src/ui/buttons/button-group/button-group.tsx +699 -0
  25. package/src/ui/buttons/button-group/index.ts +8 -0
  26. package/src/ui/buttons/button-group/types.ts +77 -0
  27. package/src/ui/{fab.tsx → buttons/fabs/fab/fab.tsx} +6 -6
  28. package/src/ui/buttons/fabs/fab/index.ts +1 -0
  29. package/src/ui/{fab-menu.tsx → buttons/fabs/fab-menu/fab-menu.tsx} +7 -4
  30. package/src/ui/buttons/fabs/fab-menu/index.ts +1 -0
  31. package/src/ui/buttons/fabs/index.ts +2 -0
  32. package/src/ui/{icon-button.tsx → buttons/icon-button/icon-button.tsx} +6 -6
  33. package/src/ui/buttons/icon-button/index.ts +1 -0
  34. package/src/ui/buttons/index.ts +4 -0
  35. package/src/ui/code-block.tsx +1 -1
  36. package/src/ui/dialog.tsx +4 -7
  37. package/src/ui/drawer.tsx +4 -7
  38. package/src/ui/menu/menu-animations.ts +14 -20
  39. package/src/ui/menu/menu-tokens.ts +7 -5
  40. package/src/ui/menu/menu.test.tsx +9 -4
  41. package/src/ui/navigation-bar.test.tsx +111 -0
  42. package/src/ui/navigation-bar.tsx +464 -0
  43. package/src/ui/navigation-rail.test.tsx +5 -4
  44. package/src/ui/navigation-rail.tsx +32 -23
  45. package/src/ui/scroll-area.tsx +4 -0
  46. package/src/ui/search/search-view-fullscreen.tsx +1 -1
  47. package/src/ui/search/search.tokens.ts +9 -43
  48. package/src/ui/search/trailing-action.tsx +1 -1
  49. package/src/ui/shared/constants.ts +25 -27
  50. package/src/ui/shared/motion-tokens.ts +238 -0
  51. package/src/ui/snackbar/snackbar.tsx +4 -6
  52. package/src/ui/switch/switch.tsx +12 -18
  53. package/src/ui/text-field/text-field.tokens.ts +12 -12
  54. package/src/ui/text-field/text-field.tsx +31 -19
  55. package/src/ui/theme-provider/index.tsx +1 -5
  56. package/src/ui/toc.tsx +1 -1
  57. package/src/ui/toolbar/__snapshots__/bottom-docked-toolbar.test.tsx.snap +51 -0
  58. package/src/ui/toolbar/__snapshots__/floating-toolbar-with-fab.test.tsx.snap +113 -0
  59. package/src/ui/toolbar/__snapshots__/floating-toolbar.test.tsx.snap +169 -0
  60. package/src/ui/toolbar/bottom-docked-toolbar.test.tsx +114 -0
  61. package/src/ui/toolbar/docked-toolbar.tsx +186 -0
  62. package/src/ui/toolbar/floating-toolbar-with-fab.test.tsx +139 -0
  63. package/src/ui/toolbar/floating-toolbar-with-fab.tsx +199 -0
  64. package/src/ui/toolbar/floating-toolbar.test.tsx +230 -0
  65. package/src/ui/toolbar/floating-toolbar.tsx +344 -0
  66. package/src/ui/toolbar/index.ts +35 -0
  67. package/src/ui/toolbar/toolbar-colors.ts +37 -0
  68. package/src/ui/toolbar/toolbar-context.tsx +13 -0
  69. package/src/ui/toolbar/toolbar-divider.test.tsx +54 -0
  70. package/src/ui/toolbar/toolbar-divider.tsx +73 -0
  71. package/src/ui/toolbar/toolbar-icon-button.test.tsx +68 -0
  72. package/src/ui/toolbar/toolbar-icon-button.tsx +136 -0
  73. package/src/ui/toolbar/toolbar-scroll-behavior.ts +140 -0
  74. package/src/ui/toolbar/toolbar-tokens.ts +51 -0
  75. package/test-clip.html +31 -0
  76. package/test-shadow.html +5 -1
  77. package/test-width.html +34 -0
  78. package/src/ui/button-group.tsx +0 -350
  79. package/src/ui/button.tsx +0 -665
  80. package/test-render.tsx +0 -4
  81. package/test_output.txt +0 -164
  82. package/test_output_v2.txt +0 -5
  83. /package/src/ui/{fab.test.tsx → buttons/fabs/fab/fab.test.tsx} +0 -0
  84. /package/src/ui/{fab-menu.test.tsx → buttons/fabs/fab-menu/fab-menu.test.tsx} +0 -0
  85. /package/src/ui/{icon-button.test.tsx → buttons/icon-button/icon-button.test.tsx} +0 -0
  86. /package/src/ui/{Text.tsx → text.tsx} +0 -0
@@ -0,0 +1,699 @@
1
+ import {
2
+ AnimatePresence,
3
+ m,
4
+ type TargetAndTransition,
5
+ type Transition,
6
+ } from "motion/react";
7
+ import * as React from "react";
8
+ import { cn } from "../../../lib/utils";
9
+ import type { MD3Size } from "../../../types/md3";
10
+ import { Icon } from "../../icon";
11
+ import type { ButtonProps } from "../button";
12
+ import {
13
+ BUTTON_RADIUS_SPRING,
14
+ BUTTON_SHAPE_MORPH_SPRING,
15
+ BUTTON_SIZE_TOKENS,
16
+ } from "../button/button-tokens";
17
+ import {
18
+ ButtonGroupDefaults,
19
+ PRESSED_RADIUS_MAP,
20
+ type ShapeSet,
21
+ } from "./button-group-defaults";
22
+ import type { ButtonGroupProps } from "./types";
23
+
24
+ // ─── Constants ───────────────────────────────────────────────────────────────
25
+
26
+ const PILL_BORDER_RADIUS = 9999;
27
+ const MIN_FLEX_GROW = 0.01;
28
+ const PADDING_EXPAND_MULTIPLIER = 4;
29
+
30
+ const NAVBAR_INDICATOR_HEIGHT_MAP: Record<string, string> = {
31
+ xs: "h-[calc(100%-4px)]",
32
+ sm: "h-[calc(100%-8px)]",
33
+ md: "h-[calc(100%-12px)]",
34
+ lg: "h-[calc(100%-16px)]",
35
+ xl: "h-[calc(100%-24px)]",
36
+ };
37
+
38
+ const CHECK_ICON_SIZE = 24;
39
+ const CHECK_ICON = <Icon name="check" size={CHECK_ICON_SIZE} weight={700} />;
40
+
41
+ const CHECK_ICON_GAP_MAP: Record<string, number> = {
42
+ xs: 6,
43
+ sm: 8,
44
+ md: 8,
45
+ lg: 12,
46
+ xl: 12,
47
+ };
48
+
49
+ const CHECK_ICON_MOTION: Transition = {
50
+ type: "spring",
51
+ stiffness: 420,
52
+ damping: 34,
53
+ mass: 0.75,
54
+ };
55
+
56
+ const WIDTH_MORPH_SPRING: Transition = {
57
+ type: "spring",
58
+ stiffness: 360,
59
+ damping: 38,
60
+ mass: 0.85,
61
+ };
62
+
63
+ const NAVBAR_EXPRESSIVE_SPRING: Transition = {
64
+ type: "spring",
65
+ stiffness: 430,
66
+ damping: 38,
67
+ mass: 0.8,
68
+ };
69
+
70
+ // ─── Sub-components ──────────────────────────────────────────────────────────
71
+
72
+ const AnimatedCheckLabel = React.memo(function AnimatedCheckLabel({
73
+ selected,
74
+ size,
75
+ children,
76
+ }: {
77
+ selected: boolean;
78
+ size: string;
79
+ children: React.ReactNode;
80
+ }) {
81
+ const gap = CHECK_ICON_GAP_MAP[size] ?? CHECK_ICON_GAP_MAP.sm;
82
+
83
+ return (
84
+ <m.span
85
+ layout
86
+ className="inline-flex min-w-0 items-center justify-center whitespace-nowrap align-middle"
87
+ >
88
+ <AnimatePresence initial={false}>
89
+ {selected && (
90
+ <m.span
91
+ key="selected-check"
92
+ aria-hidden="true"
93
+ initial={{ width: 0, opacity: 0, marginRight: 0, x: -4 }}
94
+ animate={{
95
+ width: CHECK_ICON_SIZE,
96
+ opacity: 1,
97
+ marginRight: gap,
98
+ x: 0,
99
+ }}
100
+ exit={{ width: 0, opacity: 0, marginRight: 0, x: -4 }}
101
+ transition={CHECK_ICON_MOTION}
102
+ className="inline-flex shrink-0 items-center justify-center overflow-hidden"
103
+ style={{ willChange: "width, opacity, transform, margin-right" }}
104
+ >
105
+ <span className="inline-flex h-6 w-6 items-center justify-center">
106
+ {CHECK_ICON}
107
+ </span>
108
+ </m.span>
109
+ )}
110
+ </AnimatePresence>
111
+ <m.span layout="position" className="min-w-0 truncate">
112
+ {children}
113
+ </m.span>
114
+ </m.span>
115
+ );
116
+ });
117
+
118
+ const AnimatedNavbarLabel = React.memo(function AnimatedNavbarLabel({
119
+ show,
120
+ children,
121
+ }: {
122
+ show: boolean;
123
+ children: React.ReactNode;
124
+ }) {
125
+ return (
126
+ <AnimatePresence initial={false} mode="popLayout">
127
+ {show && (
128
+ <m.span
129
+ key="navbar-label"
130
+ initial={{ width: 0, opacity: 0, x: -6 }}
131
+ animate={{ width: "auto", opacity: 1, x: 0 }}
132
+ exit={{ width: 0, opacity: 0, x: -6 }}
133
+ transition={NAVBAR_EXPRESSIVE_SPRING}
134
+ className="overflow-hidden whitespace-nowrap text-sm font-semibold tracking-[0.01em] ml-2"
135
+ >
136
+ {children}
137
+ </m.span>
138
+ )}
139
+ </AnimatePresence>
140
+ );
141
+ });
142
+
143
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
144
+
145
+ function resolveConnectedShapes(
146
+ variant: string,
147
+ orientation: string,
148
+ isFirst: boolean,
149
+ isLast: boolean,
150
+ itemSize: MD3Size,
151
+ ): ShapeSet {
152
+ const defaults = ButtonGroupDefaults.connectedLeadingButtonShapes(itemSize);
153
+ if (variant !== "connected") return defaults;
154
+
155
+ if (orientation === "horizontal") {
156
+ if (isFirst && !isLast)
157
+ return ButtonGroupDefaults.connectedLeadingButtonShapes(itemSize);
158
+ if (!isFirst && isLast)
159
+ return ButtonGroupDefaults.connectedTrailingButtonShapes(itemSize);
160
+ if (!isFirst && !isLast)
161
+ return ButtonGroupDefaults.connectedMiddleButtonShapes(itemSize);
162
+ } else {
163
+ if (isFirst && !isLast)
164
+ return ButtonGroupDefaults.connectedTopButtonShapes(itemSize);
165
+ if (!isFirst && isLast)
166
+ return ButtonGroupDefaults.connectedBottomButtonShapes(itemSize);
167
+ if (!isFirst && !isLast)
168
+ return ButtonGroupDefaults.connectedMiddleButtonShapes(itemSize);
169
+ }
170
+
171
+ return defaults;
172
+ }
173
+
174
+ interface ResolvedIcon {
175
+ icon: React.ReactNode;
176
+ label: React.ReactNode;
177
+ }
178
+
179
+ function resolveIconAndLabel(
180
+ element: React.ReactElement<ButtonProps>,
181
+ isSelected: boolean,
182
+ itemSize: string,
183
+ variant: string,
184
+ showCheck: boolean,
185
+ iconBehavior: ButtonGroupProps["iconBehavior"],
186
+ labelBehavior: ButtonGroupProps["labelBehavior"],
187
+ ): ResolvedIcon {
188
+ let icon: React.ReactNode = element.props.icon;
189
+ let label: React.ReactNode = element.props.children;
190
+
191
+ const animateCheckInLabel =
192
+ showCheck &&
193
+ iconBehavior !== "none" &&
194
+ iconBehavior !== "selected" &&
195
+ label !== undefined;
196
+
197
+ if (iconBehavior === "none" || animateCheckInLabel) {
198
+ icon = undefined;
199
+ } else if (iconBehavior === "selected") {
200
+ icon = isSelected ? element.props.icon : undefined;
201
+ }
202
+
203
+ if (labelBehavior === "none") {
204
+ label = undefined;
205
+ } else if (labelBehavior === "selected") {
206
+ label = isSelected ? element.props.children : undefined;
207
+ }
208
+
209
+ if (animateCheckInLabel && label !== undefined) {
210
+ label = (
211
+ <AnimatedCheckLabel selected={isSelected} size={itemSize}>
212
+ {label}
213
+ </AnimatedCheckLabel>
214
+ );
215
+ } else if (variant === "navbar" && label !== undefined) {
216
+ const showLabel =
217
+ !labelBehavior ||
218
+ labelBehavior === "all" ||
219
+ (labelBehavior === "selected" && isSelected);
220
+ label = <AnimatedNavbarLabel show={showLabel}>{label}</AnimatedNavbarLabel>;
221
+ }
222
+
223
+ return { icon, label };
224
+ }
225
+
226
+ interface FlexTargets {
227
+ flexGrow?: number;
228
+ paddingInline?: string;
229
+ }
230
+
231
+ function resolveFlexTargets(
232
+ variant: string,
233
+ orientation: string,
234
+ isSelected: boolean,
235
+ isPressed: boolean,
236
+ hasSelected: boolean,
237
+ count: number,
238
+ fullWidth: boolean,
239
+ activeMorphing: boolean,
240
+ morphingWidth: boolean,
241
+ expandedRatio: number,
242
+ basePx: string,
243
+ ): FlexTargets {
244
+ const increment = expandedRatio * PADDING_EXPAND_MULTIPLIER;
245
+
246
+ if (activeMorphing) {
247
+ const isFlexLayout =
248
+ fullWidth || (variant === "connected" && orientation === "horizontal");
249
+
250
+ if (isFlexLayout) {
251
+ if (isSelected) return { flexGrow: 1 + expandedRatio };
252
+ if (hasSelected && count > 1)
253
+ return {
254
+ flexGrow: Math.max(MIN_FLEX_GROW, 1 - expandedRatio / (count - 1)),
255
+ };
256
+ return { flexGrow: 1 };
257
+ }
258
+
259
+ if (isSelected)
260
+ return { paddingInline: `calc(${basePx} + ${increment}rem)` };
261
+ if (hasSelected && count > 1) {
262
+ const decrement = increment / (count - 1);
263
+ return { paddingInline: `calc(${basePx} - ${decrement}rem)` };
264
+ }
265
+ return { paddingInline: basePx };
266
+ }
267
+
268
+ if (morphingWidth && variant === "standard") {
269
+ if (fullWidth) return { flexGrow: isPressed ? 1 + expandedRatio : 1 };
270
+ return {
271
+ paddingInline: isPressed ? `calc(${basePx} + ${increment}rem)` : basePx,
272
+ };
273
+ }
274
+
275
+ return {};
276
+ }
277
+
278
+ type MotionOverrides = {
279
+ animate: TargetAndTransition;
280
+ whileTap?: TargetAndTransition;
281
+ transition?: Transition;
282
+ layout?: boolean | "position" | "size" | "preserve-aspect";
283
+ };
284
+
285
+ function buildMotionProps(
286
+ variant: string,
287
+ isSelected: boolean,
288
+ isPressed: boolean,
289
+ itemSize: MD3Size,
290
+ shapes: ShapeSet,
291
+ flex: FlexTargets,
292
+ existingAnimate: TargetAndTransition,
293
+ existingWhileTap: TargetAndTransition,
294
+ ): MotionOverrides {
295
+ const props: MotionOverrides = { animate: { ...existingAnimate } };
296
+
297
+ if (flex.flexGrow !== undefined) {
298
+ props.animate.flexGrow = flex.flexGrow;
299
+ }
300
+ if (flex.paddingInline !== undefined) {
301
+ props.animate.paddingInline = flex.paddingInline;
302
+ }
303
+
304
+ if (variant === "standard") {
305
+ const pressedRadius = PRESSED_RADIUS_MAP[itemSize] ?? PRESSED_RADIUS_MAP.sm;
306
+ const itemSizeTokens =
307
+ BUTTON_SIZE_TOKENS[itemSize] ?? BUTTON_SIZE_TOKENS.sm;
308
+ const isSquish = isSelected || isPressed;
309
+
310
+ props.animate.borderRadius = isSquish
311
+ ? pressedRadius
312
+ : itemSizeTokens.roundShapeRadius;
313
+ props.whileTap = {
314
+ scale: 0.98,
315
+ ...existingWhileTap,
316
+ };
317
+ props.transition = {
318
+ borderRadius: BUTTON_RADIUS_SPRING,
319
+ flexGrow: WIDTH_MORPH_SPRING,
320
+ paddingInline: WIDTH_MORPH_SPRING,
321
+ layout: WIDTH_MORPH_SPRING,
322
+ default: BUTTON_SHAPE_MORPH_SPRING,
323
+ };
324
+ props.layout = true;
325
+ }
326
+
327
+ if (variant === "connected") {
328
+ const { tl, tr, br, bl } = isSelected
329
+ ? {
330
+ tl: PILL_BORDER_RADIUS,
331
+ tr: PILL_BORDER_RADIUS,
332
+ br: PILL_BORDER_RADIUS,
333
+ bl: PILL_BORDER_RADIUS,
334
+ }
335
+ : shapes.default;
336
+
337
+ props.animate = {
338
+ ...props.animate,
339
+ borderRadius: undefined,
340
+ borderTopLeftRadius: tl,
341
+ borderTopRightRadius: tr,
342
+ borderBottomRightRadius: br,
343
+ borderBottomLeftRadius: bl,
344
+ };
345
+ props.whileTap = {
346
+ borderRadius: undefined,
347
+ borderTopLeftRadius: shapes.pressed.tl,
348
+ borderTopRightRadius: shapes.pressed.tr,
349
+ borderBottomRightRadius: shapes.pressed.br,
350
+ borderBottomLeftRadius: shapes.pressed.bl,
351
+ ...existingWhileTap,
352
+ };
353
+ props.transition = {
354
+ ...BUTTON_SHAPE_MORPH_SPRING,
355
+ flexGrow: WIDTH_MORPH_SPRING,
356
+ paddingInline: WIDTH_MORPH_SPRING,
357
+ borderTopLeftRadius: BUTTON_RADIUS_SPRING,
358
+ borderTopRightRadius: BUTTON_RADIUS_SPRING,
359
+ borderBottomRightRadius: BUTTON_RADIUS_SPRING,
360
+ borderBottomLeftRadius: BUTTON_RADIUS_SPRING,
361
+ layout: WIDTH_MORPH_SPRING,
362
+ };
363
+ props.layout = true;
364
+ }
365
+
366
+ if (variant === "navbar") {
367
+ props.layout = true;
368
+ props.transition = {
369
+ layout: WIDTH_MORPH_SPRING,
370
+ default: BUTTON_SHAPE_MORPH_SPRING,
371
+ };
372
+ }
373
+
374
+ return props;
375
+ }
376
+
377
+ // ─── Main Component ───────────────────────────────────────────────────────────
378
+
379
+ const ButtonGroupComponent = React.forwardRef<
380
+ HTMLFieldSetElement,
381
+ ButtonGroupProps
382
+ >(
383
+ (
384
+ {
385
+ className,
386
+ variant = "standard",
387
+ orientation = "horizontal",
388
+ fullWidth = false,
389
+ size,
390
+ morphingWidth = true,
391
+ activeMorphing = false,
392
+ itemClassName,
393
+ expandedRatio = ButtonGroupDefaults.expandedRatio,
394
+ showCheck = false,
395
+ children,
396
+ iconBehavior,
397
+ labelBehavior,
398
+ style,
399
+ ...props
400
+ },
401
+ ref,
402
+ ) => {
403
+ const [pressedIndex, setPressedIndex] = React.useState<number | null>(null);
404
+ const [lockedGroupSize, setLockedGroupSize] = React.useState<number | null>(
405
+ null,
406
+ );
407
+ const layoutIdGroup = React.useId();
408
+ const rootRef = React.useRef<HTMLFieldSetElement | null>(null);
409
+
410
+ const setRootRef = React.useCallback(
411
+ (node: HTMLFieldSetElement | null) => {
412
+ rootRef.current = node;
413
+ if (typeof ref === "function") {
414
+ ref(node);
415
+ } else if (ref) {
416
+ (ref as React.MutableRefObject<HTMLFieldSetElement | null>).current =
417
+ node;
418
+ }
419
+ },
420
+ [ref],
421
+ );
422
+
423
+ const childrenArray = React.useMemo(
424
+ () => React.Children.toArray(children).filter(React.isValidElement),
425
+ [children],
426
+ );
427
+ const count = childrenArray.length;
428
+
429
+ const groupSizeSignature = React.useMemo(
430
+ () =>
431
+ `${childrenArray
432
+ .map((child, index) => {
433
+ const element = child as React.ReactElement<ButtonProps>;
434
+ const label =
435
+ typeof element.props.children === "string" ||
436
+ typeof element.props.children === "number"
437
+ ? element.props.children
438
+ : (element.key ?? index);
439
+ return [
440
+ element.key ?? index,
441
+ element.props.size ?? size ?? "",
442
+ label,
443
+ Boolean(element.props.icon),
444
+ element.props.className ?? "",
445
+ ].join(":");
446
+ })
447
+ .join("|")}|${variant}|${orientation}|${showCheck}`,
448
+ [childrenArray, orientation, showCheck, size, variant],
449
+ );
450
+
451
+ const groupSizeSignatureRef = React.useRef(groupSizeSignature);
452
+ React.useLayoutEffect(() => {
453
+ if (groupSizeSignatureRef.current !== groupSizeSignature) {
454
+ groupSizeSignatureRef.current = groupSizeSignature;
455
+ setLockedGroupSize(null);
456
+ }
457
+ }, [groupSizeSignature]);
458
+
459
+ const hasExplicitGroupSize =
460
+ orientation === "horizontal"
461
+ ? style?.width !== undefined || style?.inlineSize !== undefined
462
+ : style?.height !== undefined || style?.blockSize !== undefined;
463
+ const shouldLockGroupSize =
464
+ activeMorphing && !fullWidth && !hasExplicitGroupSize && count > 1;
465
+
466
+ React.useLayoutEffect(() => {
467
+ if (!shouldLockGroupSize) {
468
+ if (lockedGroupSize !== null) setLockedGroupSize(null);
469
+ return;
470
+ }
471
+ if (lockedGroupSize !== null) return;
472
+
473
+ const node = rootRef.current;
474
+ if (!node) return;
475
+
476
+ const rect = node.getBoundingClientRect();
477
+ const nextSize = orientation === "horizontal" ? rect.width : rect.height;
478
+ if (nextSize > 0) {
479
+ setLockedGroupSize(Math.ceil(nextSize * 100) / 100);
480
+ }
481
+ }, [lockedGroupSize, orientation, shouldLockGroupSize]);
482
+
483
+ const groupStyle = React.useMemo<React.CSSProperties>(() => {
484
+ const nextStyle: React.CSSProperties = { ...style };
485
+ if (shouldLockGroupSize && lockedGroupSize !== null) {
486
+ if (orientation === "horizontal") {
487
+ nextStyle.inlineSize = `${lockedGroupSize}px`;
488
+ } else {
489
+ nextStyle.blockSize = `${lockedGroupSize}px`;
490
+ }
491
+ }
492
+ return nextStyle;
493
+ }, [lockedGroupSize, orientation, shouldLockGroupSize, style]);
494
+
495
+ const hasSelected = React.useMemo(
496
+ () =>
497
+ childrenArray.some(
498
+ (child) => (child as React.ReactElement<ButtonProps>).props.selected,
499
+ ),
500
+ [childrenArray],
501
+ );
502
+
503
+ const handlePointerLeaveAndUp = React.useCallback(() => {
504
+ setPressedIndex(null);
505
+ }, []);
506
+
507
+ return (
508
+ <fieldset
509
+ ref={setRootRef}
510
+ className={cn(
511
+ "inline-flex p-0 m-0 border-none max-w-full [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
512
+ orientation === "vertical"
513
+ ? "flex-col items-stretch overflow-y-auto overflow-x-hidden"
514
+ : "flex-row overflow-x-auto overflow-y-hidden",
515
+ variant === "standard"
516
+ ? "gap-3"
517
+ : variant === "navbar"
518
+ ? "gap-1 h-full"
519
+ : "gap-0.5",
520
+ fullWidth && "w-full",
521
+ fullWidth && orientation === "vertical" && "h-full",
522
+ className,
523
+ )}
524
+ style={groupStyle}
525
+ onPointerLeave={handlePointerLeaveAndUp}
526
+ onPointerUp={handlePointerLeaveAndUp}
527
+ {...props}
528
+ >
529
+ {childrenArray.map((child, index) => {
530
+ const element = child as React.ReactElement<ButtonProps>;
531
+ const isSelected = element.props.selected === true;
532
+ const isFirst = index === 0;
533
+ const isLast = index === count - 1;
534
+ const itemSize = (size || element.props.size || "sm") as MD3Size;
535
+ const basePx = BUTTON_SIZE_TOKENS[itemSize]?.leadingSpace || "1rem";
536
+ const isPressed = pressedIndex === index;
537
+
538
+ const { icon: explicitIcon, label: explicitLabel } =
539
+ resolveIconAndLabel(
540
+ element,
541
+ isSelected,
542
+ itemSize,
543
+ variant,
544
+ showCheck,
545
+ iconBehavior,
546
+ labelBehavior,
547
+ );
548
+
549
+ const shapes = resolveConnectedShapes(
550
+ variant,
551
+ orientation,
552
+ isFirst,
553
+ isLast,
554
+ itemSize,
555
+ );
556
+
557
+ const flex = resolveFlexTargets(
558
+ variant,
559
+ orientation,
560
+ isSelected,
561
+ isPressed,
562
+ hasSelected,
563
+ count,
564
+ fullWidth,
565
+ activeMorphing,
566
+ morphingWidth,
567
+ expandedRatio,
568
+ basePx,
569
+ );
570
+
571
+ const dynamicStyle: React.CSSProperties = {
572
+ ...element.props.style,
573
+ } as React.CSSProperties;
574
+
575
+ if (flex.flexGrow !== undefined && fullWidth) {
576
+ dynamicStyle.flexBasis = 0;
577
+ dynamicStyle.flexShrink = 1;
578
+ dynamicStyle.minWidth = 0;
579
+ }
580
+
581
+ const existingAnimate =
582
+ typeof element.props.animate === "object" &&
583
+ !Array.isArray(element.props.animate)
584
+ ? element.props.animate
585
+ : {};
586
+ const existingWhileTap =
587
+ typeof element.props.whileTap === "object" &&
588
+ !Array.isArray(element.props.whileTap)
589
+ ? element.props.whileTap
590
+ : {};
591
+
592
+ const motionProps = buildMotionProps(
593
+ variant,
594
+ isSelected,
595
+ isPressed,
596
+ itemSize,
597
+ shapes,
598
+ flex,
599
+ existingAnimate as TargetAndTransition,
600
+ existingWhileTap as TargetAndTransition,
601
+ );
602
+
603
+ const connectedClasses =
604
+ variant === "connected" && isSelected ? "z-20" : "";
605
+
606
+ const clonedElement = React.cloneElement(element, {
607
+ key: element.key ?? index,
608
+ tabIndex: isFirst ? 0 : -1,
609
+ size: size || element.props.size,
610
+ icon: explicitIcon,
611
+ children: explicitLabel,
612
+ ...(variant === "connected" && {
613
+ colorStyle: element.props.colorStyle || "tonal",
614
+ selectedColorStyle: element.props.selectedColorStyle || "filled",
615
+ }),
616
+ ...(variant === "navbar" && {
617
+ colorStyle: "text",
618
+ selectedColorStyle: "text",
619
+ className: cn(
620
+ element.props.className,
621
+ "w-full! h-full! px-3! min-w-0! relative z-20",
622
+ isSelected
623
+ ? "text-m3-on-secondary-container! hover:bg-transparent! active:bg-transparent!"
624
+ : "text-m3-on-surface-variant! hover:bg-transparent! active:bg-transparent!",
625
+ itemClassName,
626
+ ),
627
+ }),
628
+ ...(variant !== "navbar" && {
629
+ className: cn(
630
+ element.props.className,
631
+ connectedClasses,
632
+ "focus-visible:z-10 hover:z-10 relative min-w-0",
633
+ orientation === "vertical" && "w-full",
634
+ itemClassName,
635
+ ),
636
+ }),
637
+ style:
638
+ variant === "navbar"
639
+ ? { ...dynamicStyle, minWidth: 0 }
640
+ : dynamicStyle,
641
+ onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => {
642
+ setPressedIndex(index);
643
+ if (element.props.onPointerDown) element.props.onPointerDown(e);
644
+ },
645
+ ...motionProps,
646
+ } as Partial<ButtonProps>);
647
+
648
+ if (variant === "navbar") {
649
+ const indicatorHeight =
650
+ NAVBAR_INDICATOR_HEIGHT_MAP[itemSize] || "h-[calc(100%-8px)]";
651
+
652
+ return (
653
+ <m.div
654
+ layout
655
+ whileTap={{ scale: 0.96 }}
656
+ transition={NAVBAR_EXPRESSIVE_SPRING}
657
+ key={element.key ?? index}
658
+ className="group relative h-full flex items-center justify-center"
659
+ >
660
+ <div
661
+ className="absolute inset-0 flex items-center justify-center pointer-events-none"
662
+ style={{ zIndex: 1 }}
663
+ >
664
+ <div
665
+ className={cn(
666
+ "absolute inset-x-0.5 bg-m3-on-surface-variant/8 opacity-0 group-hover:opacity-100 transition-opacity duration-200",
667
+ indicatorHeight,
668
+ )}
669
+ style={{ borderRadius: PILL_BORDER_RADIUS }}
670
+ />
671
+ <AnimatePresence>
672
+ {isSelected && (
673
+ <m.div
674
+ layoutId={`navbar-active-indicator-${layoutIdGroup}`}
675
+ className={cn(
676
+ "absolute inset-x-0.5 bg-m3-secondary-container",
677
+ indicatorHeight,
678
+ )}
679
+ style={{ borderRadius: PILL_BORDER_RADIUS }}
680
+ transition={NAVBAR_EXPRESSIVE_SPRING}
681
+ />
682
+ )}
683
+ </AnimatePresence>
684
+ </div>
685
+ {clonedElement}
686
+ </m.div>
687
+ );
688
+ }
689
+
690
+ return clonedElement;
691
+ })}
692
+ </fieldset>
693
+ );
694
+ },
695
+ );
696
+
697
+ ButtonGroupComponent.displayName = "ButtonGroup";
698
+
699
+ export const ButtonGroup = React.memo(ButtonGroupComponent);
@@ -0,0 +1,8 @@
1
+ export { ButtonGroup } from "./button-group";
2
+ export { ButtonGroupDefaults } from "./button-group-defaults";
3
+ export { BUTTON_GROUP_TOKENS } from "./button-group-tokens";
4
+ export type {
5
+ ButtonGroupOrientation,
6
+ ButtonGroupProps,
7
+ ButtonGroupVariant,
8
+ } from "./types";