@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,345 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { render, screen } from "@testing-library/react";
|
|
4
|
+
import * as MotionReact from "motion/react";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Badge, BadgedBox } from "./badge";
|
|
7
|
+
|
|
8
|
+
// Mock motion/react – same pattern as chip.test.tsx
|
|
9
|
+
vi.mock("motion/react", async (importOriginal) => {
|
|
10
|
+
const actual = await importOriginal<typeof import("motion/react")>();
|
|
11
|
+
return {
|
|
12
|
+
...actual,
|
|
13
|
+
useReducedMotion: () => false,
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const TestIcon = () => (
|
|
20
|
+
<svg data-testid="test-icon" aria-hidden="true" viewBox="0 0 24 24" />
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
// ── Test Suites ───────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe("Badge", () => {
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── Rendering ──────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe("rendering", () => {
|
|
33
|
+
it("renders as small dot when no children provided", () => {
|
|
34
|
+
const { container } = render(<Badge />);
|
|
35
|
+
const badge = container.firstChild as HTMLElement;
|
|
36
|
+
expect(badge).toBeInTheDocument();
|
|
37
|
+
// Small dot: aria-hidden (decorative)
|
|
38
|
+
expect(badge).toHaveAttribute("aria-hidden", "true");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders with text content when children provided", () => {
|
|
42
|
+
render(<Badge>3</Badge>);
|
|
43
|
+
expect(screen.getByRole("status")).toHaveTextContent("3");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("applies correct size classes for small variant (6x6px)", () => {
|
|
47
|
+
const { container } = render(<Badge />);
|
|
48
|
+
const badge = container.firstChild as HTMLElement;
|
|
49
|
+
// Tailwind shorthand: w-1.5 = 6px, h-1.5 = 6px
|
|
50
|
+
expect(badge.className).toContain("w-1.5");
|
|
51
|
+
expect(badge.className).toContain("h-1.5");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("applies correct size classes for large variant (min 16px)", () => {
|
|
55
|
+
const { container } = render(<Badge>3</Badge>);
|
|
56
|
+
const badge = container.firstChild as HTMLElement;
|
|
57
|
+
// Tailwind shorthand: min-w-4 = 16px, h-4 = 16px
|
|
58
|
+
expect(badge.className).toContain("min-w-4");
|
|
59
|
+
expect(badge.className).toContain("h-4");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── Content truncation ─────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
describe("content truncation", () => {
|
|
66
|
+
it("displays exact number when below max", () => {
|
|
67
|
+
render(<Badge max={99}>42</Badge>);
|
|
68
|
+
expect(screen.getByRole("status")).toHaveTextContent("42");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("displays '{max}+' when number exceeds max prop", () => {
|
|
72
|
+
render(<Badge max={99}>150</Badge>);
|
|
73
|
+
expect(screen.getByRole("status")).toHaveTextContent("99+");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("defaults to showing full number when max not set", () => {
|
|
77
|
+
render(<Badge>9999</Badge>);
|
|
78
|
+
expect(screen.getByRole("status")).toHaveTextContent("9999");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("truncates string to 4 characters maximum including +", () => {
|
|
82
|
+
render(<Badge>HELLO</Badge>);
|
|
83
|
+
// "HELLO" → 5 chars → truncated to "HELL"
|
|
84
|
+
expect(screen.getByRole("status")).toHaveTextContent("HELL");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not truncate strings of 4 chars or fewer", () => {
|
|
88
|
+
render(<Badge>NEW</Badge>);
|
|
89
|
+
expect(screen.getByRole("status")).toHaveTextContent("NEW");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("displays '99+' when value is exactly max+1", () => {
|
|
93
|
+
render(<Badge max={99}>100</Badge>);
|
|
94
|
+
expect(screen.getByRole("status")).toHaveTextContent("99+");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── Styling ────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe("styling", () => {
|
|
101
|
+
it("applies MD3 error color as default container color for small badge", () => {
|
|
102
|
+
const { container } = render(<Badge />);
|
|
103
|
+
const badge = container.firstChild as HTMLElement;
|
|
104
|
+
expect(badge.className).toContain("bg-m3-error");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("applies MD3 error color as default container color for large badge", () => {
|
|
108
|
+
const { container } = render(<Badge>3</Badge>);
|
|
109
|
+
const badge = container.firstChild as HTMLElement;
|
|
110
|
+
expect(badge.className).toContain("bg-m3-error");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("applies MD3 onError color as default content color", () => {
|
|
114
|
+
const { container } = render(<Badge>3</Badge>);
|
|
115
|
+
const badge = container.firstChild as HTMLElement;
|
|
116
|
+
expect(badge.className).toContain("text-m3-on-error");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("accepts custom containerColor prop via inline style", () => {
|
|
120
|
+
const { container } = render(<Badge containerColor="blue">3</Badge>);
|
|
121
|
+
const badge = container.firstChild as HTMLElement;
|
|
122
|
+
expect(badge.style.backgroundColor).toBe("blue");
|
|
123
|
+
// Should NOT apply default bg class when override is provided
|
|
124
|
+
expect(badge.className).not.toContain("bg-m3-error");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("accepts custom contentColor prop via inline style", () => {
|
|
128
|
+
const { container } = render(<Badge contentColor="white">3</Badge>);
|
|
129
|
+
const badge = container.firstChild as HTMLElement;
|
|
130
|
+
expect(badge.style.color).toBe("white");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("applies rounded-full shape (MD3 CornerFull = 9999px)", () => {
|
|
134
|
+
const { container } = render(<Badge>3</Badge>);
|
|
135
|
+
const badge = container.firstChild as HTMLElement;
|
|
136
|
+
expect(badge.className).toContain("rounded-full");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ── Accessibility ──────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
describe("accessibility", () => {
|
|
143
|
+
it("has role='status' for screen reader announcements when content present", () => {
|
|
144
|
+
render(<Badge>3</Badge>);
|
|
145
|
+
expect(screen.getByRole("status")).toBeInTheDocument();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("renders aria-hidden='true' for small dot badge (decorative)", () => {
|
|
149
|
+
const { container } = render(<Badge />);
|
|
150
|
+
const badge = container.firstChild as HTMLElement;
|
|
151
|
+
expect(badge).toHaveAttribute("aria-hidden", "true");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("renders aria-label with content value for large badge", () => {
|
|
155
|
+
render(<Badge>3</Badge>);
|
|
156
|
+
expect(screen.getByRole("status")).toHaveAttribute("aria-label", "3");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("renders aria-label with max+ when number exceeds max", () => {
|
|
160
|
+
render(<Badge max={99}>150</Badge>);
|
|
161
|
+
expect(screen.getByRole("status")).toHaveAttribute("aria-label", "99+");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("accepts explicit aria-label prop (overrides default)", () => {
|
|
165
|
+
render(<Badge aria-label="3 notifications">3</Badge>);
|
|
166
|
+
expect(screen.getByRole("status")).toHaveAttribute(
|
|
167
|
+
"aria-label",
|
|
168
|
+
"3 notifications",
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("small badge with explicit aria-label gets role='status'", () => {
|
|
173
|
+
render(<Badge aria-label="New notification" />);
|
|
174
|
+
expect(screen.getByRole("status")).toBeInTheDocument();
|
|
175
|
+
expect(screen.getByRole("status")).toHaveAttribute(
|
|
176
|
+
"aria-label",
|
|
177
|
+
"New notification",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ── className merging ──────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
describe("className merging", () => {
|
|
185
|
+
it("merges additional className with base classes", () => {
|
|
186
|
+
const { container } = render(
|
|
187
|
+
<Badge className="my-custom-class">3</Badge>,
|
|
188
|
+
);
|
|
189
|
+
const badge = container.firstChild as HTMLElement;
|
|
190
|
+
expect(badge).toHaveClass("my-custom-class");
|
|
191
|
+
// Still has base classes
|
|
192
|
+
expect(badge.className).toContain("rounded-full");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("merges additional className for small dot badge", () => {
|
|
196
|
+
const { container } = render(<Badge className="dot-extra" />);
|
|
197
|
+
const badge = container.firstChild as HTMLElement;
|
|
198
|
+
expect(badge).toHaveClass("dot-extra");
|
|
199
|
+
expect(badge.className).toContain("w-1.5");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// ── forwardRef ─────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
describe("forwardRef", () => {
|
|
206
|
+
it("forwards ref to underlying span element", () => {
|
|
207
|
+
const ref = { current: null };
|
|
208
|
+
render(<Badge ref={ref}>3</Badge>);
|
|
209
|
+
expect(ref.current).not.toBeNull();
|
|
210
|
+
expect((ref.current as unknown as HTMLElement).tagName).toBe("SPAN");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("forwards ref to small dot badge span element", () => {
|
|
214
|
+
const ref = { current: null };
|
|
215
|
+
render(<Badge ref={ref} />);
|
|
216
|
+
expect(ref.current).not.toBeNull();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// ── Reduced Motion ─────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
describe("prefers-reduced-motion", () => {
|
|
223
|
+
it("renders correctly when prefers-reduced-motion is active", () => {
|
|
224
|
+
vi.spyOn(MotionReact, "useReducedMotion").mockReturnValue(true);
|
|
225
|
+
render(<Badge>3</Badge>);
|
|
226
|
+
expect(screen.getByRole("status")).toBeInTheDocument();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ── BadgedBox Tests ───────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
describe("BadgedBox", () => {
|
|
234
|
+
it("renders anchor content", () => {
|
|
235
|
+
render(
|
|
236
|
+
<BadgedBox badge={<Badge />}>
|
|
237
|
+
<TestIcon />
|
|
238
|
+
</BadgedBox>,
|
|
239
|
+
);
|
|
240
|
+
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("renders badge overlaying the anchor", () => {
|
|
244
|
+
render(
|
|
245
|
+
<BadgedBox badge={<Badge aria-label="3 new" />}>
|
|
246
|
+
<TestIcon />
|
|
247
|
+
</BadgedBox>,
|
|
248
|
+
);
|
|
249
|
+
// The small badge is decorated, but the aria-label makes it visible to a11y
|
|
250
|
+
expect(screen.getByLabelText("3 new")).toBeInTheDocument();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("applies relative positioning to container", () => {
|
|
254
|
+
const { container } = render(
|
|
255
|
+
<BadgedBox badge={<Badge />}>
|
|
256
|
+
<TestIcon />
|
|
257
|
+
</BadgedBox>,
|
|
258
|
+
);
|
|
259
|
+
// The outer wrapper span has relative positioning class
|
|
260
|
+
const wrapper = container.firstChild as HTMLElement;
|
|
261
|
+
expect(wrapper.className).toContain("relative");
|
|
262
|
+
expect(wrapper.className).toContain("inline-flex");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("applies absolute positioning to badge wrapper", () => {
|
|
266
|
+
const { container } = render(
|
|
267
|
+
<BadgedBox badge={<Badge />}>
|
|
268
|
+
<TestIcon />
|
|
269
|
+
</BadgedBox>,
|
|
270
|
+
);
|
|
271
|
+
// Find the badge positioner span (second child of outer wrapper)
|
|
272
|
+
const wrapper = container.firstChild as HTMLElement;
|
|
273
|
+
const badgeSlot = wrapper.children[1] as HTMLElement;
|
|
274
|
+
expect(badgeSlot.className).toContain("absolute");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("positions small badge at top-trailing corner with 50% offset", () => {
|
|
278
|
+
const { container } = render(
|
|
279
|
+
<BadgedBox badge={<Badge />}>
|
|
280
|
+
<TestIcon />
|
|
281
|
+
</BadgedBox>,
|
|
282
|
+
);
|
|
283
|
+
const wrapper = container.firstChild as HTMLElement;
|
|
284
|
+
const badgeSlot = wrapper.children[1] as HTMLElement;
|
|
285
|
+
expect(badgeSlot.className).toContain("translate-x-[50%]");
|
|
286
|
+
expect(badgeSlot.className).toContain("-translate-y-[50%]");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("positions large badge at top-trailing corner with 35% offset", () => {
|
|
290
|
+
const { container } = render(
|
|
291
|
+
<BadgedBox badge={<Badge>3</Badge>}>
|
|
292
|
+
<TestIcon />
|
|
293
|
+
</BadgedBox>,
|
|
294
|
+
);
|
|
295
|
+
const wrapper = container.firstChild as HTMLElement;
|
|
296
|
+
const badgeSlot = wrapper.children[1] as HTMLElement;
|
|
297
|
+
expect(badgeSlot.className).toContain("translate-x-[35%]");
|
|
298
|
+
expect(badgeSlot.className).toContain("-translate-y-[35%]");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("respects explicit badgeSize='small' prop", () => {
|
|
302
|
+
const { container } = render(
|
|
303
|
+
// Even though badge has content, explicitly set small
|
|
304
|
+
<BadgedBox badge={<Badge>3</Badge>} badgeSize="small">
|
|
305
|
+
<TestIcon />
|
|
306
|
+
</BadgedBox>,
|
|
307
|
+
);
|
|
308
|
+
const wrapper = container.firstChild as HTMLElement;
|
|
309
|
+
const badgeSlot = wrapper.children[1] as HTMLElement;
|
|
310
|
+
expect(badgeSlot.className).toContain("translate-x-[50%]");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("respects explicit badgeSize='large' prop", () => {
|
|
314
|
+
const { container } = render(
|
|
315
|
+
// Even though badge has no content, explicitly set large
|
|
316
|
+
<BadgedBox badge={<Badge />} badgeSize="large">
|
|
317
|
+
<TestIcon />
|
|
318
|
+
</BadgedBox>,
|
|
319
|
+
);
|
|
320
|
+
const wrapper = container.firstChild as HTMLElement;
|
|
321
|
+
const badgeSlot = wrapper.children[1] as HTMLElement;
|
|
322
|
+
expect(badgeSlot.className).toContain("translate-x-[35%]");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("accepts and applies className to container", () => {
|
|
326
|
+
const { container } = render(
|
|
327
|
+
<BadgedBox badge={<Badge />} className="my-box-class">
|
|
328
|
+
<TestIcon />
|
|
329
|
+
</BadgedBox>,
|
|
330
|
+
);
|
|
331
|
+
const wrapper = container.firstChild as HTMLElement;
|
|
332
|
+
expect(wrapper).toHaveClass("my-box-class");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("badge slot wrapper has aria-hidden to avoid double-announcement", () => {
|
|
336
|
+
const { container } = render(
|
|
337
|
+
<BadgedBox badge={<Badge>3</Badge>}>
|
|
338
|
+
<TestIcon />
|
|
339
|
+
</BadgedBox>,
|
|
340
|
+
);
|
|
341
|
+
const wrapper = container.firstChild as HTMLElement;
|
|
342
|
+
const badgeSlot = wrapper.children[1] as HTMLElement;
|
|
343
|
+
expect(badgeSlot).toHaveAttribute("aria-hidden", "true");
|
|
344
|
+
});
|
|
345
|
+
});
|
package/src/ui/badge.tsx
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file badge.tsx
|
|
3
|
+
*
|
|
4
|
+
* MD3 Expressive Badge component.
|
|
5
|
+
*
|
|
6
|
+
* - `Badge` → A small status indicator. Can be a dot (no content) or labeled (with content).
|
|
7
|
+
* - `BadgedBox` → Positions a Badge at the top-trailing corner of an anchor element.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Token references (Kotlin source):
|
|
11
|
+
* BadgeTokens — Size=6dp (→ 6px), LargeSize=16dp (→ 16px), Shape=CornerFull, Color=Error,
|
|
12
|
+
* LargeLabelTextFont=LabelSmall, LargeLabelTextColor=OnError
|
|
13
|
+
*
|
|
14
|
+
* BadgedBox offsets:
|
|
15
|
+
* - Small (dot): BadgeOffset = 6dp → translate(50%, -50%)
|
|
16
|
+
* - Large (text): HOffset=12dp, VOffset=14dp → translate(35%, -35%)
|
|
17
|
+
*
|
|
18
|
+
* Architecture:
|
|
19
|
+
* - Styling: `cn` (clsx/tailwind-merge) + static Tailwind classes
|
|
20
|
+
* - Animation: Framer Motion (`LazyMotion` + `domMax`) spring mount/unmount
|
|
21
|
+
* - A11y: `role="status"` with `aria-label`, decorative dots use `aria-hidden="true"`
|
|
22
|
+
*
|
|
23
|
+
* @see https://m3.material.io/components/badge/overview
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
AnimatePresence,
|
|
28
|
+
domMax,
|
|
29
|
+
LazyMotion,
|
|
30
|
+
m,
|
|
31
|
+
useReducedMotion,
|
|
32
|
+
} from "motion/react";
|
|
33
|
+
import * as React from "react";
|
|
34
|
+
import { cn } from "../lib/utils";
|
|
35
|
+
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Types
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
// Exclude onDrag-family event handlers that conflict between React's HTMLAttributes
|
|
41
|
+
// and Framer Motion's MotionProps (different DragEvent signatures).
|
|
42
|
+
type SafeHTMLSpanAttrs = Omit<
|
|
43
|
+
React.HTMLAttributes<HTMLSpanElement>,
|
|
44
|
+
| "onDrag"
|
|
45
|
+
| "onDragStart"
|
|
46
|
+
| "onDragEnd"
|
|
47
|
+
| "onDragEnter"
|
|
48
|
+
| "onDragLeave"
|
|
49
|
+
| "onDragOver"
|
|
50
|
+
| "onDrop"
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
export interface BadgeProps extends SafeHTMLSpanAttrs {
|
|
54
|
+
/**
|
|
55
|
+
* The content to display inside the badge.
|
|
56
|
+
* - Omitted / undefined → renders as a small 6×6px dot (decorative).
|
|
57
|
+
* - string | number → renders as a large badge (min 16px height) with label.
|
|
58
|
+
*
|
|
59
|
+
* Numbers exceeding `max` are displayed as `{max}+`.
|
|
60
|
+
* Strings longer than 4 characters are truncated to 4.
|
|
61
|
+
*/
|
|
62
|
+
children?: React.ReactNode;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Maximum numeric value to display before appending "+".
|
|
66
|
+
* Only applies when `children` is a number.
|
|
67
|
+
* @example max={99} + children={150} → "99+"
|
|
68
|
+
*/
|
|
69
|
+
max?: number;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Override the background (container) color.
|
|
73
|
+
* Accepts any valid CSS color value.
|
|
74
|
+
* Defaults to MD3 `error` token — `bg-m3-error`.
|
|
75
|
+
*/
|
|
76
|
+
containerColor?: string;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Override the text/content color.
|
|
80
|
+
* Accepts any valid CSS color value.
|
|
81
|
+
* Defaults to MD3 `on-error` token — `text-m3-on-error`.
|
|
82
|
+
*/
|
|
83
|
+
contentColor?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface BadgedBoxProps {
|
|
87
|
+
/**
|
|
88
|
+
* The badge element to overlay on the anchor.
|
|
89
|
+
* Typically a `<Badge />`.
|
|
90
|
+
*/
|
|
91
|
+
badge: React.ReactNode;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The anchor content that the badge is attached to.
|
|
95
|
+
*/
|
|
96
|
+
children: React.ReactNode;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Additional className applied to the outer wrapper `span`.
|
|
100
|
+
*/
|
|
101
|
+
className?: string;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Explicitly override size detection for badge positioning.
|
|
105
|
+
* - `'small'` → BadgeOffset = 6dp → translate(50%, -50%)
|
|
106
|
+
* - `'large'` → HOffset=12dp/VOffset=14dp → translate(35%, -35%)
|
|
107
|
+
* When omitted, BadgedBox auto-detects by inspecting `badge` children prop.
|
|
108
|
+
*/
|
|
109
|
+
badgeSize?: "small" | "large";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
// Helpers
|
|
114
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function formatBadgeLabel(children: React.ReactNode, max?: number): string {
|
|
117
|
+
if (typeof children === "number") {
|
|
118
|
+
return max !== undefined && children > max ? `${max}+` : String(children);
|
|
119
|
+
}
|
|
120
|
+
if (typeof children === "string") {
|
|
121
|
+
if (max !== undefined) {
|
|
122
|
+
const asNum = Number(children);
|
|
123
|
+
if (!Number.isNaN(asNum) && asNum > max) return `${max}+`;
|
|
124
|
+
}
|
|
125
|
+
return children.length > 4 ? children.slice(0, 4) : children;
|
|
126
|
+
}
|
|
127
|
+
return "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function detectBadgeHasContent(badge: React.ReactNode): boolean {
|
|
131
|
+
return (
|
|
132
|
+
React.isValidElement<{ children?: React.ReactNode }>(badge) &&
|
|
133
|
+
badge.props.children != null
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
// Badge
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
const BadgeImpl = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
|
142
|
+
(
|
|
143
|
+
{
|
|
144
|
+
children,
|
|
145
|
+
max,
|
|
146
|
+
containerColor,
|
|
147
|
+
contentColor,
|
|
148
|
+
className,
|
|
149
|
+
style,
|
|
150
|
+
"aria-label": ariaLabel,
|
|
151
|
+
...props
|
|
152
|
+
},
|
|
153
|
+
ref,
|
|
154
|
+
) => {
|
|
155
|
+
const hasContent = children != null;
|
|
156
|
+
const label = hasContent ? formatBadgeLabel(children, max) : "";
|
|
157
|
+
const reducedMotion = useReducedMotion();
|
|
158
|
+
const isDecorative = !hasContent && !ariaLabel;
|
|
159
|
+
|
|
160
|
+
const springTransition = reducedMotion
|
|
161
|
+
? { duration: 0 }
|
|
162
|
+
: { type: "spring" as const, stiffness: 500, damping: 30, mass: 0.8 };
|
|
163
|
+
|
|
164
|
+
const colorStyle: React.CSSProperties = {
|
|
165
|
+
...(containerColor && { backgroundColor: containerColor }),
|
|
166
|
+
...(contentColor && { color: contentColor }),
|
|
167
|
+
...style,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<LazyMotion features={domMax} strict>
|
|
172
|
+
<m.span
|
|
173
|
+
ref={ref}
|
|
174
|
+
role={isDecorative ? undefined : "status"}
|
|
175
|
+
aria-hidden={isDecorative ? "true" : undefined}
|
|
176
|
+
aria-label={hasContent ? (ariaLabel ?? label) : ariaLabel}
|
|
177
|
+
initial={{ scale: 0, opacity: 0 }}
|
|
178
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
179
|
+
exit={{ scale: 0, opacity: 0 }}
|
|
180
|
+
transition={springTransition}
|
|
181
|
+
className={cn(
|
|
182
|
+
"rounded-full ring-[1.5px] ring-m3-surface",
|
|
183
|
+
!containerColor && "bg-m3-error",
|
|
184
|
+
hasContent
|
|
185
|
+
? cn(
|
|
186
|
+
"inline-flex items-center justify-center",
|
|
187
|
+
"min-w-4 h-4 px-1 text-[11px] font-medium leading-none",
|
|
188
|
+
!contentColor && "text-m3-on-error",
|
|
189
|
+
)
|
|
190
|
+
: "inline-block w-1.5 h-1.5",
|
|
191
|
+
className,
|
|
192
|
+
)}
|
|
193
|
+
style={colorStyle}
|
|
194
|
+
// biome-ignore lint/suspicious/noExplicitAny: spread safe subset of HTML attrs
|
|
195
|
+
{...(props as any)}
|
|
196
|
+
>
|
|
197
|
+
{hasContent && label}
|
|
198
|
+
</m.span>
|
|
199
|
+
</LazyMotion>
|
|
200
|
+
);
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
BadgeImpl.displayName = "Badge";
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* MD3 Expressive Badge — dynamic status indicator.
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* ```tsx
|
|
211
|
+
* // Small dot badge (no content) — decorative
|
|
212
|
+
* <Badge />
|
|
213
|
+
*
|
|
214
|
+
* // Large badge with number (truncated at max)
|
|
215
|
+
* <Badge max={99}>150</Badge>
|
|
216
|
+
* // → displays "99+"
|
|
217
|
+
*
|
|
218
|
+
* // Large badge with text label
|
|
219
|
+
* <Badge>NEW</Badge>
|
|
220
|
+
*
|
|
221
|
+
* // Custom colors
|
|
222
|
+
* <Badge containerColor="#6750A4" contentColor="#FFFFFF">3</Badge>
|
|
223
|
+
* ```
|
|
224
|
+
*
|
|
225
|
+
* @see https://m3.material.io/components/badge/overview
|
|
226
|
+
*/
|
|
227
|
+
export const Badge = React.memo(BadgeImpl);
|
|
228
|
+
|
|
229
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
230
|
+
// BadgedBox
|
|
231
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* MD3 BadgedBox — positions a Badge at the top-trailing corner of an anchor.
|
|
235
|
+
*
|
|
236
|
+
* Implements MD3 offset specs from Badge.kt:
|
|
237
|
+
* - Small badge (dot): `BadgeOffset = 6dp` → translate(50%, -50%)
|
|
238
|
+
* - Large badge (text): `BadgeWithContentHorizontalOffset = 12dp` / `VerticalOffset = 14dp`
|
|
239
|
+
* → translate(35%, -35%)
|
|
240
|
+
*
|
|
241
|
+
* Auto-detects badge size by inspecting the badge element's children prop,
|
|
242
|
+
* or accepts an explicit `badgeSize` override.
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* ```tsx
|
|
246
|
+
* // Small dot on mail icon
|
|
247
|
+
* <BadgedBox badge={<Badge />}>
|
|
248
|
+
* <Icon name="mail" />
|
|
249
|
+
* </BadgedBox>
|
|
250
|
+
*
|
|
251
|
+
* // Count badge on notification icon
|
|
252
|
+
* <BadgedBox badge={<Badge max={99}>{count}</Badge>}>
|
|
253
|
+
* <Icon name="notifications" />
|
|
254
|
+
* </BadgedBox>
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
export function BadgedBox({
|
|
258
|
+
badge,
|
|
259
|
+
children,
|
|
260
|
+
className,
|
|
261
|
+
badgeSize,
|
|
262
|
+
}: BadgedBoxProps) {
|
|
263
|
+
const isLarge = badgeSize
|
|
264
|
+
? badgeSize === "large"
|
|
265
|
+
: detectBadgeHasContent(badge);
|
|
266
|
+
|
|
267
|
+
const badgePositionClass = isLarge
|
|
268
|
+
? "translate-x-[35%] -translate-y-[35%]"
|
|
269
|
+
: "translate-x-[50%] -translate-y-[50%]";
|
|
270
|
+
|
|
271
|
+
return (
|
|
272
|
+
<span className={cn("relative inline-flex", className)}>
|
|
273
|
+
{children}
|
|
274
|
+
<span
|
|
275
|
+
className={cn("absolute right-0 top-0", badgePositionClass)}
|
|
276
|
+
aria-hidden="true"
|
|
277
|
+
>
|
|
278
|
+
<AnimatePresence mode="wait">{badge}</AnimatePresence>
|
|
279
|
+
</span>
|
|
280
|
+
</span>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { ButtonGroup } from "./button-group";
|
|
5
|
+
|
|
6
|
+
describe("ButtonGroup Component", () => {
|
|
7
|
+
it("renders children correctly", () => {
|
|
8
|
+
render(
|
|
9
|
+
<ButtonGroup>
|
|
10
|
+
<Button>One</Button>
|
|
11
|
+
<Button>Two</Button>
|
|
12
|
+
</ButtonGroup>,
|
|
13
|
+
);
|
|
14
|
+
expect(screen.getByText("One")).toBeInTheDocument();
|
|
15
|
+
expect(screen.getByText("Two")).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("applies gap-2 for standard variant by default", () => {
|
|
19
|
+
const { container } = render(
|
|
20
|
+
<ButtonGroup>
|
|
21
|
+
<Button>One</Button>
|
|
22
|
+
<Button>Two</Button>
|
|
23
|
+
</ButtonGroup>,
|
|
24
|
+
);
|
|
25
|
+
const fieldset = container.querySelector("fieldset");
|
|
26
|
+
expect(fieldset).toHaveClass("gap-2");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("applies gap-0.5 for connected variant", () => {
|
|
30
|
+
const { container } = render(
|
|
31
|
+
<ButtonGroup variant="connected">
|
|
32
|
+
<Button>One</Button>
|
|
33
|
+
<Button>Two</Button>
|
|
34
|
+
</ButtonGroup>,
|
|
35
|
+
);
|
|
36
|
+
const fieldset = container.querySelector("fieldset");
|
|
37
|
+
expect(fieldset).toHaveClass("gap-0.5");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("passes size prop to children to enforce uniformity", () => {
|
|
41
|
+
const { container } = render(
|
|
42
|
+
<ButtonGroup size="lg">
|
|
43
|
+
{/* Even if child specifies sm, the group's lg should override or inject */}
|
|
44
|
+
<Button size="sm">One</Button>
|
|
45
|
+
<Button>Two</Button>
|
|
46
|
+
</ButtonGroup>,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const buttons = container.querySelectorAll("button");
|
|
50
|
+
// SIZE_PADDING_MAP["lg"] is "3rem" in button.tsx (roughly corresponding to h-14/px-6 class)
|
|
51
|
+
// We can check if the inner-radius variable is correctly injected (lg -> 16px)
|
|
52
|
+
expect(buttons[0]).toHaveStyle({ "--m3-inner-rad": "16px" });
|
|
53
|
+
expect(buttons[1]).toHaveStyle({ "--m3-inner-rad": "16px" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("applies morphing transition style to connected buttons by default", () => {
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<ButtonGroup variant="connected">
|
|
59
|
+
<Button>One</Button>
|
|
60
|
+
<Button>Two</Button>
|
|
61
|
+
</ButtonGroup>,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const buttons = container.querySelectorAll("button");
|
|
65
|
+
// Check that the custom transition is injected via style
|
|
66
|
+
const expectedTransition =
|
|
67
|
+
"border-top-left-radius 0.25s cubic-bezier(0.2, 0, 0, 1), border-top-right-radius 0.25s cubic-bezier(0.2, 0, 0, 1), border-bottom-right-radius 0.25s cubic-bezier(0.2, 0, 0, 1), border-bottom-left-radius 0.25s cubic-bezier(0.2, 0, 0, 1), padding 0.2s cubic-bezier(0.2, 0, 0, 1), flex 0.2s cubic-bezier(0.2, 0, 0, 1)";
|
|
68
|
+
expect(buttons[0]).toHaveStyle({ transition: expectedTransition });
|
|
69
|
+
expect(buttons[1]).toHaveStyle({ transition: expectedTransition });
|
|
70
|
+
});
|
|
71
|
+
});
|