@bug-on/md3-react 2.0.3 → 3.0.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/.turbo/turbo-build.log +33 -0
- package/CHANGELOG.md +55 -0
- package/dist/index.css.d.ts +2 -0
- package/dist/index.d.mts +6127 -0
- package/dist/index.d.ts +6127 -71
- package/dist/index.js +1653 -614
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1566 -547
- package/dist/index.mjs.map +1 -1
- package/dist/material-symbols-cdn.css.d.ts +2 -0
- package/dist/material-symbols-self-hosted.css.d.ts +2 -0
- package/dist/typography.css.d.ts +2 -0
- package/package.json +22 -19
- package/scripts/copy-assets.js +82 -0
- package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
- package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
- package/src/assets/loading-indicator.svg +19 -0
- package/src/assets/material-symbols-cdn.css +65 -0
- package/src/assets/material-symbols-self-hosted.css +90 -0
- package/src/css.d.ts +20 -0
- package/src/hooks/useClickOutside.ts +37 -0
- package/src/hooks/useMediaQuery.ts +28 -0
- package/src/hooks/useRipple.ts +88 -0
- package/src/index.css +23 -0
- package/src/index.ts +349 -0
- package/src/lib/material-symbols-preconnect.tsx +82 -0
- package/src/lib/theme-utils.ts +180 -0
- package/src/lib/utils.ts +6 -0
- package/src/test/button.test.tsx +59 -0
- package/src/test/icon.test.tsx +91 -0
- package/src/test/loading-indicator.test.tsx +128 -0
- package/src/test/progress-indicator.test.tsx +306 -0
- package/src/test/setup.ts +80 -0
- package/src/test/typography.test.tsx +206 -0
- package/src/types/index.ts +7 -0
- package/src/types/md3.ts +31 -0
- package/src/ui/Text.tsx +60 -0
- package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
- package/src/ui/app-bar/app-bar-column.tsx +99 -0
- package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
- package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
- package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
- package/src/ui/app-bar/app-bar-row.tsx +104 -0
- package/src/ui/app-bar/app-bar.test.tsx +87 -0
- package/src/ui/app-bar/app-bar.tokens.ts +223 -0
- package/src/ui/app-bar/app-bar.types.ts +441 -0
- package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
- package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
- package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
- package/src/ui/app-bar/docked-toolbar.tsx +54 -0
- package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
- package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
- package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
- package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
- package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
- package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
- package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
- package/src/ui/app-bar/search-app-bar.tsx +176 -0
- package/src/ui/app-bar/search-view.tsx +227 -0
- package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
- package/src/ui/app-bar/small-app-bar.tsx +203 -0
- package/src/ui/badge.test.tsx +345 -0
- package/src/ui/badge.tsx +282 -0
- package/src/ui/button-group.test.tsx +71 -0
- package/src/ui/button-group.tsx +350 -0
- package/src/ui/button.test.tsx +297 -0
- package/src/ui/button.tsx +669 -0
- package/src/ui/card.test.tsx +187 -0
- package/src/ui/card.tsx +259 -0
- package/src/ui/checkbox.test.tsx +423 -0
- package/src/ui/checkbox.tsx +525 -0
- package/src/ui/chip.test.tsx +292 -0
- package/src/ui/chip.tsx +548 -0
- package/src/ui/code-block.tsx +219 -0
- package/src/ui/dialog.test.tsx +300 -0
- package/src/ui/dialog.tsx +384 -0
- package/src/ui/divider.test.tsx +314 -0
- package/src/ui/divider.tsx +412 -0
- package/src/ui/drawer.tsx +240 -0
- package/src/ui/fab-menu.test.tsx +494 -0
- package/src/ui/fab-menu.tsx +739 -0
- package/src/ui/fab.test.tsx +232 -0
- package/src/ui/fab.tsx +505 -0
- package/src/ui/icon-button.test.tsx +515 -0
- package/src/ui/icon-button.tsx +525 -0
- package/src/ui/icon.test.tsx +197 -0
- package/src/ui/icon.tsx +179 -0
- package/src/ui/loading-indicator.test.tsx +73 -0
- package/src/ui/loading-indicator.tsx +312 -0
- package/src/ui/menu/context-menu.tsx +275 -0
- package/src/ui/menu/index.ts +77 -0
- package/src/ui/menu/menu-animations.ts +102 -0
- package/src/ui/menu/menu-context.tsx +99 -0
- package/src/ui/menu/menu-divider.tsx +47 -0
- package/src/ui/menu/menu-group.tsx +200 -0
- package/src/ui/menu/menu-item.tsx +294 -0
- package/src/ui/menu/menu-tokens.ts +208 -0
- package/src/ui/menu/menu-types.ts +313 -0
- package/src/ui/menu/menu.test.tsx +624 -0
- package/src/ui/menu/menu.tsx +289 -0
- package/src/ui/menu/sub-menu.tsx +223 -0
- package/src/ui/menu/vertical-menu.tsx +382 -0
- package/src/ui/navigation-rail.test.tsx +404 -0
- package/src/ui/navigation-rail.tsx +604 -0
- package/src/ui/progress-indicator/circular.tsx +248 -0
- package/src/ui/progress-indicator/hooks.ts +51 -0
- package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
- package/src/ui/progress-indicator/linear-flat.tsx +83 -0
- package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
- package/src/ui/progress-indicator/linear.tsx +143 -0
- package/src/ui/progress-indicator/types.ts +158 -0
- package/src/ui/progress-indicator/utils.ts +73 -0
- package/src/ui/radio-button.test.tsx +407 -0
- package/src/ui/radio-button.tsx +551 -0
- package/src/ui/ripple.test.tsx +72 -0
- package/src/ui/ripple.tsx +234 -0
- package/src/ui/scroll-area.test.tsx +58 -0
- package/src/ui/scroll-area.tsx +139 -0
- package/src/ui/search/animated-placeholder.tsx +145 -0
- package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
- package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
- package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
- package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
- package/src/ui/search/index.ts +44 -0
- package/src/ui/search/search-bar.tsx +220 -0
- package/src/ui/search/search-context.tsx +42 -0
- package/src/ui/search/search-view-docked.tsx +194 -0
- package/src/ui/search/search-view-fullscreen.tsx +247 -0
- package/src/ui/search/search.test.tsx +233 -0
- package/src/ui/search/search.tokens.ts +134 -0
- package/src/ui/search/search.tsx +131 -0
- package/src/ui/search/search.types.ts +154 -0
- package/src/ui/search/trailing-action.tsx +49 -0
- package/src/ui/shared/constants.ts +122 -0
- package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
- package/src/ui/slider/hooks/useSliderMath.ts +195 -0
- package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
- package/src/ui/slider/range-slider.tsx +561 -0
- package/src/ui/slider/slider-thumb.tsx +379 -0
- package/src/ui/slider/slider-track.tsx +912 -0
- package/src/ui/slider/slider.tokens.ts +189 -0
- package/src/ui/slider/slider.tsx +259 -0
- package/src/ui/slider/slider.types.ts +288 -0
- package/src/ui/snackbar/index.ts +20 -0
- package/src/ui/snackbar/snackbar.test.tsx +338 -0
- package/src/ui/snackbar/snackbar.tsx +476 -0
- package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
- package/src/ui/switch/switch.stories.tsx +309 -0
- package/src/ui/switch/switch.test.tsx +243 -0
- package/src/ui/switch/switch.tokens.ts +89 -0
- package/src/ui/switch/switch.tsx +504 -0
- package/src/ui/switch/switch.types.ts +62 -0
- package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
- package/src/ui/tabs/tab.tsx +407 -0
- package/src/ui/tabs/tabs-content.tsx +89 -0
- package/src/ui/tabs/tabs-list.tsx +146 -0
- package/src/ui/tabs/tabs.test.tsx +290 -0
- package/src/ui/tabs/tabs.tokens.ts +121 -0
- package/src/ui/tabs/tabs.tsx +229 -0
- package/src/ui/tabs/tabs.types.ts +185 -0
- package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
- package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
- package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
- package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
- package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
- package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
- package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
- package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
- package/src/ui/text-field/text-field.test.tsx +454 -0
- package/src/ui/text-field/text-field.tokens.ts +104 -0
- package/src/ui/text-field/text-field.tsx +548 -0
- package/src/ui/text-field/text-field.types.ts +180 -0
- package/src/ui/theme-provider/index.tsx +190 -0
- package/src/ui/toc.test.tsx +108 -0
- package/src/ui/toc.tsx +172 -0
- package/src/ui/tooltip/plain-tooltip.tsx +63 -0
- package/src/ui/tooltip/rich-tooltip.tsx +94 -0
- package/src/ui/tooltip/tooltip-box.tsx +266 -0
- package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
- package/src/ui/tooltip/tooltip.tokens.ts +26 -0
- package/src/ui/tooltip/tooltip.types.ts +70 -0
- package/src/ui/tooltip/use-tooltip-position.ts +208 -0
- package/src/ui/tooltip/use-tooltip-state.ts +41 -0
- package/src/ui/typography/__tests__/typography.test.tsx +170 -0
- package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
- package/src/ui/typography/type-scale-tokens.ts +205 -0
- package/src/ui/typography/typography-key-tokens.ts +43 -0
- package/src/ui/typography/typography-tokens.ts +360 -0
- package/src/ui/typography/typography.css +22 -0
- package/src/ui/typography/typography.tsx +559 -0
- package/test-render.tsx +4 -0
- package/test-shadow.html +26 -0
- package/test_output.txt +164 -0
- package/test_output_v2.txt +5 -0
- package/tsconfig.build.json +10 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +20 -0
- package/vitest.config.ts +11 -0
- package/dist/hooks/useClickOutside.d.ts +0 -8
- package/dist/hooks/useMediaQuery.d.ts +0 -11
- package/dist/hooks/useRipple.d.ts +0 -26
- package/dist/lib/material-symbols-preconnect.d.ts +0 -42
- package/dist/lib/theme-utils.d.ts +0 -63
- package/dist/lib/utils.d.ts +0 -2
- package/dist/types/index.d.ts +0 -1
- package/dist/types/md3.d.ts +0 -14
- package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
- package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
- package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
- package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
- package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
- package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
- package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
- package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
- package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
- package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
- package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
- package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
- package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
- package/dist/ui/app-bar/search-view.d.ts +0 -54
- package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
- package/dist/ui/badge.d.ts +0 -125
- package/dist/ui/button-group.d.ts +0 -59
- package/dist/ui/button.d.ts +0 -148
- package/dist/ui/card.d.ts +0 -62
- package/dist/ui/checkbox.d.ts +0 -82
- package/dist/ui/chip.d.ts +0 -110
- package/dist/ui/code-block.d.ts +0 -14
- package/dist/ui/dialog.d.ts +0 -111
- package/dist/ui/divider.d.ts +0 -164
- package/dist/ui/drawer.d.ts +0 -39
- package/dist/ui/dropdown.d.ts +0 -29
- package/dist/ui/fab-menu.d.ts +0 -204
- package/dist/ui/fab.d.ts +0 -162
- package/dist/ui/icon-button.d.ts +0 -131
- package/dist/ui/icon.d.ts +0 -88
- package/dist/ui/loading-indicator.d.ts +0 -42
- package/dist/ui/navigation-rail.d.ts +0 -29
- package/dist/ui/progress-indicator/circular.d.ts +0 -3
- package/dist/ui/progress-indicator/hooks.d.ts +0 -3
- package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
- package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
- package/dist/ui/progress-indicator/linear.d.ts +0 -3
- package/dist/ui/progress-indicator/types.d.ts +0 -151
- package/dist/ui/progress-indicator/utils.d.ts +0 -3
- package/dist/ui/radio-button.d.ts +0 -106
- package/dist/ui/ripple.d.ts +0 -126
- package/dist/ui/scroll-area.d.ts +0 -27
- package/dist/ui/search/animated-placeholder.d.ts +0 -54
- package/dist/ui/search/hooks/use-search-keyboard.d.ts +0 -32
- package/dist/ui/search/hooks/use-search-view-focus.d.ts +0 -6
- package/dist/ui/search/index.d.ts +0 -27
- package/dist/ui/search/search-bar.d.ts +0 -32
- package/dist/ui/search/search-context.d.ts +0 -24
- package/dist/ui/search/search-view-docked.d.ts +0 -25
- package/dist/ui/search/search-view-fullscreen.d.ts +0 -36
- package/dist/ui/search/search.d.ts +0 -50
- package/dist/ui/search/search.tokens.d.ts +0 -112
- package/dist/ui/search/search.types.d.ts +0 -131
- package/dist/ui/search/trailing-action.d.ts +0 -9
- package/dist/ui/shared/constants.d.ts +0 -86
- package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
- package/dist/ui/slider/range-slider.d.ts +0 -47
- package/dist/ui/slider/slider-thumb.d.ts +0 -33
- package/dist/ui/slider/slider-track.d.ts +0 -25
- package/dist/ui/slider/slider.d.ts +0 -60
- package/dist/ui/slider/slider.tokens.d.ts +0 -151
- package/dist/ui/slider/slider.types.d.ts +0 -259
- package/dist/ui/snackbar/index.d.ts +0 -6
- package/dist/ui/snackbar/snackbar.d.ts +0 -197
- package/dist/ui/switch/switch.d.ts +0 -30
- package/dist/ui/switch/switch.stories.d.ts +0 -48
- package/dist/ui/switch/switch.tokens.d.ts +0 -67
- package/dist/ui/switch/switch.types.d.ts +0 -59
- package/dist/ui/tabs/tab.d.ts +0 -43
- package/dist/ui/tabs/tabs-content.d.ts +0 -36
- package/dist/ui/tabs/tabs-list.d.ts +0 -40
- package/dist/ui/tabs/tabs.d.ts +0 -60
- package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
- package/dist/ui/tabs/tabs.types.d.ts +0 -172
- package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
- package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
- package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
- package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
- package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
- package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
- package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
- package/dist/ui/text-field/text-field.d.ts +0 -49
- package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
- package/dist/ui/text-field/text-field.types.d.ts +0 -126
- package/dist/ui/theme-provider/index.d.ts +0 -48
- package/dist/ui/toc.d.ts +0 -80
- package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
- package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
- package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
- package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
- package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
- package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
- package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
- package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
- package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
- package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
- package/dist/ui/typography/typography-tokens.d.ts +0 -220
- package/dist/ui/typography/typography.d.ts +0 -265
- /package/{dist/hooks/index.d.ts → src/hooks/index.ts} +0 -0
- /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file icon-button.tsx
|
|
3
|
+
*
|
|
4
|
+
* MD3 Expressive Icon Button component.
|
|
5
|
+
*
|
|
6
|
+
* An icon-only button with shape morphing, ripple effect, loading state,
|
|
7
|
+
* and toggle variant. Requires `aria-label` for accessibility since there
|
|
8
|
+
* is no visible text label.
|
|
9
|
+
*
|
|
10
|
+
* @see https://m3.material.io/components/icon-buttons/overview
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { HTMLMotionProps } from "motion/react";
|
|
14
|
+
import { AnimatePresence, domMax, LazyMotion, m } from "motion/react";
|
|
15
|
+
import * as React from "react";
|
|
16
|
+
import { cn } from "../lib/utils";
|
|
17
|
+
import { LoadingIndicator } from "./loading-indicator";
|
|
18
|
+
import { ProgressIndicator } from "./progress-indicator";
|
|
19
|
+
import { Ripple, useRippleState } from "./ripple";
|
|
20
|
+
import {
|
|
21
|
+
ICON_SPAN_VARIANTS,
|
|
22
|
+
SPRING_TRANSITION,
|
|
23
|
+
SPRING_TRANSITION_FAST,
|
|
24
|
+
} from "./shared/constants";
|
|
25
|
+
import { TouchTarget } from "./shared/touch-target";
|
|
26
|
+
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// Design Tokens
|
|
29
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-size container dimensions (Tailwind utility classes).
|
|
33
|
+
* MD3 Expressive sizing tokens (v14.1.0): XS=32dp, SM=40dp, MD=56dp, LG=96dp, XL=136dp.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
const SIZE_STYLES: Record<string, string> = {
|
|
37
|
+
xs: "h-8 w-8",
|
|
38
|
+
sm: "h-10 w-10",
|
|
39
|
+
md: "h-14 w-14",
|
|
40
|
+
lg: "h-24 w-24",
|
|
41
|
+
xl: "h-[8.5rem] w-[8.5rem]",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Per-size icon dimensions — Tailwind class + pixel value.
|
|
46
|
+
* Single source of truth to keep icon class and LoadingIndicator size in sync.
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
const SIZE_ICON: Record<string, { cls: string; px: number }> = {
|
|
50
|
+
xs: { cls: "size-5", px: 20 },
|
|
51
|
+
sm: { cls: "size-6", px: 24 },
|
|
52
|
+
md: { cls: "size-6", px: 24 },
|
|
53
|
+
lg: { cls: "size-8", px: 32 },
|
|
54
|
+
xl: { cls: "size-10", px: 40 },
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
58
|
+
// Shape Morphing — Border Radius Map
|
|
59
|
+
// Keys: round=CornerFull, square=CornerMedium–XL, pressed, selectedRound, selectedSquare
|
|
60
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Per-size shape morphing radius configuration for all interaction states.
|
|
64
|
+
*
|
|
65
|
+
* - `round` / `square`: idle border-radius per shape variant.
|
|
66
|
+
* - `pressed`: compressed radius on whileTap.
|
|
67
|
+
* - `selectedRound` / `selectedSquare`: radius when toggle is selected
|
|
68
|
+
* (shape flips — round selected → selectedRound which is squarer).
|
|
69
|
+
*
|
|
70
|
+
* @internal
|
|
71
|
+
*/
|
|
72
|
+
const RADIUS_MAP: Record<
|
|
73
|
+
string,
|
|
74
|
+
{
|
|
75
|
+
round: number;
|
|
76
|
+
square: number;
|
|
77
|
+
pressed: number;
|
|
78
|
+
selectedRound: number;
|
|
79
|
+
selectedSquare: number;
|
|
80
|
+
}
|
|
81
|
+
> = {
|
|
82
|
+
xs: {
|
|
83
|
+
round: 16,
|
|
84
|
+
square: 12,
|
|
85
|
+
pressed: 8,
|
|
86
|
+
selectedRound: 12,
|
|
87
|
+
selectedSquare: 16,
|
|
88
|
+
},
|
|
89
|
+
sm: {
|
|
90
|
+
round: 20,
|
|
91
|
+
square: 12,
|
|
92
|
+
pressed: 8,
|
|
93
|
+
selectedRound: 12,
|
|
94
|
+
selectedSquare: 20,
|
|
95
|
+
},
|
|
96
|
+
md: {
|
|
97
|
+
round: 28,
|
|
98
|
+
square: 16,
|
|
99
|
+
pressed: 12,
|
|
100
|
+
selectedRound: 16,
|
|
101
|
+
selectedSquare: 28,
|
|
102
|
+
},
|
|
103
|
+
lg: {
|
|
104
|
+
round: 48,
|
|
105
|
+
square: 28,
|
|
106
|
+
pressed: 16,
|
|
107
|
+
selectedRound: 28,
|
|
108
|
+
selectedSquare: 48,
|
|
109
|
+
},
|
|
110
|
+
xl: {
|
|
111
|
+
round: 68,
|
|
112
|
+
square: 28,
|
|
113
|
+
pressed: 16,
|
|
114
|
+
selectedRound: 28,
|
|
115
|
+
selectedSquare: 68,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Outline border width per size for the `outlined` color style.
|
|
121
|
+
* XS/SM/MD = 1dp, LG = 2dp, XL = 3dp.
|
|
122
|
+
* @internal
|
|
123
|
+
*/
|
|
124
|
+
const SIZE_OUTLINE_WIDTH: Record<string, string> = {
|
|
125
|
+
xs: "border",
|
|
126
|
+
sm: "border",
|
|
127
|
+
md: "border",
|
|
128
|
+
lg: "border-2",
|
|
129
|
+
xl: "border-[3px]",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
// Color Variants
|
|
134
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Color-role class pairs for each `colorStyle` × selection state combination.
|
|
138
|
+
* @internal
|
|
139
|
+
*/
|
|
140
|
+
const colorStyles = {
|
|
141
|
+
standard: {
|
|
142
|
+
default:
|
|
143
|
+
"text-m3-on-surface-variant hover:bg-m3-on-surface-variant/8 active:bg-m3-on-surface-variant/12",
|
|
144
|
+
selected: "text-m3-primary hover:bg-m3-primary/8 active:bg-m3-primary/12",
|
|
145
|
+
},
|
|
146
|
+
filled: {
|
|
147
|
+
default:
|
|
148
|
+
"bg-m3-surface-container text-m3-on-surface-variant hover:bg-m3-on-surface-variant/8 active:bg-m3-on-surface-variant/12",
|
|
149
|
+
selected:
|
|
150
|
+
"bg-m3-primary text-m3-on-primary hover:brightness-95 active:brightness-90",
|
|
151
|
+
},
|
|
152
|
+
tonal: {
|
|
153
|
+
default:
|
|
154
|
+
"bg-m3-secondary-container text-m3-on-secondary-container hover:bg-m3-on-secondary-container/8 active:bg-m3-on-secondary-container/12",
|
|
155
|
+
selected:
|
|
156
|
+
"bg-m3-secondary text-m3-on-secondary hover:brightness-95 active:brightness-90",
|
|
157
|
+
},
|
|
158
|
+
outlined: {
|
|
159
|
+
default:
|
|
160
|
+
"border-m3-outline-variant text-m3-on-surface-variant hover:bg-m3-on-surface-variant/8 active:bg-m3-on-surface-variant/12",
|
|
161
|
+
selected:
|
|
162
|
+
"bg-m3-inverse-surface text-m3-inverse-on-surface border-transparent hover:brightness-95 active:brightness-90",
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Base Tailwind classes shared by all icon button variants.
|
|
168
|
+
* Separated from CVA to keep bundle output lean (no runtime variant lookups needed here).
|
|
169
|
+
* @internal
|
|
170
|
+
*/
|
|
171
|
+
const baseIconButtonClasses = [
|
|
172
|
+
"relative shrink-0 inline-flex items-center justify-center",
|
|
173
|
+
"select-none cursor-pointer",
|
|
174
|
+
"transition-[background-color,color,border-color,box-shadow,opacity,filter] duration-200",
|
|
175
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-m3-primary focus-visible:ring-offset-2",
|
|
176
|
+
"disabled:pointer-events-none disabled:opacity-[0.38]",
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
180
|
+
// Types
|
|
181
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
type MotionButtonProps = Omit<HTMLMotionProps<"button">, "children" | "color">;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Base props for the Icon Button component.
|
|
187
|
+
*
|
|
188
|
+
* @see {@link IconButtonProps} for the full discriminated union.
|
|
189
|
+
* @see https://m3.material.io/components/icon-buttons/overview
|
|
190
|
+
*/
|
|
191
|
+
export interface BaseIconButtonProps extends MotionButtonProps {
|
|
192
|
+
/**
|
|
193
|
+
* Visual color style following MD3 color roles.
|
|
194
|
+
* @default "standard"
|
|
195
|
+
*/
|
|
196
|
+
colorStyle?: "standard" | "filled" | "tonal" | "outlined";
|
|
197
|
+
/**
|
|
198
|
+
* Button container size.
|
|
199
|
+
* Sizes: XS=32dp, SM=40dp, MD=56dp, LG=96dp, XL=136dp.
|
|
200
|
+
* @default "sm"
|
|
201
|
+
*/
|
|
202
|
+
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
|
203
|
+
/**
|
|
204
|
+
* Container shape controlling border-radius morphing.
|
|
205
|
+
* - `round`: fully circular (CornerFull).
|
|
206
|
+
* - `square`: rounded square (CornerMedium–CornerExtraLarge per size).
|
|
207
|
+
* @default "round"
|
|
208
|
+
*/
|
|
209
|
+
shape?: "round" | "square";
|
|
210
|
+
/**
|
|
211
|
+
* When `true`, replaces the icon with an animated loading indicator.
|
|
212
|
+
* Interaction is blocked and `aria-busy` is set.
|
|
213
|
+
* @default false
|
|
214
|
+
*/
|
|
215
|
+
loading?: boolean;
|
|
216
|
+
/**
|
|
217
|
+
* Spinner style shown while `loading={true}`.
|
|
218
|
+
* @default "loading-indicator"
|
|
219
|
+
*/
|
|
220
|
+
loadingVariant?: "loading-indicator" | "circular";
|
|
221
|
+
/** Icon content — typically a single SVG icon component. */
|
|
222
|
+
children: React.ReactNode;
|
|
223
|
+
/**
|
|
224
|
+
* Accessible label — **REQUIRED** because icon buttons have no visible text.
|
|
225
|
+
*
|
|
226
|
+
* @example "Close", "Add to favourites", "Toggle dark mode"
|
|
227
|
+
* @see https://m3.material.io/components/icon-buttons/accessibility
|
|
228
|
+
*/
|
|
229
|
+
"aria-label": string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Complete `IconButton` props — discriminated union that enforces
|
|
234
|
+
* `selected` is only valid with `variant="toggle"`.
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* ```tsx
|
|
238
|
+
* // Standard
|
|
239
|
+
* <IconButton aria-label="Close" onClick={handleClose}>
|
|
240
|
+
* <XIcon />
|
|
241
|
+
* </IconButton>
|
|
242
|
+
*
|
|
243
|
+
* // Toggle
|
|
244
|
+
* <IconButton
|
|
245
|
+
* variant="toggle"
|
|
246
|
+
* selected={isLiked}
|
|
247
|
+
* aria-label={isLiked ? "Unlike" : "Like"}
|
|
248
|
+
* colorStyle="filled"
|
|
249
|
+
* onClick={() => setIsLiked(!isLiked)}
|
|
250
|
+
* >
|
|
251
|
+
* <HeartIcon />
|
|
252
|
+
* </IconButton>
|
|
253
|
+
* ```
|
|
254
|
+
*
|
|
255
|
+
* @see https://m3.material.io/components/icon-buttons/overview
|
|
256
|
+
*/
|
|
257
|
+
export type IconButtonProps = BaseIconButtonProps &
|
|
258
|
+
(
|
|
259
|
+
| { variant?: "default"; selected?: never }
|
|
260
|
+
| { variant: "toggle"; selected: boolean }
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
264
|
+
// Helpers
|
|
265
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Resolves the animated border-radius based on shape and toggle state.
|
|
269
|
+
*
|
|
270
|
+
* @param radiusConfig - The radius map entry for the current size.
|
|
271
|
+
* @param shape - Current shape variant.
|
|
272
|
+
* @param isToggle - Whether the button is a toggle variant.
|
|
273
|
+
* @param isSelected - Current toggle selection state.
|
|
274
|
+
* @returns Border radius in px.
|
|
275
|
+
* @internal
|
|
276
|
+
*/
|
|
277
|
+
function resolveAnimateRadius(
|
|
278
|
+
radiusConfig: (typeof RADIUS_MAP)[string],
|
|
279
|
+
shape: "round" | "square",
|
|
280
|
+
isToggle: boolean,
|
|
281
|
+
isSelected: boolean,
|
|
282
|
+
): number {
|
|
283
|
+
if (isToggle && isSelected) {
|
|
284
|
+
return shape === "round"
|
|
285
|
+
? radiusConfig.selectedRound
|
|
286
|
+
: radiusConfig.selectedSquare;
|
|
287
|
+
}
|
|
288
|
+
return shape === "round" ? radiusConfig.round : radiusConfig.square;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Returns extra disabled-state classes specific to background-bearing color styles.
|
|
293
|
+
*
|
|
294
|
+
* `filled` and `tonal` need a dimmed background; `outlined` needs a dimmed border.
|
|
295
|
+
* `standard` only dims the icon colour (handled by global `disabled:opacity-[0.38]`).
|
|
296
|
+
*
|
|
297
|
+
* @param colorStyle - Current color style.
|
|
298
|
+
* @returns Tailwind disabled-state override classes.
|
|
299
|
+
* @internal
|
|
300
|
+
*/
|
|
301
|
+
function resolveDisabledBgClass(colorStyle: string): string {
|
|
302
|
+
if (colorStyle === "filled" || colorStyle === "tonal") {
|
|
303
|
+
return "disabled:bg-m3-on-surface/12 disabled:text-m3-on-surface/[0.38]";
|
|
304
|
+
}
|
|
305
|
+
if (colorStyle === "outlined") {
|
|
306
|
+
return "disabled:text-m3-on-surface/[0.38] disabled:border-m3-on-surface/[0.12]";
|
|
307
|
+
}
|
|
308
|
+
return "disabled:text-m3-on-surface/[0.38]";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
312
|
+
// Component
|
|
313
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
const IconButtonComponent = React.forwardRef<
|
|
316
|
+
HTMLButtonElement,
|
|
317
|
+
IconButtonProps
|
|
318
|
+
>(
|
|
319
|
+
(
|
|
320
|
+
{
|
|
321
|
+
className,
|
|
322
|
+
style,
|
|
323
|
+
variant = "default",
|
|
324
|
+
colorStyle = "standard",
|
|
325
|
+
size = "sm",
|
|
326
|
+
shape = "round",
|
|
327
|
+
selected,
|
|
328
|
+
loading = false,
|
|
329
|
+
loadingVariant = "loading-indicator",
|
|
330
|
+
children,
|
|
331
|
+
onClick,
|
|
332
|
+
onKeyDown,
|
|
333
|
+
"aria-label": ariaLabel,
|
|
334
|
+
...restProps
|
|
335
|
+
},
|
|
336
|
+
ref,
|
|
337
|
+
) => {
|
|
338
|
+
const isToggle = variant === "toggle";
|
|
339
|
+
const isSelected = isToggle && !!selected;
|
|
340
|
+
|
|
341
|
+
// Derived display values — memoized to avoid recalculation on every render
|
|
342
|
+
const resolvedColorClass = React.useMemo(
|
|
343
|
+
() =>
|
|
344
|
+
isSelected
|
|
345
|
+
? colorStyles[colorStyle].selected
|
|
346
|
+
: colorStyles[colorStyle].default,
|
|
347
|
+
[isSelected, colorStyle],
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const outlineWidthClass = React.useMemo(
|
|
351
|
+
() =>
|
|
352
|
+
colorStyle === "outlined" && !isSelected
|
|
353
|
+
? (SIZE_OUTLINE_WIDTH[size] ?? "border")
|
|
354
|
+
: "",
|
|
355
|
+
[colorStyle, isSelected, size],
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const disabledBgClass = React.useMemo(
|
|
359
|
+
() => resolveDisabledBgClass(colorStyle),
|
|
360
|
+
[colorStyle],
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
const radiusConfig = RADIUS_MAP[size] ?? RADIUS_MAP.sm;
|
|
364
|
+
const animateRadius = resolveAnimateRadius(
|
|
365
|
+
radiusConfig,
|
|
366
|
+
shape,
|
|
367
|
+
isToggle,
|
|
368
|
+
isSelected,
|
|
369
|
+
);
|
|
370
|
+
const pressedRadius = radiusConfig.pressed;
|
|
371
|
+
|
|
372
|
+
const sizeIcon = SIZE_ICON[size] ?? SIZE_ICON.sm;
|
|
373
|
+
const iconClass = sizeIcon.cls;
|
|
374
|
+
const iconPx = sizeIcon.px;
|
|
375
|
+
|
|
376
|
+
// xs/sm need 48dp touch target (WCAG 2.5.5 + MD3 a11y)
|
|
377
|
+
const needsTouchTarget = size === "xs" || size === "sm";
|
|
378
|
+
|
|
379
|
+
// ── Ripple ───────────────────────────────────────────────────────
|
|
380
|
+
const { ripples, onPointerDown, removeRipple } = useRippleState({
|
|
381
|
+
disabled: loading,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const handleClick = React.useCallback(
|
|
385
|
+
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
386
|
+
if (loading) {
|
|
387
|
+
e.preventDefault();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
onClick?.(e);
|
|
391
|
+
},
|
|
392
|
+
[loading, onClick],
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const handleKeyDown = React.useCallback(
|
|
396
|
+
(e: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
397
|
+
if (loading) return;
|
|
398
|
+
// Manually trigger click for Enter/Space — needed because JSDOM does not
|
|
399
|
+
// fire native click events from keyboard, and some custom scroll containers
|
|
400
|
+
// suppress the default browser behaviour.
|
|
401
|
+
if ((e.key === "Enter" || e.key === " ") && onClick) {
|
|
402
|
+
e.preventDefault();
|
|
403
|
+
(e.currentTarget as HTMLButtonElement).click();
|
|
404
|
+
}
|
|
405
|
+
onKeyDown?.(e);
|
|
406
|
+
},
|
|
407
|
+
[loading, onClick, onKeyDown],
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<LazyMotion features={domMax} strict>
|
|
412
|
+
<m.button
|
|
413
|
+
ref={ref}
|
|
414
|
+
type="button"
|
|
415
|
+
aria-pressed={isToggle ? isSelected : undefined}
|
|
416
|
+
aria-label={ariaLabel}
|
|
417
|
+
aria-busy={loading || undefined}
|
|
418
|
+
aria-disabled={loading || restProps.disabled}
|
|
419
|
+
onClick={handleClick}
|
|
420
|
+
onPointerDown={onPointerDown}
|
|
421
|
+
onKeyDown={handleKeyDown}
|
|
422
|
+
style={style}
|
|
423
|
+
animate={{ borderRadius: animateRadius }}
|
|
424
|
+
whileTap={{ borderRadius: pressedRadius }}
|
|
425
|
+
transition={{ borderRadius: SPRING_TRANSITION_FAST }}
|
|
426
|
+
className={cn(
|
|
427
|
+
baseIconButtonClasses,
|
|
428
|
+
resolvedColorClass,
|
|
429
|
+
outlineWidthClass,
|
|
430
|
+
disabledBgClass,
|
|
431
|
+
"overflow-hidden",
|
|
432
|
+
SIZE_STYLES[size],
|
|
433
|
+
loading && "pointer-events-none opacity-75 cursor-not-allowed",
|
|
434
|
+
className,
|
|
435
|
+
)}
|
|
436
|
+
{...restProps}
|
|
437
|
+
>
|
|
438
|
+
{/* Extended 48dp touch target for xs/sm (WCAG 2.5.5) */}
|
|
439
|
+
{needsTouchTarget && <TouchTarget />}
|
|
440
|
+
|
|
441
|
+
<Ripple ripples={ripples} onRippleDone={removeRipple} />
|
|
442
|
+
|
|
443
|
+
<AnimatePresence mode="wait" initial={false}>
|
|
444
|
+
{loading ? (
|
|
445
|
+
<m.span
|
|
446
|
+
key="loading"
|
|
447
|
+
{...ICON_SPAN_VARIANTS}
|
|
448
|
+
transition={SPRING_TRANSITION}
|
|
449
|
+
className={cn(
|
|
450
|
+
"flex items-center justify-center shrink-0",
|
|
451
|
+
iconClass,
|
|
452
|
+
)}
|
|
453
|
+
>
|
|
454
|
+
{loadingVariant === "loading-indicator" ? (
|
|
455
|
+
<LoadingIndicator
|
|
456
|
+
size={iconPx}
|
|
457
|
+
color="currentColor"
|
|
458
|
+
aria-label="Loading"
|
|
459
|
+
/>
|
|
460
|
+
) : (
|
|
461
|
+
<ProgressIndicator
|
|
462
|
+
variant="circular"
|
|
463
|
+
size={iconPx}
|
|
464
|
+
color="currentColor"
|
|
465
|
+
trackColor="transparent"
|
|
466
|
+
aria-label="Loading"
|
|
467
|
+
/>
|
|
468
|
+
)}
|
|
469
|
+
</m.span>
|
|
470
|
+
) : (
|
|
471
|
+
<m.span
|
|
472
|
+
key="content"
|
|
473
|
+
{...ICON_SPAN_VARIANTS}
|
|
474
|
+
transition={SPRING_TRANSITION}
|
|
475
|
+
aria-hidden="true"
|
|
476
|
+
className={cn(
|
|
477
|
+
"flex items-center justify-center shrink-0 [&_svg]:w-full [&_svg]:h-full [&_.md-icon]:text-[length:inherit]!",
|
|
478
|
+
iconClass,
|
|
479
|
+
)}
|
|
480
|
+
style={{ fontSize: iconPx }}
|
|
481
|
+
>
|
|
482
|
+
{children}
|
|
483
|
+
</m.span>
|
|
484
|
+
)}
|
|
485
|
+
</AnimatePresence>
|
|
486
|
+
</m.button>
|
|
487
|
+
</LazyMotion>
|
|
488
|
+
);
|
|
489
|
+
},
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
IconButtonComponent.displayName = "IconButton";
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* MD3 Expressive Icon Button.
|
|
496
|
+
*
|
|
497
|
+
* An icon-only button with spring shape morphing, ripple effect, loading state support,
|
|
498
|
+
* and an optional toggle variant. Compliant with MD3 Expressive sizing and WCAG 2.5.5
|
|
499
|
+
* (touch target minimum for XS and SM sizes).
|
|
500
|
+
*
|
|
501
|
+
* @remarks
|
|
502
|
+
* - `aria-label` is **required** — icon buttons have no visible text label.
|
|
503
|
+
* - `variant="toggle"` requires `selected: boolean`.
|
|
504
|
+
* - Touch target is automatically extended to 48dp for `xs` and `sm` sizes.
|
|
505
|
+
*
|
|
506
|
+
* @example
|
|
507
|
+
* ```tsx
|
|
508
|
+
* <IconButton aria-label="Close" onClick={handleClose}>
|
|
509
|
+
* <XIcon />
|
|
510
|
+
* </IconButton>
|
|
511
|
+
*
|
|
512
|
+
* <IconButton
|
|
513
|
+
* variant="toggle"
|
|
514
|
+
* selected={isBookmarked}
|
|
515
|
+
* aria-label={isBookmarked ? "Remove bookmark" : "Bookmark"}
|
|
516
|
+
* colorStyle="filled"
|
|
517
|
+
* onClick={toggleBookmark}
|
|
518
|
+
* >
|
|
519
|
+
* <BookmarkIcon />
|
|
520
|
+
* </IconButton>
|
|
521
|
+
* ```
|
|
522
|
+
*
|
|
523
|
+
* @see https://m3.material.io/components/icon-buttons/overview
|
|
524
|
+
*/
|
|
525
|
+
export const IconButton = React.memo(IconButtonComponent);
|