@bug-on/md3-react 2.0.3 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +42 -0
- package/CHANGELOG.md +69 -0
- package/dist/index.css +178 -0
- package/dist/index.css.d.ts +2 -0
- package/dist/index.d.mts +6135 -0
- package/dist/index.d.ts +6135 -71
- package/dist/index.js +1688 -631
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1600 -564
- 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/plugin.d.mts +1 -0
- package/dist/plugin.d.ts +1 -0
- package/dist/plugin.js +13 -0
- package/dist/plugin.js.map +1 -0
- package/dist/plugin.mjs +3 -0
- package/dist/plugin.mjs.map +1 -0
- package/dist/typography.css.d.ts +2 -0
- package/package.json +28 -19
- package/scripts/copy-assets.js +115 -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 +195 -0
- package/src/lib/utils.ts +6 -0
- package/src/plugin.ts +12 -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 +306 -0
- package/src/ui/button.tsx +665 -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 +607 -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 +135 -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 +215 -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,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file radio-button.tsx
|
|
3
|
+
* MD3 Expressive RadioButton — single-select with RadioGroup support.
|
|
4
|
+
* Spec: https://m3.material.io/components/radio-button/overview
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { domMax, LazyMotion, m, useReducedMotion } from "motion/react";
|
|
8
|
+
import * as React from "react";
|
|
9
|
+
import { cn } from "../lib/utils";
|
|
10
|
+
import { Ripple, type RippleOrigin } from "./ripple";
|
|
11
|
+
|
|
12
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** MD3 FastEffects easing: dot grow (emphasizedAccelerate). */
|
|
15
|
+
const MD3_FAST_EFFECTS = [0.3, 0, 1, 1] as const;
|
|
16
|
+
|
|
17
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Color variant for `RadioButton`.
|
|
21
|
+
* - `"primary"` — standard selection (default)
|
|
22
|
+
* - `"error"` — error/invalid state
|
|
23
|
+
*/
|
|
24
|
+
export type RadioButtonColors = "primary" | "error";
|
|
25
|
+
|
|
26
|
+
/** Props for `RadioButton`. */
|
|
27
|
+
export interface RadioButtonProps {
|
|
28
|
+
/** Whether this radio is selected. */
|
|
29
|
+
selected?: boolean;
|
|
30
|
+
/** Initial selected state (uncontrolled). @default false */
|
|
31
|
+
defaultSelected?: boolean;
|
|
32
|
+
/** Called when user clicks. Pass `null` to disable interaction. */
|
|
33
|
+
onClick?: (() => void) | null;
|
|
34
|
+
/** Disables the radio — visual disabled state + no interaction. @default false */
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
/** Color variant. @default "primary" */
|
|
37
|
+
color?: RadioButtonColors;
|
|
38
|
+
/** Error state — changes colors to `m3-error`. @default false */
|
|
39
|
+
error?: boolean;
|
|
40
|
+
/** Adjacent label text. Renders a `<label>` wrapper. */
|
|
41
|
+
label?: string;
|
|
42
|
+
/** Value used for form submission. */
|
|
43
|
+
value?: string;
|
|
44
|
+
/** Name for grouping (used in RadioGroup context). */
|
|
45
|
+
name?: string;
|
|
46
|
+
/** ID for the hidden `<input>`. Auto-generated when `label` is set. */
|
|
47
|
+
id?: string;
|
|
48
|
+
/** Extra class names on the outermost wrapper. */
|
|
49
|
+
className?: string;
|
|
50
|
+
/** ARIA label for the radio when no visible label exists. */
|
|
51
|
+
"aria-label"?: string;
|
|
52
|
+
"aria-labelledby"?: string;
|
|
53
|
+
"aria-describedby"?: string;
|
|
54
|
+
/** Whether the radio is required for form submission. */
|
|
55
|
+
required?: boolean;
|
|
56
|
+
/** Ref to the hidden `<input type="radio">`. */
|
|
57
|
+
ref?: React.Ref<HTMLInputElement>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Props for `RadioGroup`. */
|
|
61
|
+
export interface RadioGroupProps {
|
|
62
|
+
/** The name attribute shared across all child RadioButtons. */
|
|
63
|
+
name: string;
|
|
64
|
+
/** The currently selected value (controlled). */
|
|
65
|
+
value?: string;
|
|
66
|
+
/** Default value (uncontrolled). */
|
|
67
|
+
defaultValue?: string;
|
|
68
|
+
/** Called when selection changes. */
|
|
69
|
+
onValueChange?: (value: string) => void;
|
|
70
|
+
/** Disables all radio buttons in the group. */
|
|
71
|
+
disabled?: boolean;
|
|
72
|
+
/** Error state for the entire group. */
|
|
73
|
+
error?: boolean;
|
|
74
|
+
/** Label for the group (renders as visually hidden or visible heading). */
|
|
75
|
+
label?: string;
|
|
76
|
+
/** ID of an external element that labels this group. */
|
|
77
|
+
"aria-labelledby"?: string;
|
|
78
|
+
/** Direction of layout. @default "vertical" */
|
|
79
|
+
orientation?: "horizontal" | "vertical";
|
|
80
|
+
/** Whether at least one radio in the group must be selected. */
|
|
81
|
+
required?: boolean;
|
|
82
|
+
children: React.ReactNode;
|
|
83
|
+
className?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Context ──────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
interface RadioGroupContextValue {
|
|
89
|
+
name: string;
|
|
90
|
+
selectedValue: string | undefined;
|
|
91
|
+
onValueChange: (value: string) => void;
|
|
92
|
+
disabled: boolean;
|
|
93
|
+
error: boolean;
|
|
94
|
+
required: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const RadioGroupContext = React.createContext<RadioGroupContextValue | null>(
|
|
98
|
+
null,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
/** Merges external + internal refs into a single callback ref. @internal */
|
|
104
|
+
function useMergedRef<T>(
|
|
105
|
+
externalRef: React.Ref<T> | undefined,
|
|
106
|
+
internalRef: React.RefObject<T | null>,
|
|
107
|
+
): React.RefCallback<T> {
|
|
108
|
+
return React.useCallback(
|
|
109
|
+
(node: T | null) => {
|
|
110
|
+
(internalRef as React.MutableRefObject<T | null>).current = node;
|
|
111
|
+
if (!externalRef) return;
|
|
112
|
+
if (typeof externalRef === "function") {
|
|
113
|
+
externalRef(node);
|
|
114
|
+
} else {
|
|
115
|
+
(externalRef as React.MutableRefObject<T | null>).current = node;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
[externalRef, internalRef],
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── RadioVisual ──────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
interface RadioVisualProps {
|
|
125
|
+
isSelected: boolean;
|
|
126
|
+
disabled: boolean;
|
|
127
|
+
error: boolean;
|
|
128
|
+
isHovered: boolean;
|
|
129
|
+
prefersReduced: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Animated 20×20dp radio circle (outer ring + inner dot). @internal
|
|
134
|
+
* Uses `m.circle` for Framer Motion SVG animation.
|
|
135
|
+
*/
|
|
136
|
+
const RadioVisual = React.memo(function RadioVisual({
|
|
137
|
+
isSelected,
|
|
138
|
+
disabled,
|
|
139
|
+
error,
|
|
140
|
+
isHovered,
|
|
141
|
+
prefersReduced,
|
|
142
|
+
}: RadioVisualProps) {
|
|
143
|
+
const accentColor = error
|
|
144
|
+
? "var(--color-m3-error)"
|
|
145
|
+
: "var(--color-m3-primary)";
|
|
146
|
+
|
|
147
|
+
const disabledColor = "rgba(0, 0, 0, 0.38)";
|
|
148
|
+
|
|
149
|
+
const outerStroke = disabled
|
|
150
|
+
? disabledColor
|
|
151
|
+
: isSelected
|
|
152
|
+
? accentColor
|
|
153
|
+
: isHovered
|
|
154
|
+
? "var(--color-m3-on-surface)"
|
|
155
|
+
: "var(--color-m3-on-surface-variant)";
|
|
156
|
+
|
|
157
|
+
const dotFill = disabled
|
|
158
|
+
? disabledColor
|
|
159
|
+
: isSelected
|
|
160
|
+
? accentColor
|
|
161
|
+
: "rgba(0, 0, 0, 0)";
|
|
162
|
+
|
|
163
|
+
const ringDuration = prefersReduced ? 0 : 0.15;
|
|
164
|
+
const dotDuration = prefersReduced ? 0 : isSelected ? 0.2 : 0.1;
|
|
165
|
+
const dotEase = isSelected ? MD3_FAST_EFFECTS : ("easeOut" as const);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<svg
|
|
169
|
+
viewBox="0 0 20 20"
|
|
170
|
+
width={20}
|
|
171
|
+
height={20}
|
|
172
|
+
fill="none"
|
|
173
|
+
aria-hidden="true"
|
|
174
|
+
>
|
|
175
|
+
<m.circle
|
|
176
|
+
cx={10}
|
|
177
|
+
cy={10}
|
|
178
|
+
r={9}
|
|
179
|
+
strokeWidth={2}
|
|
180
|
+
fill="none"
|
|
181
|
+
animate={{ stroke: outerStroke }}
|
|
182
|
+
transition={{ duration: ringDuration, ease: "easeOut" }}
|
|
183
|
+
/>
|
|
184
|
+
<m.circle
|
|
185
|
+
cx={10}
|
|
186
|
+
cy={10}
|
|
187
|
+
initial={{ r: 0 }}
|
|
188
|
+
animate={{ r: isSelected ? 5 : 0, fill: dotFill }}
|
|
189
|
+
transition={{
|
|
190
|
+
r: { duration: dotDuration, ease: dotEase },
|
|
191
|
+
fill: { duration: ringDuration, ease: "easeOut" },
|
|
192
|
+
}}
|
|
193
|
+
stroke="none"
|
|
194
|
+
/>
|
|
195
|
+
</svg>
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ─── RadioButton ──────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const RadioButtonComponent = React.forwardRef<
|
|
202
|
+
HTMLInputElement,
|
|
203
|
+
RadioButtonProps
|
|
204
|
+
>(
|
|
205
|
+
(
|
|
206
|
+
{
|
|
207
|
+
selected,
|
|
208
|
+
defaultSelected = false,
|
|
209
|
+
onClick,
|
|
210
|
+
disabled: disabledProp = false,
|
|
211
|
+
color,
|
|
212
|
+
error: errorProp = false,
|
|
213
|
+
label,
|
|
214
|
+
value,
|
|
215
|
+
name: nameProp,
|
|
216
|
+
id: idProp,
|
|
217
|
+
className,
|
|
218
|
+
"aria-label": ariaLabel,
|
|
219
|
+
"aria-labelledby": ariaLabelledby,
|
|
220
|
+
"aria-describedby": ariaDescribedby,
|
|
221
|
+
required: requiredProp,
|
|
222
|
+
},
|
|
223
|
+
ref,
|
|
224
|
+
) => {
|
|
225
|
+
const group = React.useContext(RadioGroupContext);
|
|
226
|
+
const prefersReduced = useReducedMotion() ?? false;
|
|
227
|
+
|
|
228
|
+
const generatedId = React.useId();
|
|
229
|
+
const inputId = idProp ?? (label ? `radio-${generatedId}` : undefined);
|
|
230
|
+
|
|
231
|
+
const name = group?.name ?? nameProp;
|
|
232
|
+
const disabled = group?.disabled || disabledProp;
|
|
233
|
+
const error = group?.error || errorProp || color === "error";
|
|
234
|
+
const required = group?.required || requiredProp;
|
|
235
|
+
|
|
236
|
+
const [internalSelected, setInternalSelected] =
|
|
237
|
+
React.useState(defaultSelected);
|
|
238
|
+
|
|
239
|
+
const isControlled = selected !== undefined;
|
|
240
|
+
const isSelected: boolean = group
|
|
241
|
+
? group.selectedValue === value
|
|
242
|
+
: isControlled
|
|
243
|
+
? (selected ?? false)
|
|
244
|
+
: internalSelected;
|
|
245
|
+
|
|
246
|
+
const [ripples, setRipples] = React.useState<RippleOrigin[]>([]);
|
|
247
|
+
const removeRipple = React.useCallback(
|
|
248
|
+
(id: number) => setRipples((prev) => prev.filter((r) => r.id !== id)),
|
|
249
|
+
[],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const onPointerDown = React.useCallback(
|
|
253
|
+
(e: React.PointerEvent<HTMLElement>) => {
|
|
254
|
+
if (disabled) return;
|
|
255
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
256
|
+
const x = e.clientX - rect.left - 4;
|
|
257
|
+
const y = e.clientY - rect.top - 4;
|
|
258
|
+
const rippleSize = Math.hypot(40, 40) * 2;
|
|
259
|
+
setRipples((prev) => [
|
|
260
|
+
...prev,
|
|
261
|
+
{ id: Date.now(), x, y, size: rippleSize },
|
|
262
|
+
]);
|
|
263
|
+
},
|
|
264
|
+
[disabled],
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const [isHovered, setIsHovered] = React.useState(false);
|
|
268
|
+
const onPointerEnter = React.useCallback(() => {
|
|
269
|
+
if (!disabled) setIsHovered(true);
|
|
270
|
+
}, [disabled]);
|
|
271
|
+
const onPointerLeave = React.useCallback(() => setIsHovered(false), []);
|
|
272
|
+
|
|
273
|
+
const handleChange = React.useCallback(
|
|
274
|
+
(_e: React.ChangeEvent<HTMLInputElement>) => {
|
|
275
|
+
if (disabled || onClick === null) return;
|
|
276
|
+
|
|
277
|
+
if (group) {
|
|
278
|
+
if (value !== undefined) group.onValueChange(value);
|
|
279
|
+
} else if (!isControlled) {
|
|
280
|
+
setInternalSelected(true);
|
|
281
|
+
onClick?.();
|
|
282
|
+
} else {
|
|
283
|
+
onClick?.();
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
[disabled, onClick, group, value, isControlled],
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
290
|
+
const mergedRef = useMergedRef(ref, inputRef);
|
|
291
|
+
|
|
292
|
+
const stateLayerBg = error ? "before:bg-m3-error" : "before:bg-m3-primary";
|
|
293
|
+
|
|
294
|
+
const stateLayerClass = cn(
|
|
295
|
+
"before:absolute before:inset-0 before:rounded-full before:pointer-events-none",
|
|
296
|
+
"before:transition-opacity before:duration-150 before:opacity-0",
|
|
297
|
+
"group-hover/radio:before:opacity-[0.08]",
|
|
298
|
+
"group-focus-within/radio:before:opacity-[0.10]",
|
|
299
|
+
"group-active/radio:before:opacity-[0.10]",
|
|
300
|
+
stateLayerBg,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const touchTargetClass = cn(
|
|
304
|
+
"relative inline-flex items-center justify-center outline-none shrink-0",
|
|
305
|
+
"w-12 h-12 group/radio cursor-pointer",
|
|
306
|
+
disabled && "pointer-events-none",
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
const stateLayerAndRipple = (
|
|
310
|
+
<div
|
|
311
|
+
className={cn(
|
|
312
|
+
"absolute flex items-center justify-center w-10 h-10 m-auto inset-0 rounded-full overflow-hidden pointer-events-none",
|
|
313
|
+
stateLayerClass,
|
|
314
|
+
)}
|
|
315
|
+
aria-hidden="true"
|
|
316
|
+
>
|
|
317
|
+
<Ripple
|
|
318
|
+
ripples={ripples}
|
|
319
|
+
onRippleDone={removeRipple}
|
|
320
|
+
disabled={disabled}
|
|
321
|
+
/>
|
|
322
|
+
</div>
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const hiddenInput = (
|
|
326
|
+
<input
|
|
327
|
+
ref={mergedRef}
|
|
328
|
+
type="radio"
|
|
329
|
+
id={inputId}
|
|
330
|
+
name={name}
|
|
331
|
+
value={value}
|
|
332
|
+
checked={isSelected}
|
|
333
|
+
disabled={disabled}
|
|
334
|
+
aria-disabled={disabled || undefined}
|
|
335
|
+
aria-label={ariaLabel}
|
|
336
|
+
aria-labelledby={ariaLabelledby}
|
|
337
|
+
aria-describedby={ariaDescribedby}
|
|
338
|
+
required={required}
|
|
339
|
+
onChange={handleChange}
|
|
340
|
+
className="sr-only"
|
|
341
|
+
/>
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const visual = (
|
|
345
|
+
<RadioVisual
|
|
346
|
+
isSelected={isSelected}
|
|
347
|
+
disabled={disabled}
|
|
348
|
+
error={error}
|
|
349
|
+
isHovered={isHovered}
|
|
350
|
+
prefersReduced={prefersReduced}
|
|
351
|
+
/>
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
if (label) {
|
|
355
|
+
return (
|
|
356
|
+
<LazyMotion features={domMax} strict>
|
|
357
|
+
<label
|
|
358
|
+
htmlFor={inputId}
|
|
359
|
+
className={cn(
|
|
360
|
+
"inline-flex items-center gap-2 cursor-pointer select-none",
|
|
361
|
+
disabled &&
|
|
362
|
+
"cursor-not-allowed opacity-[0.38] pointer-events-none",
|
|
363
|
+
className,
|
|
364
|
+
)}
|
|
365
|
+
>
|
|
366
|
+
<div
|
|
367
|
+
className={touchTargetClass}
|
|
368
|
+
onPointerDown={onPointerDown}
|
|
369
|
+
onPointerEnter={onPointerEnter}
|
|
370
|
+
onPointerLeave={onPointerLeave}
|
|
371
|
+
>
|
|
372
|
+
{stateLayerAndRipple}
|
|
373
|
+
{hiddenInput}
|
|
374
|
+
{visual}
|
|
375
|
+
</div>
|
|
376
|
+
<span className="text-sm leading-none text-m3-on-surface">
|
|
377
|
+
{label}
|
|
378
|
+
</span>
|
|
379
|
+
</label>
|
|
380
|
+
</LazyMotion>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<LazyMotion features={domMax} strict>
|
|
386
|
+
<label
|
|
387
|
+
htmlFor={inputId}
|
|
388
|
+
className={cn(touchTargetClass, className)}
|
|
389
|
+
onPointerDown={onPointerDown}
|
|
390
|
+
onPointerEnter={onPointerEnter}
|
|
391
|
+
onPointerLeave={onPointerLeave}
|
|
392
|
+
>
|
|
393
|
+
{stateLayerAndRipple}
|
|
394
|
+
{hiddenInput}
|
|
395
|
+
{visual}
|
|
396
|
+
</label>
|
|
397
|
+
</LazyMotion>
|
|
398
|
+
);
|
|
399
|
+
},
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
RadioButtonComponent.displayName = "RadioButton";
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* MD3 Expressive RadioButton component.
|
|
406
|
+
*
|
|
407
|
+
* Single-select control. Supports standalone (controlled/uncontrolled) and
|
|
408
|
+
* `RadioGroup` context. Animated per MD3 spec: inner dot radius morph,
|
|
409
|
+
* outer ring color transition, state layer, and ripple.
|
|
410
|
+
*
|
|
411
|
+
* @example
|
|
412
|
+
* ```tsx
|
|
413
|
+
* <RadioButton selected={isSelected} onClick={() => setSelected(true)} label="Option A" />
|
|
414
|
+
*
|
|
415
|
+
* <RadioGroup name="plan" value={plan} onValueChange={setPlan}>
|
|
416
|
+
* <RadioButton value="free" label="Free" />
|
|
417
|
+
* <RadioButton value="pro" label="Pro" />
|
|
418
|
+
* </RadioGroup>
|
|
419
|
+
* ```
|
|
420
|
+
* @see https://m3.material.io/components/radio-button/overview
|
|
421
|
+
*/
|
|
422
|
+
export const RadioButton = React.memo(RadioButtonComponent);
|
|
423
|
+
|
|
424
|
+
// ─── RadioGroup ────────────────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
const RadioGroupComponent = React.forwardRef<HTMLDivElement, RadioGroupProps>(
|
|
427
|
+
(
|
|
428
|
+
{
|
|
429
|
+
name,
|
|
430
|
+
value: valueProp,
|
|
431
|
+
defaultValue,
|
|
432
|
+
onValueChange,
|
|
433
|
+
disabled = false,
|
|
434
|
+
error = false,
|
|
435
|
+
label,
|
|
436
|
+
"aria-labelledby": ariaLabelledby,
|
|
437
|
+
required = false,
|
|
438
|
+
orientation = "vertical",
|
|
439
|
+
children,
|
|
440
|
+
className,
|
|
441
|
+
},
|
|
442
|
+
ref,
|
|
443
|
+
) => {
|
|
444
|
+
const [internalValue, setInternalValue] = React.useState<
|
|
445
|
+
string | undefined
|
|
446
|
+
>(defaultValue);
|
|
447
|
+
const isControlled = valueProp !== undefined;
|
|
448
|
+
const selectedValue = isControlled ? valueProp : internalValue;
|
|
449
|
+
|
|
450
|
+
const handleValueChange = React.useCallback(
|
|
451
|
+
(val: string) => {
|
|
452
|
+
if (!isControlled) setInternalValue(val);
|
|
453
|
+
onValueChange?.(val);
|
|
454
|
+
},
|
|
455
|
+
[isControlled, onValueChange],
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
const groupRef = React.useRef<HTMLDivElement>(null);
|
|
459
|
+
const mergedRef = useMergedRef(ref, groupRef);
|
|
460
|
+
|
|
461
|
+
const onKeyDown = React.useCallback(
|
|
462
|
+
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
463
|
+
if (disabled) return;
|
|
464
|
+
|
|
465
|
+
const isNext = e.key === "ArrowDown" || e.key === "ArrowRight";
|
|
466
|
+
const isPrev = e.key === "ArrowUp" || e.key === "ArrowLeft";
|
|
467
|
+
if (!isNext && !isPrev) return;
|
|
468
|
+
|
|
469
|
+
e.preventDefault();
|
|
470
|
+
|
|
471
|
+
const inputs = Array.from(
|
|
472
|
+
groupRef.current?.querySelectorAll<HTMLInputElement>(
|
|
473
|
+
'input[type="radio"]:not(:disabled)',
|
|
474
|
+
) ?? [],
|
|
475
|
+
);
|
|
476
|
+
if (inputs.length === 0) return;
|
|
477
|
+
|
|
478
|
+
const currentIdx = inputs.indexOf(
|
|
479
|
+
document.activeElement as HTMLInputElement,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
const nextIdx = isNext
|
|
483
|
+
? currentIdx < inputs.length - 1
|
|
484
|
+
? currentIdx + 1
|
|
485
|
+
: 0
|
|
486
|
+
: currentIdx > 0
|
|
487
|
+
? currentIdx - 1
|
|
488
|
+
: inputs.length - 1;
|
|
489
|
+
|
|
490
|
+
const target = inputs[nextIdx];
|
|
491
|
+
target.focus();
|
|
492
|
+
handleValueChange(target.value);
|
|
493
|
+
},
|
|
494
|
+
[disabled, handleValueChange],
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
const contextValue = React.useMemo<RadioGroupContextValue>(
|
|
498
|
+
() => ({
|
|
499
|
+
name,
|
|
500
|
+
selectedValue,
|
|
501
|
+
onValueChange: handleValueChange,
|
|
502
|
+
disabled,
|
|
503
|
+
error,
|
|
504
|
+
required,
|
|
505
|
+
}),
|
|
506
|
+
[name, selectedValue, handleValueChange, disabled, error, required],
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
return (
|
|
510
|
+
<RadioGroupContext.Provider value={contextValue}>
|
|
511
|
+
<div
|
|
512
|
+
ref={mergedRef}
|
|
513
|
+
role="radiogroup"
|
|
514
|
+
aria-label={label && !ariaLabelledby ? label : undefined}
|
|
515
|
+
aria-labelledby={ariaLabelledby}
|
|
516
|
+
aria-disabled={disabled || undefined}
|
|
517
|
+
aria-required={required || undefined}
|
|
518
|
+
className={cn(
|
|
519
|
+
"flex",
|
|
520
|
+
orientation === "horizontal" ? "flex-row gap-4" : "flex-col gap-1",
|
|
521
|
+
className,
|
|
522
|
+
)}
|
|
523
|
+
onKeyDown={onKeyDown}
|
|
524
|
+
>
|
|
525
|
+
{label && !ariaLabelledby && <span className="sr-only">{label}</span>}
|
|
526
|
+
{children}
|
|
527
|
+
</div>
|
|
528
|
+
</RadioGroupContext.Provider>
|
|
529
|
+
);
|
|
530
|
+
},
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
RadioGroupComponent.displayName = "RadioGroup";
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* MD3 Expressive RadioGroup component.
|
|
537
|
+
*
|
|
538
|
+
* Groups multiple `RadioButton` components under a shared `name` with keyboard
|
|
539
|
+
* navigation (Arrow keys with wrapping) and ARIA `radiogroup` semantics.
|
|
540
|
+
*
|
|
541
|
+
* @example
|
|
542
|
+
* ```tsx
|
|
543
|
+
* <RadioGroup name="theme" value={theme} onValueChange={setTheme} label="Theme">
|
|
544
|
+
* <RadioButton value="light" label="Light" />
|
|
545
|
+
* <RadioButton value="dark" label="Dark" />
|
|
546
|
+
* <RadioButton value="system" label="System" />
|
|
547
|
+
* </RadioGroup>
|
|
548
|
+
* ```
|
|
549
|
+
* @see https://m3.material.io/components/radio-button/overview
|
|
550
|
+
*/
|
|
551
|
+
export const RadioGroup = React.memo(RadioGroupComponent);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
import * as MotionReact from "motion/react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { Ripple } from "./ripple";
|
|
5
|
+
|
|
6
|
+
// Top-level mock is required by vitest (hoisted before any test runs)
|
|
7
|
+
vi.mock("motion/react", async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal<typeof import("motion/react")>();
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
// Default: motion is allowed
|
|
12
|
+
useReducedMotion: () => false,
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("Ripple Component", () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("when motion is allowed (default)", () => {
|
|
22
|
+
it("renders nothing without ripples passed in", () => {
|
|
23
|
+
const { container } = render(
|
|
24
|
+
<Ripple ripples={[]} onRippleDone={vi.fn()} />,
|
|
25
|
+
);
|
|
26
|
+
expect(container.textContent).toBe("");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renders ripple elements when provided", () => {
|
|
30
|
+
const ripples = [{ id: 1, x: 10, y: 10, size: 50 }];
|
|
31
|
+
const { container } = render(
|
|
32
|
+
<Ripple ripples={ripples} onRippleDone={vi.fn()} />,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const span = container.querySelector("span");
|
|
36
|
+
expect(span).toBeInTheDocument();
|
|
37
|
+
expect(span).toHaveAttribute("aria-hidden", "true");
|
|
38
|
+
expect(span?.style.left).toBe("-15px"); // x(10) - size(50)/2
|
|
39
|
+
expect(span?.style.top).toBe("-15px"); // y(10) - size(50)/2
|
|
40
|
+
expect(span?.style.width).toBe("50px");
|
|
41
|
+
expect(span?.style.height).toBe("50px");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("marks ripple span as aria-hidden for screen readers", () => {
|
|
45
|
+
const ripples = [{ id: 2, x: 0, y: 0, size: 40 }];
|
|
46
|
+
const { container } = render(
|
|
47
|
+
<Ripple ripples={ripples} onRippleDone={vi.fn()} />,
|
|
48
|
+
);
|
|
49
|
+
expect(container.querySelector("span")).toHaveAttribute(
|
|
50
|
+
"aria-hidden",
|
|
51
|
+
"true",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── A11y: prefers-reduced-motion ──────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe("when prefers-reduced-motion is active", () => {
|
|
59
|
+
it("renders nothing even when ripples are provided", () => {
|
|
60
|
+
// Override the mocked hook for this single test via spyOn
|
|
61
|
+
vi.spyOn(MotionReact, "useReducedMotion").mockReturnValue(true);
|
|
62
|
+
|
|
63
|
+
const ripples = [{ id: 99, x: 5, y: 5, size: 30 }];
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<Ripple ripples={ripples} onRippleDone={vi.fn()} />,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// When useReducedMotion returns true, Ripple renders null — no spans
|
|
69
|
+
expect(container.querySelector("span")).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|