@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,187 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
import * as MotionReact from "motion/react";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Card } from "./card";
|
|
7
|
+
|
|
8
|
+
// Mock motion/react – tương tự pattern trong button.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
|
+
const renderCard = (props = {}, children = <p>Content</p>) =>
|
|
18
|
+
render(<Card {...props}>{children}</Card>);
|
|
19
|
+
|
|
20
|
+
describe("Card Component", () => {
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// ── Static Card ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe("static card (no interaction)", () => {
|
|
28
|
+
it("renders a <div> when no onClick/href/interactive given", () => {
|
|
29
|
+
const { container } = renderCard();
|
|
30
|
+
expect(container.firstChild?.nodeName).toBe("DIV");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does not have tabIndex on static card", () => {
|
|
34
|
+
const { container } = renderCard();
|
|
35
|
+
expect(container.firstChild).not.toHaveAttribute("tabIndex");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("renders children inside the card", () => {
|
|
39
|
+
renderCard({}, <span data-testid="child">Hello</span>);
|
|
40
|
+
expect(screen.getByTestId("child")).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// ── Interactive Card (onClick) ────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
describe("interactive card (onClick prop)", () => {
|
|
47
|
+
it("renders a <button> when onClick is provided", () => {
|
|
48
|
+
renderCard({ onClick: vi.fn() });
|
|
49
|
+
expect(screen.getByRole("button")).toBeInTheDocument();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("has tabIndex={0} when interactive", () => {
|
|
53
|
+
renderCard({ onClick: vi.fn() });
|
|
54
|
+
expect(screen.getByRole("button")).toHaveAttribute("tabIndex", "0");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("calls onClick when clicked", () => {
|
|
58
|
+
const handleClick = vi.fn();
|
|
59
|
+
renderCard({ onClick: handleClick });
|
|
60
|
+
fireEvent.click(screen.getByRole("button"));
|
|
61
|
+
expect(handleClick).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("triggers ripple on pointerDown", () => {
|
|
65
|
+
renderCard({ onClick: vi.fn() });
|
|
66
|
+
const btn = screen.getByRole("button");
|
|
67
|
+
fireEvent.pointerDown(btn, { clientX: 10, clientY: 10 });
|
|
68
|
+
// Ripple mounts a span[aria-hidden] inside the button
|
|
69
|
+
expect(btn.querySelector("span[aria-hidden='true']")).not.toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── Interactive Card (interactive prop) ───────────────────────────────────
|
|
74
|
+
|
|
75
|
+
describe("interactive card (interactive prop)", () => {
|
|
76
|
+
it("renders a <button> when interactive=true even without onClick", () => {
|
|
77
|
+
renderCard({ interactive: true });
|
|
78
|
+
expect(screen.getByRole("button")).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("has tabIndex={0} when interactive=true", () => {
|
|
82
|
+
renderCard({ interactive: true });
|
|
83
|
+
expect(screen.getByRole("button")).toHaveAttribute("tabIndex", "0");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── Link Card (href) ───────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
describe("link card (href prop)", () => {
|
|
90
|
+
it("renders an <a> tag when href is provided", () => {
|
|
91
|
+
renderCard({ href: "/some-page" });
|
|
92
|
+
expect(screen.getByRole("link")).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("passes href attribute to the <a> element", () => {
|
|
96
|
+
renderCard({ href: "/some-page" });
|
|
97
|
+
expect(screen.getByRole("link")).toHaveAttribute("href", "/some-page");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("adds rel='noreferrer' automatically when target='_blank'", () => {
|
|
101
|
+
renderCard({ href: "https://example.com", target: "_blank" });
|
|
102
|
+
expect(screen.getByRole("link")).toHaveAttribute("rel", "noreferrer");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does not add rel when target is not '_blank'", () => {
|
|
106
|
+
renderCard({ href: "/internal", target: "_self" });
|
|
107
|
+
expect(screen.getByRole("link")).not.toHaveAttribute("rel");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Disabled State ────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe("disabled state", () => {
|
|
114
|
+
it("static card: has aria-disabled when disabled=true", () => {
|
|
115
|
+
const { container } = renderCard({ disabled: true });
|
|
116
|
+
expect(container.firstChild).toHaveAttribute("aria-disabled", "true");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("interactive card: has aria-disabled when disabled=true", () => {
|
|
120
|
+
renderCard({ onClick: vi.fn(), disabled: true });
|
|
121
|
+
expect(screen.getByRole("button")).toHaveAttribute(
|
|
122
|
+
"aria-disabled",
|
|
123
|
+
"true",
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("interactive card: has tabIndex=-1 when disabled", () => {
|
|
128
|
+
renderCard({ onClick: vi.fn(), disabled: true });
|
|
129
|
+
expect(screen.getByRole("button")).toHaveAttribute("tabIndex", "-1");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("interactive card: has 'pointer-events-none' class when disabled", () => {
|
|
133
|
+
renderCard({ onClick: vi.fn(), disabled: true });
|
|
134
|
+
expect(screen.getByRole("button").className).toContain(
|
|
135
|
+
"pointer-events-none",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("interactive card: has 'opacity-[0.38]' class when disabled", () => {
|
|
140
|
+
renderCard({ onClick: vi.fn(), disabled: true });
|
|
141
|
+
expect(screen.getByRole("button").className).toMatch(/opacity-\[0\.38\]/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("link card: href is removed when disabled", () => {
|
|
145
|
+
const { container } = renderCard({ href: "/page", disabled: true });
|
|
146
|
+
// When disabled, href is set to undefined so the <a> has no href attribute.
|
|
147
|
+
// Note: without href, the element loses its "link" role, so we query by tag.
|
|
148
|
+
const anchor = container.querySelector("a");
|
|
149
|
+
expect(anchor).toBeInTheDocument();
|
|
150
|
+
expect(anchor).not.toHaveAttribute("href");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── Variant Token Classes ─────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
describe("variant token classes (MD3)", () => {
|
|
157
|
+
it("elevated variant → bg-m3-surface-container-low", () => {
|
|
158
|
+
const { container } = renderCard({ variant: "elevated" });
|
|
159
|
+
expect(container.firstChild).toHaveClass("bg-m3-surface-container-low");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("filled variant → bg-m3-surface-container-highest", () => {
|
|
163
|
+
const { container } = renderCard({ variant: "filled" });
|
|
164
|
+
expect(container.firstChild).toHaveClass(
|
|
165
|
+
"bg-m3-surface-container-highest",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("outlined variant → bg-m3-surface + border-m3-outline-variant", () => {
|
|
170
|
+
const { container } = renderCard({ variant: "outlined" });
|
|
171
|
+
expect(container.firstChild).toHaveClass("bg-m3-surface");
|
|
172
|
+
expect(container.firstChild).toHaveClass("border-m3-outline-variant");
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── A11y: prefers-reduced-motion ──────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
describe("accessibility - reduced motion", () => {
|
|
179
|
+
it("Ripple renders nothing when prefers-reduced-motion is active", () => {
|
|
180
|
+
vi.spyOn(MotionReact, "useReducedMotion").mockReturnValue(true);
|
|
181
|
+
renderCard({ onClick: vi.fn() });
|
|
182
|
+
const btn = screen.getByRole("button");
|
|
183
|
+
fireEvent.pointerDown(btn, { clientX: 5, clientY: 5 });
|
|
184
|
+
expect(btn.querySelector("span[aria-hidden='true']")).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
package/src/ui/card.tsx
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file card.tsx
|
|
3
|
+
*
|
|
4
|
+
* Thẻ Card của bộ khung MD3 Expressive.
|
|
5
|
+
*
|
|
6
|
+
* Phân chia làm hai khía cạnh chức năng (cấu trúc tham khảo từ con ruột Android Card.kt):
|
|
7
|
+
* - **Tĩnh Lặng (Static)** → đơn thuần mang thẻ `<div>`, yên tĩnh và không hề mảy may phản hồi có tương tác nào.
|
|
8
|
+
* - **Có phản ứng (Interactive)** → được phù phép bằng `<motion.button>` hoặc `<motion.a>`, mang bùa Ripple vẫy sống cùng khả năng nhảy vọt elevation khi lướt lên.
|
|
9
|
+
*
|
|
10
|
+
* Nấc độ bóng Elevation levels (dịch từ các file mã ElevatedCardTokens / FilledCardTokens / OutlinedCardTokens / Elevation.kt):
|
|
11
|
+
* - Level 0 = "none" (Bằng phẳng)
|
|
12
|
+
* - Level 1 = box-shadow ~1dp (Hơi nhỉnh nổi nhẹ)
|
|
13
|
+
* - Level 2 = box-shadow ~2dp (Bay lên cao xíu)
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* // Card tĩnh
|
|
18
|
+
* <Card variant="elevated">
|
|
19
|
+
* <div className="p-4">Nội dung thẻ Card nhẹ nhàng</div>
|
|
20
|
+
* </Card>
|
|
21
|
+
*
|
|
22
|
+
* // Card button tương tác
|
|
23
|
+
* <Card variant="filled" onClick={() => alert('Đã nhấn!')}>
|
|
24
|
+
* <div className="p-4">Click vào đây em ei</div>
|
|
25
|
+
* </Card>
|
|
26
|
+
*
|
|
27
|
+
* // Card làm thẻ Link a
|
|
28
|
+
* <Card variant="outlined" href="/home">
|
|
29
|
+
* <div className="p-4">Click để chuyển trang</div>
|
|
30
|
+
* </Card>
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* @see https://m3.material.io/components/cards/overview
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
37
|
+
import type { HTMLMotionProps } from "motion/react";
|
|
38
|
+
import { domMax, LazyMotion, m } from "motion/react";
|
|
39
|
+
import * as React from "react";
|
|
40
|
+
import { cn } from "../lib/utils";
|
|
41
|
+
import { Ripple, useRippleState } from "./ripple";
|
|
42
|
+
|
|
43
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
// MD3 Elevation Shadows (from packages/tailwind/src/index.ts)
|
|
45
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
const SHADOW = {
|
|
47
|
+
level0: "none",
|
|
48
|
+
level1: "0px 1px 2px 0px rgba(0,0,0,.3), 0px 1px 3px 1px rgba(0,0,0,.15)",
|
|
49
|
+
level2: "0px 1px 2px 0px rgba(0,0,0,.3), 0px 2px 6px 2px rgba(0,0,0,.15)",
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
// Maps each variant to its elevation levels per interaction state.
|
|
53
|
+
// Source: ElevatedCardTokens.kt, FilledCardTokens.kt, OutlinedCardTokens.kt
|
|
54
|
+
const VARIANT_ELEVATION = {
|
|
55
|
+
elevated: {
|
|
56
|
+
rest: SHADOW.level1,
|
|
57
|
+
hover: SHADOW.level2,
|
|
58
|
+
pressed: SHADOW.level1,
|
|
59
|
+
disabled: SHADOW.level1, // ElevatedCardTokens.DisabledContainerElevation = Level1
|
|
60
|
+
},
|
|
61
|
+
filled: {
|
|
62
|
+
rest: SHADOW.level0,
|
|
63
|
+
hover: SHADOW.level1,
|
|
64
|
+
pressed: SHADOW.level0,
|
|
65
|
+
disabled: SHADOW.level0,
|
|
66
|
+
},
|
|
67
|
+
outlined: {
|
|
68
|
+
rest: SHADOW.level0,
|
|
69
|
+
hover: SHADOW.level1,
|
|
70
|
+
pressed: SHADOW.level0,
|
|
71
|
+
disabled: SHADOW.level0,
|
|
72
|
+
},
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
type CardVariant = keyof typeof VARIANT_ELEVATION;
|
|
76
|
+
|
|
77
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
// CVA – Variant base classes (token-aligned with MD3)
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
const cardVariants = cva(
|
|
81
|
+
"rounded-m3-lg flex flex-col relative overflow-hidden transition-colors duration-200",
|
|
82
|
+
{
|
|
83
|
+
variants: {
|
|
84
|
+
variant: {
|
|
85
|
+
// ElevatedCardTokens.ContainerColor = SurfaceContainerLow
|
|
86
|
+
elevated: "bg-m3-surface-container-low",
|
|
87
|
+
// FilledCardTokens.ContainerColor = SurfaceContainerHighest
|
|
88
|
+
filled: "bg-m3-surface-container-highest",
|
|
89
|
+
// OutlinedCardTokens.ContainerColor = Surface, OutlineColor = OutlineVariant
|
|
90
|
+
outlined: "bg-m3-surface border border-m3-outline-variant",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
defaultVariants: { variant: "elevated" },
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
// Hook: Card Elevation Animation
|
|
99
|
+
// Mirrors animateElevation() from Elevation.kt.
|
|
100
|
+
// Returns motion animation props for interactive boxShadow transitions.
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
function useCardElevation(variant: CardVariant, disabled: boolean) {
|
|
103
|
+
const levels = VARIANT_ELEVATION[variant];
|
|
104
|
+
return {
|
|
105
|
+
animate: { boxShadow: disabled ? levels.disabled : levels.rest },
|
|
106
|
+
whileHover: disabled ? undefined : { boxShadow: levels.hover },
|
|
107
|
+
whileTap: disabled ? undefined : { boxShadow: levels.pressed },
|
|
108
|
+
whileFocus: disabled ? undefined : { boxShadow: levels.hover },
|
|
109
|
+
transition: {
|
|
110
|
+
boxShadow: {
|
|
111
|
+
// Incoming: 120ms (from Elevation.kt DefaultIncomingSpec)
|
|
112
|
+
duration: 0.12,
|
|
113
|
+
ease: [0.4, 0, 0.2, 1] as [number, number, number, number],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
// Types
|
|
121
|
+
// Use HTMLMotionProps<"button"> as the base to avoid onDrag / event handler
|
|
122
|
+
// conflicts between native React HTMLAttributes and Motion's extended prop types.
|
|
123
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
124
|
+
type MotionDivProps = Omit<HTMLMotionProps<"button">, "children" | "color">;
|
|
125
|
+
|
|
126
|
+
export interface CardProps
|
|
127
|
+
extends MotionDivProps,
|
|
128
|
+
VariantProps<typeof cardVariants> {
|
|
129
|
+
/** Vô hiệu hóa tương tác và giảm opacity (MD3 disabled state). */
|
|
130
|
+
disabled?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Buộc card trở thành interactive dù không có `onClick`.
|
|
133
|
+
* Hữu ích khi card chứa các element con là interactive.
|
|
134
|
+
*/
|
|
135
|
+
interactive?: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Nếu có, card render thành thẻ `<a>`. Tự động kích hoạt interactive mode.
|
|
138
|
+
* Ưu tiên dùng `href` thay vì `onClick` khi điều hướng trang.
|
|
139
|
+
*/
|
|
140
|
+
href?: string;
|
|
141
|
+
/** Target cho thẻ `<a>` (chỉ có hiệu lực khi `href` được cung cấp). */
|
|
142
|
+
target?: React.AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
|
143
|
+
/** rel cho thẻ `<a>` (tự động thêm `noreferrer` khi `target="_blank"`). */
|
|
144
|
+
rel?: string;
|
|
145
|
+
children?: React.ReactNode;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
// Component
|
|
150
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
151
|
+
const CardImpl = React.forwardRef<HTMLElement, CardProps>(
|
|
152
|
+
(
|
|
153
|
+
{
|
|
154
|
+
className,
|
|
155
|
+
variant = "elevated",
|
|
156
|
+
disabled = false,
|
|
157
|
+
interactive = false,
|
|
158
|
+
href,
|
|
159
|
+
target,
|
|
160
|
+
rel: relProp,
|
|
161
|
+
onClick,
|
|
162
|
+
children,
|
|
163
|
+
...props
|
|
164
|
+
},
|
|
165
|
+
ref,
|
|
166
|
+
) => {
|
|
167
|
+
const safeVariant = variant as CardVariant;
|
|
168
|
+
const isInteractive = !!onClick || !!href || interactive;
|
|
169
|
+
const elevationProps = useCardElevation(safeVariant, disabled);
|
|
170
|
+
const { ripples, onPointerDown, removeRipple } = useRippleState();
|
|
171
|
+
|
|
172
|
+
const baseClass = cn(
|
|
173
|
+
cardVariants({ variant }),
|
|
174
|
+
// Disabled state:
|
|
175
|
+
// - pointer-events-none → vô hiệu hóa tương tác hoàn toàn
|
|
176
|
+
// - opacity-[0.38] → MD3 DisabledContainerOpacity
|
|
177
|
+
disabled && "pointer-events-none opacity-[0.38]",
|
|
178
|
+
className,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// MD3 State Layer (Hover: 8%, Focus: 10%, Pressed: 10%)
|
|
182
|
+
// Áp dụng cho interactive elements, dùng absolute inset ::before
|
|
183
|
+
const interactiveClass = cn(
|
|
184
|
+
// Xóa outline default, dùng state overlay & elevation của MD3 để biểu hiện focus
|
|
185
|
+
"focus-visible:outline-none focus:outline-none group",
|
|
186
|
+
// Layer overlay base pseudo-element
|
|
187
|
+
"before:absolute before:inset-0 before:pointer-events-none before:bg-m3-on-surface before:opacity-0 before:transition-opacity before:duration-200",
|
|
188
|
+
// Interactive states opacities
|
|
189
|
+
"hover:before:opacity-[0.08] focus-visible:before:opacity-[0.10] active:before:opacity-[0.10]",
|
|
190
|
+
// Outlined interactive card: đổi màu border sang m3-outline khi focus/press/hover
|
|
191
|
+
variant === "outlined" &&
|
|
192
|
+
"hover:border-m3-outline focus-visible:border-m3-outline active:border-m3-outline",
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// ── Static Card – không có interaction ─────────────────────────────────
|
|
196
|
+
if (!isInteractive) {
|
|
197
|
+
return (
|
|
198
|
+
<div
|
|
199
|
+
ref={ref as React.Ref<HTMLDivElement>}
|
|
200
|
+
className={baseClass}
|
|
201
|
+
aria-disabled={disabled ? true : undefined}
|
|
202
|
+
>
|
|
203
|
+
{children}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Safe rel: tự động thêm "noreferrer" khi target="_blank" ────────────
|
|
209
|
+
const safeRel = href
|
|
210
|
+
? (relProp ?? (target === "_blank" ? "noreferrer" : undefined))
|
|
211
|
+
: undefined;
|
|
212
|
+
|
|
213
|
+
// ── Link Card ────────────────────────────────────────────────────────────
|
|
214
|
+
if (href) {
|
|
215
|
+
return (
|
|
216
|
+
<LazyMotion features={domMax} strict>
|
|
217
|
+
<m.a
|
|
218
|
+
ref={ref as React.Ref<HTMLAnchorElement>}
|
|
219
|
+
href={disabled ? undefined : href}
|
|
220
|
+
target={target}
|
|
221
|
+
rel={safeRel}
|
|
222
|
+
className={cn(baseClass, interactiveClass)}
|
|
223
|
+
aria-disabled={disabled ? true : undefined}
|
|
224
|
+
tabIndex={disabled ? -1 : 0}
|
|
225
|
+
onPointerDown={onPointerDown}
|
|
226
|
+
{...elevationProps}
|
|
227
|
+
>
|
|
228
|
+
<Ripple ripples={ripples} onRippleDone={removeRipple} />
|
|
229
|
+
{children}
|
|
230
|
+
</m.a>
|
|
231
|
+
</LazyMotion>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Interactive Button Card ──────────────────────────────────────────────
|
|
236
|
+
return (
|
|
237
|
+
<LazyMotion features={domMax} strict>
|
|
238
|
+
<m.button
|
|
239
|
+
ref={ref as React.Ref<HTMLButtonElement>}
|
|
240
|
+
type="button"
|
|
241
|
+
disabled={disabled}
|
|
242
|
+
onClick={onClick}
|
|
243
|
+
className={cn(baseClass, interactiveClass)}
|
|
244
|
+
aria-disabled={disabled ? true : undefined}
|
|
245
|
+
tabIndex={disabled ? -1 : 0}
|
|
246
|
+
onPointerDown={onPointerDown}
|
|
247
|
+
{...elevationProps}
|
|
248
|
+
{...props}
|
|
249
|
+
>
|
|
250
|
+
<Ripple ripples={ripples} onRippleDone={removeRipple} />
|
|
251
|
+
{children}
|
|
252
|
+
</m.button>
|
|
253
|
+
</LazyMotion>
|
|
254
|
+
);
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
CardImpl.displayName = "Card";
|
|
258
|
+
|
|
259
|
+
export const Card = React.memo(CardImpl);
|