@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 checkbox.tsx
|
|
3
|
+
* MD3 Expressive Checkbox — 2-state and tri-state support.
|
|
4
|
+
* Spec: https://m3.material.io/components/checkbox/overview
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
AnimatePresence,
|
|
9
|
+
domMax,
|
|
10
|
+
LazyMotion,
|
|
11
|
+
m,
|
|
12
|
+
useReducedMotion,
|
|
13
|
+
} from "motion/react";
|
|
14
|
+
import * as React from "react";
|
|
15
|
+
import { cn } from "../lib/utils";
|
|
16
|
+
import { Ripple, type RippleOrigin } from "./ripple";
|
|
17
|
+
|
|
18
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** MD3 Standard easing: checkmark draw and path morph. */
|
|
21
|
+
const MD3_STANDARD = [0.2, 0, 0, 1] as const;
|
|
22
|
+
|
|
23
|
+
/** MD3 FastEffects easing: container fill (emphasizedAccelerate). */
|
|
24
|
+
const MD3_FAST_EFFECTS = [0.3, 0, 1, 1] as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* SVG paths share the same command count (M→L→L) for Framer Motion d-morph.
|
|
28
|
+
* viewBox: 18×18
|
|
29
|
+
*/
|
|
30
|
+
const CHECKMARK_PATH = "M 4.5 9.5 L 7.5 12.5 L 13.5 5.5";
|
|
31
|
+
const DASH_PATH = "M 4.5 9 L 9 9 L 13.5 9";
|
|
32
|
+
|
|
33
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Tri-state value: `"unchecked"` | `"checked"` | `"indeterminate"`.
|
|
37
|
+
*/
|
|
38
|
+
export type CheckboxState = "unchecked" | "checked" | "indeterminate";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Props for `Checkbox`. Supports boolean (`checked`/`onCheckedChange`)
|
|
42
|
+
* and tri-state (`state`/`onStateChange`) modes.
|
|
43
|
+
*/
|
|
44
|
+
export interface CheckboxProps {
|
|
45
|
+
/** Controlled checked value (2-state mode). */
|
|
46
|
+
checked?: boolean;
|
|
47
|
+
/** Initial value for uncontrolled mode. @default false */
|
|
48
|
+
defaultChecked?: boolean;
|
|
49
|
+
/** Forces indeterminate rendering regardless of `checked`. */
|
|
50
|
+
indeterminate?: boolean;
|
|
51
|
+
/** Fired on checked change (simple mode). Not called when disabled. */
|
|
52
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
53
|
+
|
|
54
|
+
/** Controlled tri-state value. Takes priority over `checked`/`indeterminate`. */
|
|
55
|
+
state?: CheckboxState;
|
|
56
|
+
/** Fired on tri-state change. Cycles: unchecked → checked → indeterminate. */
|
|
57
|
+
onStateChange?: (state: CheckboxState) => void;
|
|
58
|
+
|
|
59
|
+
/** Disables interaction and applies 0.38 opacity. */
|
|
60
|
+
disabled?: boolean;
|
|
61
|
+
/** Error state — changes colors to `m3-error` and sets `aria-invalid`. */
|
|
62
|
+
error?: boolean;
|
|
63
|
+
|
|
64
|
+
/** Adjacent label text. Wraps checkbox + span in `<label>`. */
|
|
65
|
+
label?: string;
|
|
66
|
+
"aria-label"?: string;
|
|
67
|
+
"aria-labelledby"?: string;
|
|
68
|
+
"aria-describedby"?: string;
|
|
69
|
+
"aria-required"?: boolean;
|
|
70
|
+
|
|
71
|
+
/** Passed to the hidden `<input>` for form submission. */
|
|
72
|
+
name?: string;
|
|
73
|
+
/** Passed to the hidden `<input>` for form submission. */
|
|
74
|
+
value?: string;
|
|
75
|
+
/** ID for the hidden `<input>`. Auto-generated when `label` is set. */
|
|
76
|
+
id?: string;
|
|
77
|
+
|
|
78
|
+
/** Extra class names on the outermost wrapper. */
|
|
79
|
+
className?: string;
|
|
80
|
+
/** Ref pointing to the hidden `<input type="checkbox">`. */
|
|
81
|
+
ref?: React.Ref<HTMLInputElement>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* `TriStateCheckbox` props — requires `state` + `onStateChange`.
|
|
86
|
+
*/
|
|
87
|
+
export interface TriStateCheckboxProps
|
|
88
|
+
extends Omit<
|
|
89
|
+
CheckboxProps,
|
|
90
|
+
"checked" | "defaultChecked" | "onCheckedChange"
|
|
91
|
+
> {
|
|
92
|
+
state: CheckboxState;
|
|
93
|
+
onStateChange: (state: CheckboxState) => void;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── State helpers ────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/** Priority: `state` prop > `indeterminate` > `checked`. @internal */
|
|
99
|
+
function resolveState(
|
|
100
|
+
checked?: boolean,
|
|
101
|
+
indeterminate?: boolean,
|
|
102
|
+
state?: CheckboxState,
|
|
103
|
+
): CheckboxState {
|
|
104
|
+
if (state !== undefined) return state;
|
|
105
|
+
if (indeterminate) return "indeterminate";
|
|
106
|
+
return checked ? "checked" : "unchecked";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Tri-state cycle: unchecked → checked → indeterminate → unchecked. */
|
|
110
|
+
const NEXT_STATE: Record<CheckboxState, CheckboxState> = {
|
|
111
|
+
unchecked: "checked",
|
|
112
|
+
checked: "indeterminate",
|
|
113
|
+
indeterminate: "unchecked",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ─── Internal subcomponents ───────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
interface CheckboxVisualProps {
|
|
119
|
+
isSelected: boolean;
|
|
120
|
+
isIndeterminate: boolean;
|
|
121
|
+
containerBg: string;
|
|
122
|
+
containerBorderColor: string;
|
|
123
|
+
containerBorderWidth: number;
|
|
124
|
+
iconColor: string;
|
|
125
|
+
svgPath: string;
|
|
126
|
+
pathLength: number;
|
|
127
|
+
fillDuration: number;
|
|
128
|
+
drawDuration: number;
|
|
129
|
+
morphDuration: number;
|
|
130
|
+
prefersReduced: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Animated 18×18dp checkbox box (container + SVG icon). @internal */
|
|
134
|
+
const CheckboxVisual = React.memo(function CheckboxVisual({
|
|
135
|
+
isSelected,
|
|
136
|
+
isIndeterminate,
|
|
137
|
+
containerBg,
|
|
138
|
+
containerBorderColor,
|
|
139
|
+
containerBorderWidth,
|
|
140
|
+
iconColor,
|
|
141
|
+
svgPath,
|
|
142
|
+
pathLength,
|
|
143
|
+
fillDuration,
|
|
144
|
+
drawDuration,
|
|
145
|
+
morphDuration,
|
|
146
|
+
prefersReduced,
|
|
147
|
+
}: CheckboxVisualProps) {
|
|
148
|
+
return (
|
|
149
|
+
<m.div
|
|
150
|
+
aria-hidden="true"
|
|
151
|
+
className="relative flex items-center justify-center w-4.5 h-4.5 rounded-sm overflow-hidden"
|
|
152
|
+
animate={{
|
|
153
|
+
backgroundColor: containerBg,
|
|
154
|
+
borderColor: containerBorderColor,
|
|
155
|
+
borderWidth: containerBorderWidth,
|
|
156
|
+
}}
|
|
157
|
+
transition={{
|
|
158
|
+
backgroundColor: {
|
|
159
|
+
duration: fillDuration,
|
|
160
|
+
ease: isSelected ? MD3_FAST_EFFECTS : "easeOut",
|
|
161
|
+
},
|
|
162
|
+
borderColor: { duration: fillDuration, ease: "easeOut" },
|
|
163
|
+
borderWidth: { duration: fillDuration, ease: "easeOut" },
|
|
164
|
+
}}
|
|
165
|
+
style={{ borderStyle: "solid" }}
|
|
166
|
+
>
|
|
167
|
+
<AnimatePresence>
|
|
168
|
+
{isSelected && (
|
|
169
|
+
<m.svg
|
|
170
|
+
key="icon"
|
|
171
|
+
viewBox="0 0 18 18"
|
|
172
|
+
fill="none"
|
|
173
|
+
strokeLinecap="round"
|
|
174
|
+
strokeLinejoin="round"
|
|
175
|
+
width={18}
|
|
176
|
+
height={18}
|
|
177
|
+
initial={{ opacity: 0 }}
|
|
178
|
+
animate={{ opacity: 1 }}
|
|
179
|
+
exit={{ opacity: 0 }}
|
|
180
|
+
transition={{ duration: prefersReduced ? 0 : 0.1 }}
|
|
181
|
+
aria-hidden="true"
|
|
182
|
+
>
|
|
183
|
+
<m.path
|
|
184
|
+
d={svgPath}
|
|
185
|
+
stroke={iconColor}
|
|
186
|
+
strokeWidth={2}
|
|
187
|
+
animate={{
|
|
188
|
+
d: svgPath,
|
|
189
|
+
pathLength: isIndeterminate ? 1 : pathLength,
|
|
190
|
+
}}
|
|
191
|
+
initial={{ pathLength: 0 }}
|
|
192
|
+
transition={{
|
|
193
|
+
d: { duration: morphDuration, ease: MD3_STANDARD },
|
|
194
|
+
pathLength: { duration: drawDuration, ease: MD3_STANDARD },
|
|
195
|
+
}}
|
|
196
|
+
/>
|
|
197
|
+
</m.svg>
|
|
198
|
+
)}
|
|
199
|
+
</AnimatePresence>
|
|
200
|
+
</m.div>
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ─── useMergedRef ─────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/** Merges external + internal refs into a single callback ref. @internal */
|
|
207
|
+
function useMergedRef<T>(
|
|
208
|
+
externalRef: React.Ref<T> | undefined,
|
|
209
|
+
internalRef: React.RefObject<T | null>,
|
|
210
|
+
): React.RefCallback<T> {
|
|
211
|
+
return React.useCallback(
|
|
212
|
+
(node: T | null) => {
|
|
213
|
+
(internalRef as React.MutableRefObject<T | null>).current = node;
|
|
214
|
+
if (!externalRef) return;
|
|
215
|
+
if (typeof externalRef === "function") {
|
|
216
|
+
externalRef(node);
|
|
217
|
+
} else {
|
|
218
|
+
(externalRef as React.MutableRefObject<T | null>).current = node;
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
[externalRef, internalRef],
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
const CheckboxComponent = React.forwardRef<HTMLInputElement, CheckboxProps>(
|
|
228
|
+
(
|
|
229
|
+
{
|
|
230
|
+
checked,
|
|
231
|
+
defaultChecked = false,
|
|
232
|
+
indeterminate = false,
|
|
233
|
+
onCheckedChange,
|
|
234
|
+
state: stateProp,
|
|
235
|
+
onStateChange,
|
|
236
|
+
disabled = false,
|
|
237
|
+
error = false,
|
|
238
|
+
label,
|
|
239
|
+
"aria-label": ariaLabel,
|
|
240
|
+
"aria-labelledby": ariaLabelledby,
|
|
241
|
+
"aria-describedby": ariaDescribedby,
|
|
242
|
+
"aria-required": ariaRequired,
|
|
243
|
+
name,
|
|
244
|
+
value,
|
|
245
|
+
id: idProp,
|
|
246
|
+
className,
|
|
247
|
+
},
|
|
248
|
+
ref,
|
|
249
|
+
) => {
|
|
250
|
+
const prefersReduced = useReducedMotion() ?? false;
|
|
251
|
+
|
|
252
|
+
const generatedId = React.useId();
|
|
253
|
+
const inputId = idProp ?? (label ? `checkbox-${generatedId}` : undefined);
|
|
254
|
+
|
|
255
|
+
const [internalState, setInternalState] = React.useState<CheckboxState>(
|
|
256
|
+
() => (defaultChecked ? "checked" : "unchecked"),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// `state` and `checked` determine controlled vs uncontrolled.
|
|
260
|
+
// `indeterminate` is visual-only and always overrides.
|
|
261
|
+
const isControlled = stateProp !== undefined || checked !== undefined;
|
|
262
|
+
const baseState = isControlled
|
|
263
|
+
? resolveState(checked, false, stateProp)
|
|
264
|
+
: internalState;
|
|
265
|
+
const effectiveState: CheckboxState = indeterminate
|
|
266
|
+
? "indeterminate"
|
|
267
|
+
: baseState;
|
|
268
|
+
|
|
269
|
+
// ── Ripple ──────────────────────────────────────────────────────────
|
|
270
|
+
const [ripples, setRipples] = React.useState<RippleOrigin[]>([]);
|
|
271
|
+
const removeRipple = React.useCallback(
|
|
272
|
+
(id: number) => setRipples((prev) => prev.filter((r) => r.id !== id)),
|
|
273
|
+
[],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const onPointerDown = React.useCallback(
|
|
277
|
+
(e: React.PointerEvent<HTMLElement>) => {
|
|
278
|
+
if (disabled) return;
|
|
279
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
280
|
+
// Ripple origin offset by 4px to simulate a 40×40 state layer inside 48×48 touch target.
|
|
281
|
+
const x = e.clientX - rect.left - 4;
|
|
282
|
+
const y = e.clientY - rect.top - 4;
|
|
283
|
+
const rippleSize = Math.hypot(40, 40) * 2;
|
|
284
|
+
setRipples((prev) => [
|
|
285
|
+
...prev,
|
|
286
|
+
{ id: Date.now(), x, y, size: rippleSize },
|
|
287
|
+
]);
|
|
288
|
+
},
|
|
289
|
+
[disabled],
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// ── Change handler ───────────────────────────────────────────────────
|
|
293
|
+
const handleChange = React.useCallback(
|
|
294
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
295
|
+
if (disabled) return;
|
|
296
|
+
|
|
297
|
+
if (stateProp !== undefined) {
|
|
298
|
+
onStateChange?.(NEXT_STATE[effectiveState]);
|
|
299
|
+
} else if (checked !== undefined) {
|
|
300
|
+
onCheckedChange?.(e.target.checked);
|
|
301
|
+
} else {
|
|
302
|
+
const next = NEXT_STATE[effectiveState];
|
|
303
|
+
setInternalState(next);
|
|
304
|
+
if (next === "indeterminate") {
|
|
305
|
+
onStateChange?.(next);
|
|
306
|
+
} else {
|
|
307
|
+
onCheckedChange?.(next === "checked");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
[
|
|
312
|
+
disabled,
|
|
313
|
+
stateProp,
|
|
314
|
+
checked,
|
|
315
|
+
effectiveState,
|
|
316
|
+
onStateChange,
|
|
317
|
+
onCheckedChange,
|
|
318
|
+
],
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
// ── Sync indeterminate DOM property ──────────────────────────────────
|
|
322
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
323
|
+
const mergedRef = useMergedRef(ref, inputRef);
|
|
324
|
+
|
|
325
|
+
React.useEffect(() => {
|
|
326
|
+
if (inputRef.current) {
|
|
327
|
+
inputRef.current.indeterminate = effectiveState === "indeterminate";
|
|
328
|
+
}
|
|
329
|
+
}, [effectiveState]);
|
|
330
|
+
|
|
331
|
+
// ── Derived visual state ─────────────────────────────────────────────
|
|
332
|
+
const isChecked = effectiveState === "checked";
|
|
333
|
+
const isIndeterminate = effectiveState === "indeterminate";
|
|
334
|
+
const isSelected = isChecked || isIndeterminate;
|
|
335
|
+
const ariaChecked = isIndeterminate ? ("mixed" as const) : isChecked;
|
|
336
|
+
|
|
337
|
+
// ── Animation values ─────────────────────────────────────────────────
|
|
338
|
+
const accentColor = error
|
|
339
|
+
? "var(--color-m3-error)"
|
|
340
|
+
: "var(--color-m3-primary)";
|
|
341
|
+
const onAccentColor = error
|
|
342
|
+
? "var(--color-m3-on-error)"
|
|
343
|
+
: "var(--color-m3-on-primary)";
|
|
344
|
+
|
|
345
|
+
// MD3 Outline color (on-surface with 38% opacity)
|
|
346
|
+
// We use a CSS variable or a static RGBA to ensure Framer Motion can animate it.
|
|
347
|
+
// Since color-mix is not animatable, we'll use the variable directly if it's a plain color,
|
|
348
|
+
// or use a fallback. Better yet, we can use the style prop for static colors
|
|
349
|
+
// and only animate opacity if needed, but here we want to animate the color itself.
|
|
350
|
+
const outlineColor = error
|
|
351
|
+
? "var(--color-m3-error)"
|
|
352
|
+
: "rgba(0, 0, 0, 0.38)"; // Standard fallback for on-surface 38%
|
|
353
|
+
|
|
354
|
+
const containerBg = isSelected ? accentColor : "rgba(0, 0, 0, 0)";
|
|
355
|
+
const containerBorderColor = isSelected ? "rgba(0, 0, 0, 0)" : outlineColor;
|
|
356
|
+
const containerBorderWidth = isSelected ? 0 : 2;
|
|
357
|
+
const iconColor = isSelected ? onAccentColor : "rgba(0, 0, 0, 0)";
|
|
358
|
+
|
|
359
|
+
const svgPath = isIndeterminate ? DASH_PATH : CHECKMARK_PATH;
|
|
360
|
+
const pathLength = isSelected ? 1 : 0;
|
|
361
|
+
|
|
362
|
+
const fillDuration = prefersReduced ? 0 : isSelected ? 0.15 : 0.1;
|
|
363
|
+
const drawDuration = prefersReduced ? 0 : isSelected ? 0.2 : 0.1;
|
|
364
|
+
const morphDuration = prefersReduced ? 0 : 0.2;
|
|
365
|
+
|
|
366
|
+
// ── State layer classes ──────────────────────────────────────────────
|
|
367
|
+
const stateLayerBg = isSelected
|
|
368
|
+
? error
|
|
369
|
+
? "before:bg-m3-error"
|
|
370
|
+
: "before:bg-m3-primary"
|
|
371
|
+
: error
|
|
372
|
+
? "before:bg-m3-error"
|
|
373
|
+
: "before:bg-m3-on-surface";
|
|
374
|
+
|
|
375
|
+
const stateLayerClass = cn(
|
|
376
|
+
"before:absolute before:inset-0 before:rounded-full before:pointer-events-none",
|
|
377
|
+
"before:transition-opacity before:duration-150 before:opacity-0",
|
|
378
|
+
"group-hover/cbx:before:opacity-[0.08]",
|
|
379
|
+
"group-focus-within/cbx:before:opacity-[0.10]",
|
|
380
|
+
"group-active/cbx:before:opacity-[0.10]",
|
|
381
|
+
stateLayerBg,
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// ── Shared visual props ──────────────────────────────────────────────
|
|
385
|
+
const visualProps: CheckboxVisualProps = {
|
|
386
|
+
isSelected,
|
|
387
|
+
isIndeterminate,
|
|
388
|
+
containerBg,
|
|
389
|
+
containerBorderColor,
|
|
390
|
+
containerBorderWidth,
|
|
391
|
+
iconColor,
|
|
392
|
+
svgPath,
|
|
393
|
+
pathLength,
|
|
394
|
+
fillDuration,
|
|
395
|
+
drawDuration,
|
|
396
|
+
morphDuration,
|
|
397
|
+
prefersReduced,
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const touchTargetClass = cn(
|
|
401
|
+
"relative inline-flex items-center justify-center outline-none shrink-0",
|
|
402
|
+
"w-12 h-12 group/cbx",
|
|
403
|
+
disabled && "pointer-events-none",
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
// ── Render ───────────────────────────────────────────────────────────
|
|
407
|
+
const stateLayerAndRipple = (
|
|
408
|
+
<div
|
|
409
|
+
className={cn(
|
|
410
|
+
"absolute flex items-center justify-center w-10 h-10 m-auto inset-0 rounded-full overflow-hidden pointer-events-none",
|
|
411
|
+
stateLayerClass,
|
|
412
|
+
)}
|
|
413
|
+
aria-hidden="true"
|
|
414
|
+
>
|
|
415
|
+
<Ripple
|
|
416
|
+
ripples={ripples}
|
|
417
|
+
onRippleDone={removeRipple}
|
|
418
|
+
disabled={disabled}
|
|
419
|
+
/>
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const hiddenInput = (
|
|
424
|
+
<input
|
|
425
|
+
ref={mergedRef}
|
|
426
|
+
type="checkbox"
|
|
427
|
+
id={inputId}
|
|
428
|
+
name={name}
|
|
429
|
+
value={value}
|
|
430
|
+
checked={isChecked}
|
|
431
|
+
disabled={disabled}
|
|
432
|
+
aria-checked={ariaChecked}
|
|
433
|
+
aria-disabled={disabled || undefined}
|
|
434
|
+
aria-invalid={error || undefined}
|
|
435
|
+
aria-label={ariaLabel}
|
|
436
|
+
aria-labelledby={ariaLabelledby}
|
|
437
|
+
aria-describedby={ariaDescribedby}
|
|
438
|
+
aria-required={ariaRequired}
|
|
439
|
+
onChange={handleChange}
|
|
440
|
+
className="sr-only"
|
|
441
|
+
/>
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
if (label) {
|
|
445
|
+
return (
|
|
446
|
+
<LazyMotion features={domMax} strict>
|
|
447
|
+
<label
|
|
448
|
+
htmlFor={inputId}
|
|
449
|
+
className={cn(
|
|
450
|
+
"inline-flex items-center gap-2 cursor-pointer select-none",
|
|
451
|
+
disabled &&
|
|
452
|
+
"cursor-not-allowed opacity-[0.38] pointer-events-none",
|
|
453
|
+
className,
|
|
454
|
+
)}
|
|
455
|
+
>
|
|
456
|
+
<div className={touchTargetClass} onPointerDown={onPointerDown}>
|
|
457
|
+
{stateLayerAndRipple}
|
|
458
|
+
{hiddenInput}
|
|
459
|
+
<CheckboxVisual {...visualProps} />
|
|
460
|
+
</div>
|
|
461
|
+
<span className="text-sm leading-none text-m3-on-surface">
|
|
462
|
+
{label}
|
|
463
|
+
</span>
|
|
464
|
+
</label>
|
|
465
|
+
</LazyMotion>
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return (
|
|
470
|
+
<LazyMotion features={domMax} strict>
|
|
471
|
+
<label
|
|
472
|
+
htmlFor={inputId}
|
|
473
|
+
className={cn(touchTargetClass, "cursor-pointer", className)}
|
|
474
|
+
onPointerDown={onPointerDown}
|
|
475
|
+
>
|
|
476
|
+
{stateLayerAndRipple}
|
|
477
|
+
{hiddenInput}
|
|
478
|
+
<CheckboxVisual {...visualProps} />
|
|
479
|
+
</label>
|
|
480
|
+
</LazyMotion>
|
|
481
|
+
);
|
|
482
|
+
},
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
CheckboxComponent.displayName = "Checkbox";
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* MD3 Expressive Checkbox component.
|
|
489
|
+
*
|
|
490
|
+
* Supports 2-state and tri-state patterns. Fully animated per MD3 spec:
|
|
491
|
+
* checkmark draw, indeterminate dash morph, container fill, state layer, and ripple.
|
|
492
|
+
*
|
|
493
|
+
* @example
|
|
494
|
+
* ```tsx
|
|
495
|
+
* <Checkbox checked={isChecked} onCheckedChange={setIsChecked} label="Accept terms" />
|
|
496
|
+
* <Checkbox state={parentState} onStateChange={setParentState} label="Select all" />
|
|
497
|
+
* <Checkbox error label="Required field" aria-describedby="err-msg" />
|
|
498
|
+
* ```
|
|
499
|
+
* @see https://m3.material.io/components/checkbox/overview
|
|
500
|
+
*/
|
|
501
|
+
export const Checkbox = React.memo(CheckboxComponent);
|
|
502
|
+
|
|
503
|
+
// ─── TriStateCheckbox ─────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
const TriStateCheckboxComponent = React.forwardRef<
|
|
506
|
+
HTMLInputElement,
|
|
507
|
+
TriStateCheckboxProps
|
|
508
|
+
>(({ state, onStateChange, ...rest }, ref) => (
|
|
509
|
+
<Checkbox ref={ref} state={state} onStateChange={onStateChange} {...rest} />
|
|
510
|
+
));
|
|
511
|
+
|
|
512
|
+
TriStateCheckboxComponent.displayName = "TriStateCheckbox";
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* MD3 Expressive Tri-State Checkbox.
|
|
516
|
+
*
|
|
517
|
+
* Convenience wrapper around `Checkbox` that enforces `state` + `onStateChange`.
|
|
518
|
+
* Ideal for parent-child selection patterns.
|
|
519
|
+
*
|
|
520
|
+
* @example
|
|
521
|
+
* ```tsx
|
|
522
|
+
* <TriStateCheckbox state={parentState} onStateChange={setParentState} label="Select all" />
|
|
523
|
+
* ```
|
|
524
|
+
*/
|
|
525
|
+
export const TriStateCheckbox = React.memo(TriStateCheckboxComponent);
|