@tinybigui/react 0.4.1 → 0.4.2

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/dist/index.cjs CHANGED
@@ -426,218 +426,206 @@ var ButtonHeadless = React.forwardRef(
426
426
  ButtonHeadless.displayName = "ButtonHeadless";
427
427
  var buttonVariants = classVarianceAuthority.cva(
428
428
  [
429
- // Base classes (always applied)
430
- "relative inline-flex items-center justify-center cursor-pointer",
431
- "overflow-hidden rounded-full font-medium",
432
- // Split MD3 transition: spatial (border-radius) uses expressive spring with overshoot;
433
- // effects (color/bg/shadow) use standard effects spring — no overshoot allowed on color.
429
+ // Layout + shape — NO overflow-hidden here (see note above)
430
+ "relative inline-flex items-center justify-center",
431
+ "rounded-full cursor-pointer select-none",
432
+ // Split MD3 transition: spatial (border-radius) expressive spring;
433
+ // effects (color/bg/shadow) standard effects spring.
434
434
  "btn-transition",
435
- "tracking-[0.1px]",
436
- // MD3 spec: +0.1px letter-spacing for label-large
437
- "focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2"
435
+ // Disabled — self-targeting data-[x]: selectors
436
+ "data-[disabled]:cursor-not-allowed data-[disabled]:pointer-events-none"
438
437
  ],
439
438
  {
440
439
  variants: {
441
440
  /**
442
- * Button variant (MD3 specification)
441
+ * Button variant (MD3 specification — strict, no color override)
442
+ *
443
+ * Elevation per state follows _md-comp-*-button.scss tokens:
444
+ * Filled/Tonal hover→level-1, focus/pressed→level-0
445
+ * Elevated base→level-1, hover→level-2, focus/pressed→level-1
446
+ * Outlined/Text no elevation
443
447
  */
444
448
  variant: {
445
- filled: "shadow-none hover:shadow-elevation-1",
446
- // MD3: gains elevation on hover
447
- outlined: "bg-transparent border border-outline-variant",
448
- tonal: "",
449
- elevated: "shadow-elevation-1 hover:shadow-elevation-2",
450
- // MD3: level 1 → level 2 on hover
451
- text: "bg-transparent"
452
- },
453
- /**
454
- * Color scheme (MD3 color roles)
455
- */
456
- color: {
457
- primary: "",
458
- secondary: "",
459
- tertiary: "",
460
- error: ""
449
+ /**
450
+ * Filled highest emphasis.
451
+ * MD3: container=primary, label=on-primary, state-layer=on-primary
452
+ * Elevation: 0 base → 1 hover → 0 focus → 0 pressed
453
+ */
454
+ filled: [
455
+ "bg-primary text-on-primary shadow-none",
456
+ // Hover: gains level-1 elevation
457
+ "group-data-[hovered]/button:shadow-elevation-1",
458
+ // Focus/pressed: shadow must explicitly return to level-0
459
+ // (doubled attribute selector → higher specificity than hover)
460
+ "group-data-[focus-visible]/button:shadow-none",
461
+ "group-data-[pressed]/button:group-data-[pressed]/button:shadow-none",
462
+ // Disabled overrides
463
+ "group-data-[disabled]/button:bg-on-surface/12",
464
+ "group-data-[disabled]/button:text-on-surface/38",
465
+ "group-data-[disabled]/button:shadow-none"
466
+ ],
467
+ /**
468
+ * Outlined — medium emphasis. Transparent with border.
469
+ * MD3: container=transparent, outline=outline, label=primary, state-layer=primary
470
+ * Elevation: always 0
471
+ */
472
+ outlined: [
473
+ "bg-transparent border border-outline text-primary",
474
+ // Disabled overrides
475
+ "group-data-[disabled]/button:border-on-surface/12",
476
+ "group-data-[disabled]/button:text-on-surface/38"
477
+ ],
478
+ /**
479
+ * Tonal — secondary emphasis.
480
+ * MD3 name: "Filled tonal". container=secondary-container, label=on-secondary-container
481
+ * Elevation: 0 base → 1 hover → 0 focus → 0 pressed
482
+ */
483
+ tonal: [
484
+ "bg-secondary-container text-on-secondary-container shadow-none",
485
+ // Hover: gains level-1 elevation (same as filled)
486
+ "group-data-[hovered]/button:shadow-elevation-1",
487
+ // Focus/pressed: return to level-0
488
+ "group-data-[focus-visible]/button:shadow-none",
489
+ "group-data-[pressed]/button:group-data-[pressed]/button:shadow-none",
490
+ // Disabled overrides
491
+ "group-data-[disabled]/button:bg-on-surface/12",
492
+ "group-data-[disabled]/button:text-on-surface/38",
493
+ "group-data-[disabled]/button:shadow-none"
494
+ ],
495
+ /**
496
+ * Elevated — separation via shadow.
497
+ * MD3: container=surface-container-low, label=primary
498
+ * Elevation: 1 base → 2 hover → 1 focus → 1 pressed
499
+ */
500
+ elevated: [
501
+ "bg-surface-container-low text-primary shadow-elevation-1",
502
+ // Hover: gains extra elevation
503
+ "group-data-[hovered]/button:shadow-elevation-2",
504
+ // Focus/pressed: return to base level-1
505
+ // (doubled selector wins over single hover selector at same cascade position)
506
+ "group-data-[focus-visible]/button:shadow-elevation-1",
507
+ "group-data-[pressed]/button:group-data-[pressed]/button:shadow-elevation-1",
508
+ // Disabled overrides
509
+ "group-data-[disabled]/button:bg-on-surface/12",
510
+ "group-data-[disabled]/button:text-on-surface/38",
511
+ "group-data-[disabled]/button:shadow-none"
512
+ ],
513
+ /**
514
+ * Text — lowest emphasis.
515
+ * MD3: container=transparent, label=primary, state-layer=primary
516
+ * Elevation: always 0
517
+ */
518
+ text: [
519
+ "bg-transparent text-primary",
520
+ // Disabled overrides
521
+ "group-data-[disabled]/button:text-on-surface/38"
522
+ ]
461
523
  },
462
524
  /**
463
525
  * Button size
526
+ * MD3 spec: small=32dp, medium=40dp, large=56dp
527
+ * Padding: small=16dp, medium=24dp, large=32dp
528
+ * Text variant uses reduced padding: small=12dp, medium=12dp
464
529
  */
465
530
  size: {
466
- small: "h-8 px-4 text-sm gap-2",
467
- medium: "h-10 px-6 text-sm gap-2",
468
- large: "h-12 px-8 text-base gap-3"
531
+ small: "h-8 px-4 gap-1 text-label-medium tracking-[0.1px]",
532
+ medium: "h-10 px-6 gap-2 text-label-large tracking-[0.1px]",
533
+ large: "h-14 px-8 gap-2 text-title-medium"
469
534
  },
470
535
  /**
471
- * Full width variant
536
+ * Full width button (spans container)
472
537
  */
473
538
  fullWidth: {
474
539
  true: "w-full",
475
540
  false: ""
476
- },
477
- /**
478
- * Disabled state (MD3 spec: container 12% opacity, content 38% opacity)
479
- */
480
- disabled: {
481
- true: [
482
- "pointer-events-none cursor-not-allowed",
483
- "bg-on-surface/12",
484
- // MD3: disabled container uses on-surface at 12%
485
- "text-on-surface/38",
486
- // MD3: disabled text/icons use on-surface at 38%
487
- "border-on-surface/12",
488
- // For outlined variant
489
- "shadow-none"
490
- // Remove elevation when disabled
491
- ],
492
- false: ""
493
- },
494
- /**
495
- * Loading state
496
- */
497
- loading: {
498
- true: "cursor-wait",
499
- false: ""
500
541
  }
501
542
  },
502
543
  /**
503
- * Compound variants - combinations of variant + color
544
+ * Compound variants for text variant reduced padding per size
545
+ * MD3: text buttons use 12dp padding (px-3) instead of standard padding
504
546
  */
505
547
  compoundVariants: [
506
- // ====================
507
- // FILLED VARIANTS
508
- // ====================
509
- {
510
- variant: "filled",
511
- color: "primary",
512
- className: "bg-primary text-on-primary"
513
- },
514
- {
515
- variant: "filled",
516
- color: "secondary",
517
- className: "bg-secondary text-on-secondary"
518
- },
519
- {
520
- variant: "filled",
521
- color: "tertiary",
522
- className: "bg-tertiary text-on-tertiary"
523
- },
524
- {
525
- variant: "filled",
526
- color: "error",
527
- className: "bg-error text-on-error"
528
- },
529
- // ====================
530
- // OUTLINED VARIANTS
531
- // ====================
532
- {
533
- variant: "outlined",
534
- color: "primary",
535
- className: "text-primary"
536
- },
537
- {
538
- variant: "outlined",
539
- color: "secondary",
540
- className: "text-secondary"
541
- },
542
- {
543
- variant: "outlined",
544
- color: "tertiary",
545
- className: "text-tertiary"
546
- },
547
- {
548
- variant: "outlined",
549
- color: "error",
550
- className: "text-error"
551
- },
552
- // ====================
553
- // TONAL VARIANTS
554
- // ====================
555
- {
556
- variant: "tonal",
557
- color: "primary",
558
- className: "bg-primary-container text-on-primary-container"
559
- },
560
- {
561
- variant: "tonal",
562
- color: "secondary",
563
- className: "bg-secondary-container text-on-secondary-container"
564
- },
565
- {
566
- variant: "tonal",
567
- color: "tertiary",
568
- className: "bg-tertiary-container text-on-tertiary-container"
569
- },
570
- {
571
- variant: "tonal",
572
- color: "error",
573
- className: "bg-error-container text-on-error-container"
574
- },
575
- // ====================
576
- // ELEVATED VARIANTS
577
- // ====================
578
- {
579
- variant: "elevated",
580
- color: "primary",
581
- className: "bg-surface-container-low text-primary"
582
- },
583
- {
584
- variant: "elevated",
585
- color: "secondary",
586
- className: "bg-surface-container-low text-secondary"
587
- },
588
- {
589
- variant: "elevated",
590
- color: "tertiary",
591
- className: "bg-surface-container-low text-tertiary"
592
- },
593
- {
594
- variant: "elevated",
595
- color: "error",
596
- className: "bg-surface-container-low text-error"
597
- },
598
- // ====================
599
- // TEXT VARIANTS
600
- // ====================
601
- {
602
- variant: "text",
603
- color: "primary",
604
- className: "text-primary hover:bg-primary/[0.08]"
605
- // MD3: text buttons gain primary color at 8% opacity on hover
606
- },
607
- {
608
- variant: "text",
609
- color: "secondary",
610
- className: "text-secondary hover:bg-secondary/[0.08]"
611
- // MD3: text buttons gain secondary color at 8% opacity on hover
612
- },
613
- {
614
- variant: "text",
615
- color: "tertiary",
616
- className: "text-tertiary hover:bg-tertiary/[0.08]"
617
- // MD3: text buttons gain tertiary color at 8% opacity on hover
618
- },
619
- {
620
- variant: "text",
621
- color: "error",
622
- className: "text-error hover:bg-error/[0.08]"
623
- // MD3: text buttons gain error color at 8% opacity on hover
624
- }
548
+ { variant: "text", size: "small", className: "px-3" },
549
+ { variant: "text", size: "medium", className: "px-3" },
550
+ { variant: "text", size: "large", className: "px-4" }
625
551
  ],
626
- /**
627
- * Default variants
628
- */
629
552
  defaultVariants: {
630
553
  variant: "filled",
631
- color: "primary",
632
554
  size: "medium",
633
- fullWidth: false,
634
- disabled: false,
635
- loading: false
555
+ fullWidth: false
636
556
  }
637
557
  }
638
558
  );
559
+ var buttonStateLayerVariants = classVarianceAuthority.cva(
560
+ [
561
+ "absolute inset-0 rounded-[inherit] overflow-hidden pointer-events-none opacity-0",
562
+ // Effects transition for opacity — standard spring, no overshoot
563
+ "transition-opacity duration-spring-standard-fast-effects ease-spring-standard-fast-effects",
564
+ // Hover: 8%
565
+ "group-data-[hovered]/button:opacity-8",
566
+ // Focus: 10%
567
+ "group-data-[focus-visible]/button:opacity-10",
568
+ // Pressed: 10%, doubled selector wins over hover
569
+ "group-data-[pressed]/button:group-data-[pressed]/button:opacity-10",
570
+ // No state layer when disabled
571
+ "group-data-[disabled]/button:hidden"
572
+ ],
573
+ {
574
+ variants: {
575
+ variant: {
576
+ filled: "bg-on-primary",
577
+ outlined: "bg-primary",
578
+ tonal: "bg-on-secondary-container",
579
+ elevated: "bg-primary",
580
+ text: "bg-primary"
581
+ }
582
+ },
583
+ defaultVariants: { variant: "filled" }
584
+ }
585
+ );
586
+ var buttonFocusRingVariants = classVarianceAuthority.cva([
587
+ "pointer-events-none absolute inset-[-3px] rounded-full",
588
+ "outline outline-2 outline-offset-0 outline-secondary",
589
+ // Effects transition — opacity change must not overshoot
590
+ "transition-opacity duration-spring-standard-fast-effects ease-spring-standard-fast-effects",
591
+ "opacity-0",
592
+ "group-data-[focus-visible]/button:opacity-100"
593
+ ]);
594
+ var buttonIconVariants = classVarianceAuthority.cva(
595
+ [
596
+ "relative z-10 inline-flex shrink-0 items-center justify-center",
597
+ "size-[18px]",
598
+ // Color transition uses effects token (no spatial overshoot on color)
599
+ "transition-colors duration-spring-standard-fast-effects ease-spring-standard-fast-effects"
600
+ ],
601
+ {
602
+ variants: {
603
+ hidden: {
604
+ true: "invisible",
605
+ false: ""
606
+ }
607
+ },
608
+ defaultVariants: { hidden: false }
609
+ }
610
+ );
611
+ var buttonLabelVariants = classVarianceAuthority.cva(["relative z-10 inline-flex items-center"]);
612
+ var QUERY = "(prefers-reduced-motion: reduce)";
613
+ function useReducedMotion() {
614
+ const [reduced, setReduced] = React.useState(() => {
615
+ if (typeof window === "undefined") return false;
616
+ return window.matchMedia(QUERY).matches;
617
+ });
618
+ React.useEffect(() => {
619
+ const mql = window.matchMedia(QUERY);
620
+ const handler = (e) => setReduced(e.matches);
621
+ mql.addEventListener("change", handler);
622
+ return () => mql.removeEventListener("change", handler);
623
+ }, []);
624
+ return reduced;
625
+ }
639
626
  function useRipple(options = {}) {
640
627
  const { disabled = false, color = "currentColor", duration = 450 } = options;
628
+ const prefersReducedMotion = useReducedMotion();
641
629
  const [ripples, setRipples] = React.useState([]);
642
630
  const rippleKeyCounter = React.useRef(0);
643
631
  const timersRef = React.useRef([]);
@@ -648,7 +636,7 @@ function useRipple(options = {}) {
648
636
  }, []);
649
637
  const onMouseDown = React.useCallback(
650
638
  (event) => {
651
- if (disabled) return;
639
+ if (disabled || prefersReducedMotion) return;
652
640
  const element = event.currentTarget;
653
641
  const rect = element.getBoundingClientRect();
654
642
  const x = event.clientX - rect.left;
@@ -664,9 +652,9 @@ function useRipple(options = {}) {
664
652
  }, duration);
665
653
  timersRef.current.push(timer);
666
654
  },
667
- [disabled, duration]
655
+ [disabled, duration, prefersReducedMotion]
668
656
  );
669
- const rippleElements = disabled ? null : /* @__PURE__ */ jsxRuntime.jsx(
657
+ const rippleElements = disabled || prefersReducedMotion ? null : /* @__PURE__ */ jsxRuntime.jsx(
670
658
  "span",
671
659
  {
672
660
  "data-ripple-container": true,
@@ -674,7 +662,7 @@ function useRipple(options = {}) {
674
662
  children: ripples.map((ripple) => /* @__PURE__ */ jsxRuntime.jsx(
675
663
  "span",
676
664
  {
677
- className: "animate-ripple absolute rounded-full opacity-12",
665
+ className: "animate-md-ripple absolute rounded-full opacity-12",
678
666
  style: {
679
667
  left: ripple.x,
680
668
  top: ripple.y,
@@ -766,7 +754,7 @@ var Spinner = () => /* @__PURE__ */ jsxRuntime.jsxs(
766
754
  {
767
755
  role: "progressbar",
768
756
  "aria-label": "Loading",
769
- className: "h-4 w-4 animate-spin",
757
+ className: "relative z-10 h-[18px] w-[18px] animate-spin",
770
758
  xmlns: "http://www.w3.org/2000/svg",
771
759
  fill: "none",
772
760
  viewBox: "0 0 24 24",
@@ -787,7 +775,6 @@ var Button = React.forwardRef(
787
775
  ({
788
776
  // Variant props (CVA)
789
777
  variant = "filled",
790
- color = "primary",
791
778
  size = "medium",
792
779
  fullWidth = false,
793
780
  // Content props
@@ -800,64 +787,84 @@ var Button = React.forwardRef(
800
787
  isDisabled = false,
801
788
  // Styling
802
789
  className,
803
- // Other props
790
+ // Other button props
804
791
  tabIndex = 0,
805
792
  type = "button",
806
- onPress,
793
+ // Passed through to ButtonHeadless → useButton
807
794
  ...props
808
795
  }, ref) => {
796
+ const buttonRef = React.useRef(null);
797
+ const resolvedRef = ref ?? buttonRef;
809
798
  const groupCtx = useOptionalButtonGroup();
810
799
  const isConnected = groupCtx?.variant === "connected";
811
- if (process.env.NODE_ENV === "development") {
812
- if (!children) {
813
- console.warn(
814
- "[Button] Button should have text content. Use IconButton for icon-only buttons."
815
- );
816
- }
817
- if (icon && trailingIcon) {
818
- console.warn("[Button] Button should have either icon or trailingIcon, not both.");
819
- }
820
- }
821
800
  const isButtonDisabled = isDisabled || loading;
801
+ const [isPressed, setIsPressed] = React.useState(false);
802
+ const handlePressStart = React.useCallback(() => setIsPressed(true), []);
803
+ const handlePressEnd = React.useCallback(() => setIsPressed(false), []);
804
+ const { isHovered, hoverProps } = reactAria.useHover({ isDisabled: isButtonDisabled });
805
+ const { isFocusVisible, focusProps } = reactAria.useFocusRing();
822
806
  const { onMouseDown: handleRipple, ripples } = useRipple({
823
807
  disabled: isButtonDisabled || disableRipple
824
808
  });
809
+ const buttonValue = props.value;
810
+ const isGroupSelected = isConnected && groupCtx && buttonValue ? groupCtx.selectedValues.has(buttonValue) : false;
825
811
  const connectedClasses = isConnected && groupCtx ? [
826
- ...getConnectedRadiusClasses(groupCtx, props?.value),
812
+ ...getConnectedRadiusClasses(groupCtx, buttonValue),
827
813
  groupCtx.enforceMinWidth ? "min-w-12" : ""
828
814
  ] : [];
815
+ const hasIcon = !!icon || !!trailingIcon;
816
+ if (process.env.NODE_ENV === "development") {
817
+ if (!children) {
818
+ console.warn(
819
+ "[Button] Button should have text content. Use IconButton for icon-only buttons."
820
+ );
821
+ }
822
+ }
829
823
  return /* @__PURE__ */ jsxRuntime.jsxs(
830
824
  ButtonHeadless,
831
825
  {
832
- ...props,
833
- ref,
826
+ ...reactAria.mergeProps(
827
+ hoverProps,
828
+ focusProps,
829
+ // Track pressed state via useButton's press lifecycle callbacks,
830
+ // rather than a separate usePress hook, to avoid event handler conflicts.
831
+ { onPressStart: handlePressStart, onPressEnd: handlePressEnd },
832
+ props
833
+ ),
834
+ ref: resolvedRef,
834
835
  type,
835
836
  isDisabled: isButtonDisabled,
836
- ...onPress && { onPress },
837
837
  tabIndex,
838
838
  onMouseDown: handleRipple,
839
+ ...getInteractionDataAttributes({
840
+ isHovered,
841
+ isFocusVisible,
842
+ isPressed,
843
+ isDisabled: isButtonDisabled
844
+ }),
839
845
  "data-variant": variant,
840
- "data-color": color,
846
+ "data-with-icon": hasIcon ? "" : void 0,
847
+ "data-loading": loading ? "" : void 0,
848
+ "data-group-selected": isGroupSelected ? "" : void 0,
841
849
  className: cn(
842
- // Apply CVA variants (includes rounded-full base)
843
- buttonVariants({
844
- variant,
845
- color,
846
- size,
847
- fullWidth,
848
- disabled: isButtonDisabled,
849
- loading
850
- }),
850
+ buttonVariants({ variant, size, fullWidth }),
851
+ // group/button: enables group-data-[x]/button child selectors in all slots
852
+ // (added here, not in CVA, following the Switch pattern)
853
+ "group/button",
854
+ // Asymmetric border-radius easing: expressive when selected, decelerate when not
855
+ isGroupSelected ? "btn-transition-selected" : "",
851
856
  ...connectedClasses,
852
857
  // User custom classes
853
858
  className
854
859
  ),
855
860
  children: [
856
861
  ripples,
857
- icon && /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn("relative z-10 inline-flex shrink-0", loading && "invisible"), children: icon }),
858
- loading && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "relative z-10", children: /* @__PURE__ */ jsxRuntime.jsx(Spinner, {}) }),
859
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "relative z-10 inline-flex items-center", children }),
860
- trailingIcon && /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn("relative z-10 inline-flex shrink-0", loading && "invisible"), children: trailingIcon })
862
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn(buttonStateLayerVariants({ variant })), "aria-hidden": "true" }),
863
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn(buttonFocusRingVariants()), "aria-hidden": "true" }),
864
+ icon && /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn(buttonIconVariants({ hidden: loading })), children: icon }),
865
+ loading && /* @__PURE__ */ jsxRuntime.jsx(Spinner, {}),
866
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn(buttonLabelVariants()), children }),
867
+ trailingIcon && /* @__PURE__ */ jsxRuntime.jsx("span", { className: cn(buttonIconVariants({ hidden: loading })), children: trailingIcon })
861
868
  ]
862
869
  }
863
870
  );
@@ -871,6 +878,7 @@ var ButtonGroupHeadless = React.forwardRef(
871
878
  size = "medium",
872
879
  shape = "round",
873
880
  selectionMode,
881
+ isDisabled = false,
874
882
  // Selection — controlled
875
883
  selectedValues: controlledValues,
876
884
  onSelectionChange: onControlledChange,
@@ -892,7 +900,7 @@ var ButtonGroupHeadless = React.forwardRef(
892
900
  const isControlled = controlledValues !== void 0;
893
901
  const selectedValues = isControlled ? controlledValues : uncontrolledValues;
894
902
  const handleSelectionChange = (value) => {
895
- if (!selectionMode) return;
903
+ if (!selectionMode || isDisabled) return;
896
904
  let nextValues;
897
905
  if (selectionMode === "multi") {
898
906
  nextValues = new Set(selectedValues);
@@ -933,6 +941,7 @@ var ButtonGroupHeadless = React.forwardRef(
933
941
  selectionMode,
934
942
  selectedValues,
935
943
  onSelectionChange: handleSelectionChange,
944
+ isDisabled,
936
945
  connectedInnerRadius: getInnerRadius(size),
937
946
  connectedOuterRadius: getOuterRadius(shape, size),
938
947
  enforceMinWidth: variant === "connected" && (size === "extra-small" || size === "small")
@@ -945,9 +954,15 @@ var ButtonGroupHeadless = React.forwardRef(
945
954
  }
946
955
  );
947
956
  ButtonGroupHeadless.displayName = "ButtonGroupHeadless";
948
- var buttonGroupVariants = classVarianceAuthority.cva(
949
- // Base classes applied to every ButtonGroup container
950
- ["items-center"],
957
+ var buttonGroupRootVariants = classVarianceAuthority.cva(
958
+ [
959
+ // Layout
960
+ "items-center",
961
+ // Spatial motion for gap changes — standard spring (calm, utility UI)
962
+ "transition-[gap] duration-spring-standard-fast-spatial ease-spring-standard-fast-spatial",
963
+ // Disabled state — self-targeting data-[x]: selector on root
964
+ "data-[disabled]:pointer-events-none data-[disabled]:opacity-38"
965
+ ],
951
966
  {
952
967
  variants: {
953
968
  /**
@@ -995,6 +1010,14 @@ var buttonGroupVariants = classVarianceAuthority.cva(
995
1010
  }
996
1011
  }
997
1012
  );
1013
+ var buttonGroupFocusRingVariants = classVarianceAuthority.cva([
1014
+ "pointer-events-none absolute inset-[-3px] rounded-[inherit]",
1015
+ "outline outline-2 outline-offset-0 outline-secondary",
1016
+ "transition-opacity duration-spring-standard-fast-effects ease-spring-standard-fast-effects",
1017
+ "opacity-0",
1018
+ "group-data-[focus-visible]/button-group:opacity-100"
1019
+ ]);
1020
+ var buttonGroupVariants = buttonGroupRootVariants;
998
1021
  var ButtonGroup = React.forwardRef(
999
1022
  ({
1000
1023
  variant = "standard",
@@ -1004,6 +1027,7 @@ var ButtonGroup = React.forwardRef(
1004
1027
  selectedValues,
1005
1028
  onSelectionChange,
1006
1029
  defaultValue,
1030
+ isDisabled = false,
1007
1031
  children,
1008
1032
  className,
1009
1033
  ...htmlProps
@@ -1020,6 +1044,13 @@ var ButtonGroup = React.forwardRef(
1020
1044
  },
1021
1045
  [ref]
1022
1046
  );
1047
+ const hasSelection = React.useMemo(() => {
1048
+ if (selectedValues) return selectedValues.size > 0;
1049
+ if (defaultValue) {
1050
+ return Array.isArray(defaultValue) ? defaultValue.length > 0 : true;
1051
+ }
1052
+ return false;
1053
+ }, [selectedValues, defaultValue]);
1023
1054
  if (process.env.NODE_ENV === "development") {
1024
1055
  const childArray = Array.isArray(children) ? children : [children];
1025
1056
  for (const child of childArray) {
@@ -1068,7 +1099,12 @@ var ButtonGroup = React.forwardRef(
1068
1099
  selectedValues,
1069
1100
  onSelectionChange,
1070
1101
  defaultValue,
1071
- className: cn(buttonGroupVariants({ variant, size }), className),
1102
+ isDisabled,
1103
+ className: cn(buttonGroupRootVariants({ variant, size }), "group/button-group", className),
1104
+ ...getInteractionDataAttributes({ isDisabled }),
1105
+ "data-connected": variant === "connected" ? "" : void 0,
1106
+ "data-has-selection": hasSelection ? "" : void 0,
1107
+ "data-selection-mode": selectionMode ?? void 0,
1072
1108
  children
1073
1109
  }
1074
1110
  );
@@ -1128,10 +1164,12 @@ var iconButtonVariants = classVarianceAuthority.cva(
1128
1164
  "relative inline-flex items-center justify-center cursor-pointer",
1129
1165
  "overflow-hidden rounded-full",
1130
1166
  // Circular shape
1131
- // Spatial (border-radius, transform): expressive fast spring 350ms, visible overshoot
1132
- "transition-all duration-expressive-fast-spatial ease-expressive-fast-spatial",
1167
+ // Split MD3 transition: btn-transition handles spatial (border-radius) with asymmetric
1168
+ // easing (decelerate by default, switched to expressive via btn-transition-selected when
1169
+ // the button is group-selected) and effects (color/bg/shadow) with standard spring.
1170
+ "btn-transition",
1133
1171
  "focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2",
1134
- // State layers — effects token: opacity only, no overshoot
1172
+ // State layers — effects token: opacity only, no overshoot (separate ::before pseudo-element)
1135
1173
  "before:absolute before:inset-0 before:rounded-[inherit]",
1136
1174
  "before:transition-opacity before:duration-spring-standard-fast-effects before:ease-spring-standard-fast-effects",
1137
1175
  "before:bg-current before:opacity-0",
@@ -1368,6 +1406,7 @@ var IconButton = React.forwardRef(
1368
1406
  onMouseDown: mergedOnMouseDown,
1369
1407
  isDisabled
1370
1408
  });
1409
+ const isGroupSelected = isConnected && groupCtx && value ? groupCtx.selectedValues.has(value) : false;
1371
1410
  const connectedClasses = isConnected && groupCtx ? [
1372
1411
  ...getConnectedRadiusClasses(groupCtx, value),
1373
1412
  groupCtx.enforceMinWidth ? "min-w-12" : ""
@@ -1377,22 +1416,13 @@ var IconButton = React.forwardRef(
1377
1416
  {
1378
1417
  ref,
1379
1418
  className: cn(
1380
- // Base classes
1381
- "relative inline-flex items-center justify-center",
1382
- "overflow-hidden rounded-full",
1383
- // Circular shape (overridden by connected group classes)
1384
- // Spatial (border-radius, transform): expressive fast spring — 350ms, visible overshoot
1385
- "duration-expressive-fast-spatial ease-expressive-fast-spatial transition-all",
1386
- "focus-visible:outline-primary focus-visible:outline-2 focus-visible:outline-offset-2",
1387
- // State layers (hover, focus, active) — effects token: opacity, no overshoot
1388
- "before:absolute before:inset-0 before:rounded-[inherit]",
1389
- "before:duration-spring-standard-fast-effects before:ease-spring-standard-fast-effects before:transition-opacity",
1390
- "before:bg-current before:opacity-0",
1391
- "hover:before:opacity-8",
1392
- "focus-visible:before:opacity-12",
1393
- "active:before:opacity-12",
1394
- // CVA variants
1419
+ // CVA variants — includes btn-transition for asymmetric border-radius easing
1395
1420
  iconButtonVariants({ variant, color, size, selected: selected ?? false, isDisabled }),
1421
+ // Asymmetric border-radius easing: expressive when selected, decelerate when not.
1422
+ // btn-transition-selected overrides --_btn-radius-easing to the bouncy spring while
1423
+ // the button is gaining the pill shape; removal restores decelerate for the return
1424
+ // path, preventing the overshoot-to-0px sharp-corner flash.
1425
+ isGroupSelected ? "btn-transition-selected" : "",
1396
1426
  ...connectedClasses,
1397
1427
  // User custom classes
1398
1428
  className
@@ -1400,6 +1430,7 @@ var IconButton = React.forwardRef(
1400
1430
  "aria-label": ariaLabel,
1401
1431
  "data-variant": variant,
1402
1432
  "data-color": color,
1433
+ "data-group-selected": isGroupSelected ? "" : void 0,
1403
1434
  ...selected !== void 0 && { selected },
1404
1435
  ...title && { title },
1405
1436
  ...mergedPropsValue,
@@ -4791,20 +4822,6 @@ var BadgeContent = React.forwardRef(
4791
4822
  }
4792
4823
  );
4793
4824
  BadgeContent.displayName = "BadgeContent";
4794
- var QUERY = "(prefers-reduced-motion: reduce)";
4795
- function useReducedMotion() {
4796
- const [reduced, setReduced] = React.useState(() => {
4797
- if (typeof window === "undefined") return false;
4798
- return window.matchMedia(QUERY).matches;
4799
- });
4800
- React.useEffect(() => {
4801
- const mql = window.matchMedia(QUERY);
4802
- const handler = (e) => setReduced(e.matches);
4803
- mql.addEventListener("change", handler);
4804
- return () => mql.removeEventListener("change", handler);
4805
- }, []);
4806
- return reduced;
4807
- }
4808
4825
  var Badge = React.forwardRef(
4809
4826
  ({
4810
4827
  count,
@@ -14924,6 +14941,8 @@ exports.bottomSheetHandlePillVariants = bottomSheetHandlePillVariants;
14924
14941
  exports.bottomSheetHandleWrapperVariants = bottomSheetHandleWrapperVariants;
14925
14942
  exports.bottomSheetScrimVariants = bottomSheetScrimVariants;
14926
14943
  exports.bottomSheetVariants = bottomSheetVariants;
14944
+ exports.buttonGroupFocusRingVariants = buttonGroupFocusRingVariants;
14945
+ exports.buttonGroupRootVariants = buttonGroupRootVariants;
14927
14946
  exports.buttonGroupVariants = buttonGroupVariants;
14928
14947
  exports.calendarCellVariants = calendarCellVariants;
14929
14948
  exports.cardVariants = cardVariants;