@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.
Files changed (308) hide show
  1. package/.turbo/turbo-build.log +33 -0
  2. package/CHANGELOG.md +55 -0
  3. package/dist/index.css.d.ts +2 -0
  4. package/dist/index.d.mts +6127 -0
  5. package/dist/index.d.ts +6127 -71
  6. package/dist/index.js +1653 -614
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +1566 -547
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/material-symbols-cdn.css.d.ts +2 -0
  11. package/dist/material-symbols-self-hosted.css.d.ts +2 -0
  12. package/dist/typography.css.d.ts +2 -0
  13. package/package.json +22 -19
  14. package/scripts/copy-assets.js +82 -0
  15. package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
  16. package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  17. package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  18. package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  19. package/src/assets/loading-indicator.svg +19 -0
  20. package/src/assets/material-symbols-cdn.css +65 -0
  21. package/src/assets/material-symbols-self-hosted.css +90 -0
  22. package/src/css.d.ts +20 -0
  23. package/src/hooks/useClickOutside.ts +37 -0
  24. package/src/hooks/useMediaQuery.ts +28 -0
  25. package/src/hooks/useRipple.ts +88 -0
  26. package/src/index.css +23 -0
  27. package/src/index.ts +349 -0
  28. package/src/lib/material-symbols-preconnect.tsx +82 -0
  29. package/src/lib/theme-utils.ts +180 -0
  30. package/src/lib/utils.ts +6 -0
  31. package/src/test/button.test.tsx +59 -0
  32. package/src/test/icon.test.tsx +91 -0
  33. package/src/test/loading-indicator.test.tsx +128 -0
  34. package/src/test/progress-indicator.test.tsx +306 -0
  35. package/src/test/setup.ts +80 -0
  36. package/src/test/typography.test.tsx +206 -0
  37. package/src/types/index.ts +7 -0
  38. package/src/types/md3.ts +31 -0
  39. package/src/ui/Text.tsx +60 -0
  40. package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
  41. package/src/ui/app-bar/app-bar-column.tsx +99 -0
  42. package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
  43. package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
  44. package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
  45. package/src/ui/app-bar/app-bar-row.tsx +104 -0
  46. package/src/ui/app-bar/app-bar.test.tsx +87 -0
  47. package/src/ui/app-bar/app-bar.tokens.ts +223 -0
  48. package/src/ui/app-bar/app-bar.types.ts +441 -0
  49. package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
  50. package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
  51. package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
  52. package/src/ui/app-bar/docked-toolbar.tsx +54 -0
  53. package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
  54. package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
  55. package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
  56. package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
  57. package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
  58. package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
  59. package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
  60. package/src/ui/app-bar/search-app-bar.tsx +176 -0
  61. package/src/ui/app-bar/search-view.tsx +227 -0
  62. package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
  63. package/src/ui/app-bar/small-app-bar.tsx +203 -0
  64. package/src/ui/badge.test.tsx +345 -0
  65. package/src/ui/badge.tsx +282 -0
  66. package/src/ui/button-group.test.tsx +71 -0
  67. package/src/ui/button-group.tsx +350 -0
  68. package/src/ui/button.test.tsx +297 -0
  69. package/src/ui/button.tsx +669 -0
  70. package/src/ui/card.test.tsx +187 -0
  71. package/src/ui/card.tsx +259 -0
  72. package/src/ui/checkbox.test.tsx +423 -0
  73. package/src/ui/checkbox.tsx +525 -0
  74. package/src/ui/chip.test.tsx +292 -0
  75. package/src/ui/chip.tsx +548 -0
  76. package/src/ui/code-block.tsx +219 -0
  77. package/src/ui/dialog.test.tsx +300 -0
  78. package/src/ui/dialog.tsx +384 -0
  79. package/src/ui/divider.test.tsx +314 -0
  80. package/src/ui/divider.tsx +412 -0
  81. package/src/ui/drawer.tsx +240 -0
  82. package/src/ui/fab-menu.test.tsx +494 -0
  83. package/src/ui/fab-menu.tsx +739 -0
  84. package/src/ui/fab.test.tsx +232 -0
  85. package/src/ui/fab.tsx +505 -0
  86. package/src/ui/icon-button.test.tsx +515 -0
  87. package/src/ui/icon-button.tsx +525 -0
  88. package/src/ui/icon.test.tsx +197 -0
  89. package/src/ui/icon.tsx +179 -0
  90. package/src/ui/loading-indicator.test.tsx +73 -0
  91. package/src/ui/loading-indicator.tsx +312 -0
  92. package/src/ui/menu/context-menu.tsx +275 -0
  93. package/src/ui/menu/index.ts +77 -0
  94. package/src/ui/menu/menu-animations.ts +102 -0
  95. package/src/ui/menu/menu-context.tsx +99 -0
  96. package/src/ui/menu/menu-divider.tsx +47 -0
  97. package/src/ui/menu/menu-group.tsx +200 -0
  98. package/src/ui/menu/menu-item.tsx +294 -0
  99. package/src/ui/menu/menu-tokens.ts +208 -0
  100. package/src/ui/menu/menu-types.ts +313 -0
  101. package/src/ui/menu/menu.test.tsx +624 -0
  102. package/src/ui/menu/menu.tsx +289 -0
  103. package/src/ui/menu/sub-menu.tsx +223 -0
  104. package/src/ui/menu/vertical-menu.tsx +382 -0
  105. package/src/ui/navigation-rail.test.tsx +404 -0
  106. package/src/ui/navigation-rail.tsx +604 -0
  107. package/src/ui/progress-indicator/circular.tsx +248 -0
  108. package/src/ui/progress-indicator/hooks.ts +51 -0
  109. package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
  110. package/src/ui/progress-indicator/linear-flat.tsx +83 -0
  111. package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
  112. package/src/ui/progress-indicator/linear.tsx +143 -0
  113. package/src/ui/progress-indicator/types.ts +158 -0
  114. package/src/ui/progress-indicator/utils.ts +73 -0
  115. package/src/ui/radio-button.test.tsx +407 -0
  116. package/src/ui/radio-button.tsx +551 -0
  117. package/src/ui/ripple.test.tsx +72 -0
  118. package/src/ui/ripple.tsx +234 -0
  119. package/src/ui/scroll-area.test.tsx +58 -0
  120. package/src/ui/scroll-area.tsx +139 -0
  121. package/src/ui/search/animated-placeholder.tsx +145 -0
  122. package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
  123. package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
  124. package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
  125. package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
  126. package/src/ui/search/index.ts +44 -0
  127. package/src/ui/search/search-bar.tsx +220 -0
  128. package/src/ui/search/search-context.tsx +42 -0
  129. package/src/ui/search/search-view-docked.tsx +194 -0
  130. package/src/ui/search/search-view-fullscreen.tsx +247 -0
  131. package/src/ui/search/search.test.tsx +233 -0
  132. package/src/ui/search/search.tokens.ts +134 -0
  133. package/src/ui/search/search.tsx +131 -0
  134. package/src/ui/search/search.types.ts +154 -0
  135. package/src/ui/search/trailing-action.tsx +49 -0
  136. package/src/ui/shared/constants.ts +122 -0
  137. package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
  138. package/src/ui/slider/hooks/useSliderMath.ts +195 -0
  139. package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
  140. package/src/ui/slider/range-slider.tsx +561 -0
  141. package/src/ui/slider/slider-thumb.tsx +379 -0
  142. package/src/ui/slider/slider-track.tsx +912 -0
  143. package/src/ui/slider/slider.tokens.ts +189 -0
  144. package/src/ui/slider/slider.tsx +259 -0
  145. package/src/ui/slider/slider.types.ts +288 -0
  146. package/src/ui/snackbar/index.ts +20 -0
  147. package/src/ui/snackbar/snackbar.test.tsx +338 -0
  148. package/src/ui/snackbar/snackbar.tsx +476 -0
  149. package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
  150. package/src/ui/switch/switch.stories.tsx +309 -0
  151. package/src/ui/switch/switch.test.tsx +243 -0
  152. package/src/ui/switch/switch.tokens.ts +89 -0
  153. package/src/ui/switch/switch.tsx +504 -0
  154. package/src/ui/switch/switch.types.ts +62 -0
  155. package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
  156. package/src/ui/tabs/tab.tsx +407 -0
  157. package/src/ui/tabs/tabs-content.tsx +89 -0
  158. package/src/ui/tabs/tabs-list.tsx +146 -0
  159. package/src/ui/tabs/tabs.test.tsx +290 -0
  160. package/src/ui/tabs/tabs.tokens.ts +121 -0
  161. package/src/ui/tabs/tabs.tsx +229 -0
  162. package/src/ui/tabs/tabs.types.ts +185 -0
  163. package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
  164. package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
  165. package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
  166. package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
  167. package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
  168. package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
  169. package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
  170. package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
  171. package/src/ui/text-field/text-field.test.tsx +454 -0
  172. package/src/ui/text-field/text-field.tokens.ts +104 -0
  173. package/src/ui/text-field/text-field.tsx +548 -0
  174. package/src/ui/text-field/text-field.types.ts +180 -0
  175. package/src/ui/theme-provider/index.tsx +190 -0
  176. package/src/ui/toc.test.tsx +108 -0
  177. package/src/ui/toc.tsx +172 -0
  178. package/src/ui/tooltip/plain-tooltip.tsx +63 -0
  179. package/src/ui/tooltip/rich-tooltip.tsx +94 -0
  180. package/src/ui/tooltip/tooltip-box.tsx +266 -0
  181. package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
  182. package/src/ui/tooltip/tooltip.tokens.ts +26 -0
  183. package/src/ui/tooltip/tooltip.types.ts +70 -0
  184. package/src/ui/tooltip/use-tooltip-position.ts +208 -0
  185. package/src/ui/tooltip/use-tooltip-state.ts +41 -0
  186. package/src/ui/typography/__tests__/typography.test.tsx +170 -0
  187. package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
  188. package/src/ui/typography/type-scale-tokens.ts +205 -0
  189. package/src/ui/typography/typography-key-tokens.ts +43 -0
  190. package/src/ui/typography/typography-tokens.ts +360 -0
  191. package/src/ui/typography/typography.css +22 -0
  192. package/src/ui/typography/typography.tsx +559 -0
  193. package/test-render.tsx +4 -0
  194. package/test-shadow.html +26 -0
  195. package/test_output.txt +164 -0
  196. package/test_output_v2.txt +5 -0
  197. package/tsconfig.build.json +10 -0
  198. package/tsconfig.json +18 -0
  199. package/tsup.config.ts +20 -0
  200. package/vitest.config.ts +11 -0
  201. package/dist/hooks/useClickOutside.d.ts +0 -8
  202. package/dist/hooks/useMediaQuery.d.ts +0 -11
  203. package/dist/hooks/useRipple.d.ts +0 -26
  204. package/dist/lib/material-symbols-preconnect.d.ts +0 -42
  205. package/dist/lib/theme-utils.d.ts +0 -63
  206. package/dist/lib/utils.d.ts +0 -2
  207. package/dist/types/index.d.ts +0 -1
  208. package/dist/types/md3.d.ts +0 -14
  209. package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
  210. package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
  211. package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
  212. package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
  213. package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
  214. package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
  215. package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
  216. package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
  217. package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
  218. package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
  219. package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
  220. package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
  221. package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
  222. package/dist/ui/app-bar/search-view.d.ts +0 -54
  223. package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
  224. package/dist/ui/badge.d.ts +0 -125
  225. package/dist/ui/button-group.d.ts +0 -59
  226. package/dist/ui/button.d.ts +0 -148
  227. package/dist/ui/card.d.ts +0 -62
  228. package/dist/ui/checkbox.d.ts +0 -82
  229. package/dist/ui/chip.d.ts +0 -110
  230. package/dist/ui/code-block.d.ts +0 -14
  231. package/dist/ui/dialog.d.ts +0 -111
  232. package/dist/ui/divider.d.ts +0 -164
  233. package/dist/ui/drawer.d.ts +0 -39
  234. package/dist/ui/dropdown.d.ts +0 -29
  235. package/dist/ui/fab-menu.d.ts +0 -204
  236. package/dist/ui/fab.d.ts +0 -162
  237. package/dist/ui/icon-button.d.ts +0 -131
  238. package/dist/ui/icon.d.ts +0 -88
  239. package/dist/ui/loading-indicator.d.ts +0 -42
  240. package/dist/ui/navigation-rail.d.ts +0 -29
  241. package/dist/ui/progress-indicator/circular.d.ts +0 -3
  242. package/dist/ui/progress-indicator/hooks.d.ts +0 -3
  243. package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
  244. package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
  245. package/dist/ui/progress-indicator/linear.d.ts +0 -3
  246. package/dist/ui/progress-indicator/types.d.ts +0 -151
  247. package/dist/ui/progress-indicator/utils.d.ts +0 -3
  248. package/dist/ui/radio-button.d.ts +0 -106
  249. package/dist/ui/ripple.d.ts +0 -126
  250. package/dist/ui/scroll-area.d.ts +0 -27
  251. package/dist/ui/search/animated-placeholder.d.ts +0 -54
  252. package/dist/ui/search/hooks/use-search-keyboard.d.ts +0 -32
  253. package/dist/ui/search/hooks/use-search-view-focus.d.ts +0 -6
  254. package/dist/ui/search/index.d.ts +0 -27
  255. package/dist/ui/search/search-bar.d.ts +0 -32
  256. package/dist/ui/search/search-context.d.ts +0 -24
  257. package/dist/ui/search/search-view-docked.d.ts +0 -25
  258. package/dist/ui/search/search-view-fullscreen.d.ts +0 -36
  259. package/dist/ui/search/search.d.ts +0 -50
  260. package/dist/ui/search/search.tokens.d.ts +0 -112
  261. package/dist/ui/search/search.types.d.ts +0 -131
  262. package/dist/ui/search/trailing-action.d.ts +0 -9
  263. package/dist/ui/shared/constants.d.ts +0 -86
  264. package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
  265. package/dist/ui/slider/range-slider.d.ts +0 -47
  266. package/dist/ui/slider/slider-thumb.d.ts +0 -33
  267. package/dist/ui/slider/slider-track.d.ts +0 -25
  268. package/dist/ui/slider/slider.d.ts +0 -60
  269. package/dist/ui/slider/slider.tokens.d.ts +0 -151
  270. package/dist/ui/slider/slider.types.d.ts +0 -259
  271. package/dist/ui/snackbar/index.d.ts +0 -6
  272. package/dist/ui/snackbar/snackbar.d.ts +0 -197
  273. package/dist/ui/switch/switch.d.ts +0 -30
  274. package/dist/ui/switch/switch.stories.d.ts +0 -48
  275. package/dist/ui/switch/switch.tokens.d.ts +0 -67
  276. package/dist/ui/switch/switch.types.d.ts +0 -59
  277. package/dist/ui/tabs/tab.d.ts +0 -43
  278. package/dist/ui/tabs/tabs-content.d.ts +0 -36
  279. package/dist/ui/tabs/tabs-list.d.ts +0 -40
  280. package/dist/ui/tabs/tabs.d.ts +0 -60
  281. package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
  282. package/dist/ui/tabs/tabs.types.d.ts +0 -172
  283. package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
  284. package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
  285. package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
  286. package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
  287. package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
  288. package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
  289. package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
  290. package/dist/ui/text-field/text-field.d.ts +0 -49
  291. package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
  292. package/dist/ui/text-field/text-field.types.d.ts +0 -126
  293. package/dist/ui/theme-provider/index.d.ts +0 -48
  294. package/dist/ui/toc.d.ts +0 -80
  295. package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
  296. package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
  297. package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
  298. package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
  299. package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
  300. package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
  301. package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
  302. package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
  303. package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
  304. package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
  305. package/dist/ui/typography/typography-tokens.d.ts +0 -220
  306. package/dist/ui/typography/typography.d.ts +0 -265
  307. /package/{dist/hooks/index.d.ts → src/hooks/index.ts} +0 -0
  308. /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
+ });
@@ -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);