@bug-on/md3-react 2.0.2 → 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 (296) hide show
  1. package/.turbo/turbo-build.log +33 -0
  2. package/CHANGELOG.md +55 -0
  3. package/dist/index.css +23 -0
  4. package/dist/index.css.d.ts +2 -0
  5. package/dist/index.d.mts +6127 -0
  6. package/dist/index.d.ts +6127 -69
  7. package/dist/index.js +2536 -665
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2443 -603
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/material-symbols-cdn.css.d.ts +2 -0
  12. package/dist/material-symbols-self-hosted.css.d.ts +2 -0
  13. package/dist/typography.css.d.ts +2 -0
  14. package/package.json +23 -19
  15. package/scripts/copy-assets.js +82 -0
  16. package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
  17. package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  18. package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  19. package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  20. package/src/assets/loading-indicator.svg +19 -0
  21. package/src/assets/material-symbols-cdn.css +65 -0
  22. package/src/assets/material-symbols-self-hosted.css +90 -0
  23. package/src/css.d.ts +20 -0
  24. package/{dist/hooks/index.d.ts → src/hooks/index.ts} +1 -0
  25. package/src/hooks/useClickOutside.ts +37 -0
  26. package/src/hooks/useMediaQuery.ts +28 -0
  27. package/src/hooks/useRipple.ts +88 -0
  28. package/src/index.css +23 -0
  29. package/src/index.ts +349 -0
  30. package/src/lib/material-symbols-preconnect.tsx +82 -0
  31. package/src/lib/theme-utils.ts +180 -0
  32. package/src/lib/utils.ts +6 -0
  33. package/src/test/button.test.tsx +59 -0
  34. package/src/test/icon.test.tsx +91 -0
  35. package/src/test/loading-indicator.test.tsx +128 -0
  36. package/src/test/progress-indicator.test.tsx +306 -0
  37. package/src/test/setup.ts +80 -0
  38. package/src/test/typography.test.tsx +206 -0
  39. package/src/types/index.ts +7 -0
  40. package/src/types/md3.ts +31 -0
  41. package/src/ui/Text.tsx +60 -0
  42. package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
  43. package/src/ui/app-bar/app-bar-column.tsx +99 -0
  44. package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
  45. package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
  46. package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
  47. package/src/ui/app-bar/app-bar-row.tsx +104 -0
  48. package/src/ui/app-bar/app-bar.test.tsx +87 -0
  49. package/src/ui/app-bar/app-bar.tokens.ts +223 -0
  50. package/src/ui/app-bar/app-bar.types.ts +441 -0
  51. package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
  52. package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
  53. package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
  54. package/src/ui/app-bar/docked-toolbar.tsx +54 -0
  55. package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
  56. package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
  57. package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
  58. package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
  59. package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
  60. package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
  61. package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
  62. package/src/ui/app-bar/search-app-bar.tsx +176 -0
  63. package/src/ui/app-bar/search-view.tsx +227 -0
  64. package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
  65. package/src/ui/app-bar/small-app-bar.tsx +203 -0
  66. package/src/ui/badge.test.tsx +345 -0
  67. package/src/ui/badge.tsx +282 -0
  68. package/src/ui/button-group.test.tsx +71 -0
  69. package/src/ui/button-group.tsx +350 -0
  70. package/src/ui/button.test.tsx +297 -0
  71. package/src/ui/button.tsx +669 -0
  72. package/src/ui/card.test.tsx +187 -0
  73. package/src/ui/card.tsx +259 -0
  74. package/src/ui/checkbox.test.tsx +423 -0
  75. package/src/ui/checkbox.tsx +525 -0
  76. package/src/ui/chip.test.tsx +292 -0
  77. package/src/ui/chip.tsx +548 -0
  78. package/src/ui/code-block.tsx +219 -0
  79. package/src/ui/dialog.test.tsx +300 -0
  80. package/src/ui/dialog.tsx +384 -0
  81. package/src/ui/divider.test.tsx +314 -0
  82. package/src/ui/divider.tsx +412 -0
  83. package/src/ui/drawer.tsx +240 -0
  84. package/src/ui/fab-menu.test.tsx +494 -0
  85. package/src/ui/fab-menu.tsx +739 -0
  86. package/src/ui/fab.test.tsx +232 -0
  87. package/src/ui/fab.tsx +505 -0
  88. package/src/ui/icon-button.test.tsx +515 -0
  89. package/src/ui/icon-button.tsx +525 -0
  90. package/src/ui/icon.test.tsx +197 -0
  91. package/src/ui/icon.tsx +179 -0
  92. package/src/ui/loading-indicator.test.tsx +73 -0
  93. package/src/ui/loading-indicator.tsx +312 -0
  94. package/src/ui/menu/context-menu.tsx +275 -0
  95. package/src/ui/menu/index.ts +77 -0
  96. package/src/ui/menu/menu-animations.ts +102 -0
  97. package/src/ui/menu/menu-context.tsx +99 -0
  98. package/src/ui/menu/menu-divider.tsx +47 -0
  99. package/src/ui/menu/menu-group.tsx +200 -0
  100. package/src/ui/menu/menu-item.tsx +294 -0
  101. package/src/ui/menu/menu-tokens.ts +208 -0
  102. package/src/ui/menu/menu-types.ts +313 -0
  103. package/src/ui/menu/menu.test.tsx +624 -0
  104. package/src/ui/menu/menu.tsx +289 -0
  105. package/src/ui/menu/sub-menu.tsx +223 -0
  106. package/src/ui/menu/vertical-menu.tsx +382 -0
  107. package/src/ui/navigation-rail.test.tsx +404 -0
  108. package/src/ui/navigation-rail.tsx +604 -0
  109. package/src/ui/progress-indicator/circular.tsx +248 -0
  110. package/src/ui/progress-indicator/hooks.ts +51 -0
  111. package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
  112. package/src/ui/progress-indicator/linear-flat.tsx +83 -0
  113. package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
  114. package/src/ui/progress-indicator/linear.tsx +143 -0
  115. package/src/ui/progress-indicator/types.ts +158 -0
  116. package/src/ui/progress-indicator/utils.ts +73 -0
  117. package/src/ui/radio-button.test.tsx +407 -0
  118. package/src/ui/radio-button.tsx +551 -0
  119. package/src/ui/ripple.test.tsx +72 -0
  120. package/src/ui/ripple.tsx +234 -0
  121. package/src/ui/scroll-area.test.tsx +58 -0
  122. package/src/ui/scroll-area.tsx +139 -0
  123. package/src/ui/search/animated-placeholder.tsx +145 -0
  124. package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
  125. package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
  126. package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
  127. package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
  128. package/src/ui/search/index.ts +44 -0
  129. package/src/ui/search/search-bar.tsx +220 -0
  130. package/src/ui/search/search-context.tsx +42 -0
  131. package/src/ui/search/search-view-docked.tsx +194 -0
  132. package/src/ui/search/search-view-fullscreen.tsx +247 -0
  133. package/src/ui/search/search.test.tsx +233 -0
  134. package/src/ui/search/search.tokens.ts +134 -0
  135. package/src/ui/search/search.tsx +131 -0
  136. package/src/ui/search/search.types.ts +154 -0
  137. package/src/ui/search/trailing-action.tsx +49 -0
  138. package/src/ui/shared/constants.ts +122 -0
  139. package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
  140. package/src/ui/slider/hooks/useSliderMath.ts +195 -0
  141. package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
  142. package/src/ui/slider/range-slider.tsx +561 -0
  143. package/src/ui/slider/slider-thumb.tsx +379 -0
  144. package/src/ui/slider/slider-track.tsx +912 -0
  145. package/src/ui/slider/slider.tokens.ts +189 -0
  146. package/src/ui/slider/slider.tsx +259 -0
  147. package/src/ui/slider/slider.types.ts +288 -0
  148. package/src/ui/snackbar/index.ts +20 -0
  149. package/src/ui/snackbar/snackbar.test.tsx +338 -0
  150. package/src/ui/snackbar/snackbar.tsx +476 -0
  151. package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
  152. package/src/ui/switch/switch.stories.tsx +309 -0
  153. package/src/ui/switch/switch.test.tsx +243 -0
  154. package/src/ui/switch/switch.tokens.ts +89 -0
  155. package/src/ui/switch/switch.tsx +504 -0
  156. package/src/ui/switch/switch.types.ts +62 -0
  157. package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
  158. package/src/ui/tabs/tab.tsx +407 -0
  159. package/src/ui/tabs/tabs-content.tsx +89 -0
  160. package/src/ui/tabs/tabs-list.tsx +146 -0
  161. package/src/ui/tabs/tabs.test.tsx +290 -0
  162. package/src/ui/tabs/tabs.tokens.ts +121 -0
  163. package/src/ui/tabs/tabs.tsx +229 -0
  164. package/src/ui/tabs/tabs.types.ts +185 -0
  165. package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
  166. package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
  167. package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
  168. package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
  169. package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
  170. package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
  171. package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
  172. package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
  173. package/src/ui/text-field/text-field.test.tsx +454 -0
  174. package/src/ui/text-field/text-field.tokens.ts +104 -0
  175. package/src/ui/text-field/text-field.tsx +548 -0
  176. package/src/ui/text-field/text-field.types.ts +180 -0
  177. package/src/ui/theme-provider/index.tsx +190 -0
  178. package/src/ui/toc.test.tsx +108 -0
  179. package/src/ui/toc.tsx +172 -0
  180. package/src/ui/tooltip/plain-tooltip.tsx +63 -0
  181. package/src/ui/tooltip/rich-tooltip.tsx +94 -0
  182. package/src/ui/tooltip/tooltip-box.tsx +266 -0
  183. package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
  184. package/src/ui/tooltip/tooltip.tokens.ts +26 -0
  185. package/src/ui/tooltip/tooltip.types.ts +70 -0
  186. package/src/ui/tooltip/use-tooltip-position.ts +208 -0
  187. package/src/ui/tooltip/use-tooltip-state.ts +41 -0
  188. package/src/ui/typography/__tests__/typography.test.tsx +170 -0
  189. package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
  190. package/src/ui/typography/type-scale-tokens.ts +205 -0
  191. package/src/ui/typography/typography-key-tokens.ts +43 -0
  192. package/src/ui/typography/typography-tokens.ts +360 -0
  193. package/src/ui/typography/typography.css +22 -0
  194. package/src/ui/typography/typography.tsx +559 -0
  195. package/test-render.tsx +4 -0
  196. package/test-shadow.html +26 -0
  197. package/test_output.txt +164 -0
  198. package/test_output_v2.txt +5 -0
  199. package/tsconfig.build.json +10 -0
  200. package/tsconfig.json +18 -0
  201. package/tsup.config.ts +20 -0
  202. package/vitest.config.ts +11 -0
  203. package/dist/hooks/useMediaQuery.d.ts +0 -11
  204. package/dist/hooks/useRipple.d.ts +0 -26
  205. package/dist/lib/material-symbols-preconnect.d.ts +0 -42
  206. package/dist/lib/theme-utils.d.ts +0 -63
  207. package/dist/lib/utils.d.ts +0 -2
  208. package/dist/types/index.d.ts +0 -1
  209. package/dist/types/md3.d.ts +0 -14
  210. package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
  211. package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
  212. package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
  213. package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
  214. package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
  215. package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
  216. package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
  217. package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
  218. package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
  219. package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
  220. package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
  221. package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
  222. package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
  223. package/dist/ui/app-bar/search-view.d.ts +0 -54
  224. package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
  225. package/dist/ui/badge.d.ts +0 -125
  226. package/dist/ui/button-group.d.ts +0 -59
  227. package/dist/ui/button.d.ts +0 -148
  228. package/dist/ui/card.d.ts +0 -62
  229. package/dist/ui/checkbox.d.ts +0 -82
  230. package/dist/ui/chip.d.ts +0 -110
  231. package/dist/ui/code-block.d.ts +0 -14
  232. package/dist/ui/dialog.d.ts +0 -111
  233. package/dist/ui/divider.d.ts +0 -164
  234. package/dist/ui/drawer.d.ts +0 -39
  235. package/dist/ui/dropdown.d.ts +0 -29
  236. package/dist/ui/fab-menu.d.ts +0 -204
  237. package/dist/ui/fab.d.ts +0 -162
  238. package/dist/ui/icon-button.d.ts +0 -131
  239. package/dist/ui/icon.d.ts +0 -88
  240. package/dist/ui/loading-indicator.d.ts +0 -42
  241. package/dist/ui/navigation-rail.d.ts +0 -29
  242. package/dist/ui/progress-indicator/circular.d.ts +0 -3
  243. package/dist/ui/progress-indicator/hooks.d.ts +0 -3
  244. package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
  245. package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
  246. package/dist/ui/progress-indicator/linear.d.ts +0 -3
  247. package/dist/ui/progress-indicator/types.d.ts +0 -151
  248. package/dist/ui/progress-indicator/utils.d.ts +0 -3
  249. package/dist/ui/radio-button.d.ts +0 -106
  250. package/dist/ui/ripple.d.ts +0 -126
  251. package/dist/ui/scroll-area.d.ts +0 -27
  252. package/dist/ui/shared/constants.d.ts +0 -86
  253. package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
  254. package/dist/ui/slider/range-slider.d.ts +0 -47
  255. package/dist/ui/slider/slider-thumb.d.ts +0 -33
  256. package/dist/ui/slider/slider-track.d.ts +0 -25
  257. package/dist/ui/slider/slider.d.ts +0 -60
  258. package/dist/ui/slider/slider.tokens.d.ts +0 -151
  259. package/dist/ui/slider/slider.types.d.ts +0 -259
  260. package/dist/ui/snackbar/index.d.ts +0 -6
  261. package/dist/ui/snackbar/snackbar.d.ts +0 -197
  262. package/dist/ui/switch/switch.d.ts +0 -30
  263. package/dist/ui/switch/switch.stories.d.ts +0 -48
  264. package/dist/ui/switch/switch.tokens.d.ts +0 -67
  265. package/dist/ui/switch/switch.types.d.ts +0 -59
  266. package/dist/ui/tabs/tab.d.ts +0 -43
  267. package/dist/ui/tabs/tabs-content.d.ts +0 -36
  268. package/dist/ui/tabs/tabs-list.d.ts +0 -40
  269. package/dist/ui/tabs/tabs.d.ts +0 -60
  270. package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
  271. package/dist/ui/tabs/tabs.types.d.ts +0 -172
  272. package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
  273. package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
  274. package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
  275. package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
  276. package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
  277. package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
  278. package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
  279. package/dist/ui/text-field/text-field.d.ts +0 -49
  280. package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
  281. package/dist/ui/text-field/text-field.types.d.ts +0 -126
  282. package/dist/ui/theme-provider/index.d.ts +0 -48
  283. package/dist/ui/toc.d.ts +0 -80
  284. package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
  285. package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
  286. package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
  287. package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
  288. package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
  289. package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
  290. package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
  291. package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
  292. package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
  293. package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
  294. package/dist/ui/typography/typography-tokens.d.ts +0 -220
  295. package/dist/ui/typography/typography.d.ts +0 -265
  296. /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @file icon.test.tsx
3
+ *
4
+ * Unit tests for the Material Symbols <Icon /> component.
5
+ *
6
+ * Tests cover:
7
+ * - Correct text content (icon name as ligature)
8
+ * - Font-family for each variant (outlined / rounded / sharp)
9
+ * - font-variation-settings reflecting fill / weight / grade / opticalSize
10
+ * - aria-hidden attribute
11
+ * - className merging
12
+ * - Static render (plain span) vs animated render (motion span)
13
+ * - size prop overrides font-size independently of opticalSize
14
+ */
15
+
16
+ import { render, screen } from "@testing-library/react";
17
+ import { describe, expect, it } from "vitest";
18
+ import { Icon } from "./icon";
19
+
20
+ // ─────────────────────────────────────────────────────────────────────────────
21
+ // Helpers
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ /** Returns the rendered span's inline style object. */
25
+ function getStyle(element: HTMLElement): CSSStyleDeclaration {
26
+ return element.style;
27
+ }
28
+
29
+ // ─────────────────────────────────────────────────────────────────────────────
30
+ // Tests
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+
33
+ describe("Icon", () => {
34
+ // ── Rendering ─────────────────────────────────────────────────────────────
35
+
36
+ it("renders the icon name as text content", () => {
37
+ render(<Icon name="home" />);
38
+ expect(screen.getByText("home")).toBeInTheDocument();
39
+ });
40
+
41
+ it("renders the icon name with underscores intact", () => {
42
+ render(<Icon name="arrow_forward" />);
43
+ expect(screen.getByText("arrow_forward")).toBeInTheDocument();
44
+ });
45
+
46
+ // ── Accessibility ─────────────────────────────────────────────────────────
47
+
48
+ it("sets aria-hidden='true' by default", () => {
49
+ render(<Icon name="home" />);
50
+ const el = screen.getByText("home");
51
+ expect(el).toHaveAttribute("aria-hidden", "true");
52
+ });
53
+
54
+ // ── Variant font-family ───────────────────────────────────────────────────
55
+
56
+ it("uses 'Material Symbols Outlined' font by default (outlined variant)", () => {
57
+ render(<Icon name="home" />);
58
+ const style = getStyle(screen.getByText("home"));
59
+ expect(style.fontFamily).toContain("Material Symbols Outlined");
60
+ });
61
+
62
+ it("uses 'Material Symbols Rounded' for variant='rounded'", () => {
63
+ render(<Icon name="home" variant="rounded" />);
64
+ const style = getStyle(screen.getByText("home"));
65
+ expect(style.fontFamily).toContain("Material Symbols Rounded");
66
+ });
67
+
68
+ it("uses 'Material Symbols Sharp' for variant='sharp'", () => {
69
+ render(<Icon name="home" variant="sharp" />);
70
+ const style = getStyle(screen.getByText("home"));
71
+ expect(style.fontFamily).toContain("Material Symbols Sharp");
72
+ });
73
+
74
+ // ── Font size ─────────────────────────────────────────────────────────────
75
+
76
+ it("defaults font-size to opticalSize (24px) when size is not provided", () => {
77
+ render(<Icon name="home" />);
78
+ const style = getStyle(screen.getByText("home"));
79
+ expect(style.fontSize).toBe("24px");
80
+ });
81
+
82
+ it("uses explicit size prop for font-size (overrides opticalSize)", () => {
83
+ render(<Icon name="home" size={18} opticalSize={20} />);
84
+ const style = getStyle(screen.getByText("home"));
85
+ expect(style.fontSize).toBe("18px");
86
+ });
87
+
88
+ it("applies opticalSize=48 as font-size when no explicit size given", () => {
89
+ render(<Icon name="home" opticalSize={48} />);
90
+ const style = getStyle(screen.getByText("home"));
91
+ expect(style.fontSize).toBe("48px");
92
+ });
93
+
94
+ // ── font-variation-settings ───────────────────────────────────────────────
95
+
96
+ it("applies default font-variation-settings (fill=0, wght=400, GRAD=0, opsz=24)", () => {
97
+ render(<Icon name="home" />);
98
+ const style = getStyle(screen.getByText("home"));
99
+ expect(style.fontVariationSettings).toBe(
100
+ "'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24",
101
+ );
102
+ });
103
+
104
+ it("reflects fill=1 in font-variation-settings", () => {
105
+ render(<Icon name="favorite" fill={1} />);
106
+ const style = getStyle(screen.getByText("favorite"));
107
+ expect(style.fontVariationSettings).toContain("'FILL' 1");
108
+ });
109
+
110
+ it("reflects weight=700 in font-variation-settings", () => {
111
+ render(<Icon name="home" weight={700} />);
112
+ const style = getStyle(screen.getByText("home"));
113
+ expect(style.fontVariationSettings).toContain("'wght' 700");
114
+ });
115
+
116
+ it("reflects grade=-25 in font-variation-settings", () => {
117
+ render(<Icon name="home" grade={-25} />);
118
+ const style = getStyle(screen.getByText("home"));
119
+ expect(style.fontVariationSettings).toContain("'GRAD' -25");
120
+ });
121
+
122
+ it("reflects opticalSize=48 in font-variation-settings opsz axis", () => {
123
+ render(<Icon name="home" opticalSize={48} />);
124
+ const style = getStyle(screen.getByText("home"));
125
+ expect(style.fontVariationSettings).toContain("'opsz' 48");
126
+ });
127
+
128
+ it("composes all axes correctly when all props are provided", () => {
129
+ render(
130
+ <Icon name="star" fill={1} weight={300} grade={200} opticalSize={40} />,
131
+ );
132
+ const style = getStyle(screen.getByText("star"));
133
+ expect(style.fontVariationSettings).toBe(
134
+ "'FILL' 1, 'wght' 300, 'GRAD' 200, 'opsz' 40",
135
+ );
136
+ });
137
+
138
+ // ── className merging ─────────────────────────────────────────────────────
139
+
140
+ it("includes 'md-icon' base class by default", () => {
141
+ render(<Icon name="home" />);
142
+ const el = screen.getByText("home");
143
+ expect(el.className).toContain("md-icon");
144
+ });
145
+
146
+ it("merges additional className with md-icon", () => {
147
+ render(<Icon name="home" className="text-primary" />);
148
+ const el = screen.getByText("home");
149
+ expect(el.className).toContain("md-icon");
150
+ expect(el.className).toContain("text-primary");
151
+ });
152
+
153
+ // ── Static vs animated render ─────────────────────────────────────────────
154
+
155
+ it("renders a plain <span> when animateFill is false (default)", () => {
156
+ const { container } = render(<Icon name="home" />);
157
+ // motion/react m.span renders as a <span> in DOM, but static span has no
158
+ // data-framer / motion attributes injected. We check the element is a span.
159
+ const el = container.querySelector("span");
160
+ expect(el).not.toBeNull();
161
+ expect(el?.textContent).toBe("home");
162
+ });
163
+
164
+ it("still renders as a span element when animateFill=true (motion renders span)", () => {
165
+ const { container } = render(<Icon name="home" animateFill />);
166
+ // m.span renders as a real <span> in DOM
167
+ const el = container.querySelector("span");
168
+ expect(el).not.toBeNull();
169
+ expect(el?.textContent).toBe("home");
170
+ });
171
+
172
+ it("sets aria-hidden on animated span too", () => {
173
+ render(<Icon name="home" animateFill />);
174
+ const el = screen.getByText("home");
175
+ expect(el).toHaveAttribute("aria-hidden", "true");
176
+ });
177
+
178
+ // ── style prop passthrough ────────────────────────────────────────────────
179
+
180
+ it("merges custom style with computed style", () => {
181
+ render(<Icon name="home" style={{ color: "red" }} />);
182
+ const el = screen.getByText("home");
183
+ expect(el.style.color).toBe("red");
184
+ // Computed props still present
185
+ expect(el.style.fontFamily).toContain("Material Symbols Outlined");
186
+ });
187
+
188
+ // ── displayName ───────────────────────────────────────────────────────────
189
+
190
+ it("has displayName 'Icon'", () => {
191
+ // React.memo wraps the component; access display name via the inner type
192
+ // biome-ignore lint/suspicious/noExplicitAny: accessing React internals for testing
193
+ expect((Icon as any).type?.displayName ?? (Icon as any).displayName).toBe(
194
+ "Icon",
195
+ );
196
+ });
197
+ });
@@ -0,0 +1,179 @@
1
+ import { domMax, LazyMotion, m } from "motion/react";
2
+ import * as React from "react";
3
+ import { cn } from "../lib/utils";
4
+ import { SPRING_TRANSITION_FAST } from "./shared/constants";
5
+
6
+ // @internal — font must be loaded via '@bug-on/md3-react/material-symbols.css'
7
+ const VARIANT_FONT: Record<NonNullable<IconProps["variant"]>, string> = {
8
+ outlined: "'Material Symbols Outlined'",
9
+ rounded: "'Material Symbols Rounded'",
10
+ sharp: "'Material Symbols Sharp'",
11
+ };
12
+
13
+ /**
14
+ * Props cho component {@link Icon}.
15
+ *
16
+ * Tất cả các trục biến thiên (variable font axes) được map trực tiếp sang `font-variation-settings`.
17
+ */
18
+ export interface IconProps extends React.HTMLAttributes<HTMLSpanElement> {
19
+ /**
20
+ * Tên của Material Symbol theo định dạng snake_case.
21
+ * @example "home", "arrow_forward", "settings"
22
+ * @see https://fonts.google.com/icons
23
+ */
24
+ name: string;
25
+
26
+ /**
27
+ * Kiểu hình học (Geometric style variant) — tương ứng với font family được tải.
28
+ * @default "outlined"
29
+ */
30
+ variant?: "outlined" | "rounded" | "sharp";
31
+
32
+ /**
33
+ * Trục FILL. `0` = outlined (viền), `1` = filled (tràn màu).
34
+ * Có hiệu ứng spring khi `animateFill` là true.
35
+ * @default 0
36
+ */
37
+ fill?: 0 | 1;
38
+
39
+ /**
40
+ * Trục wght — độ dày của nét (stroke weight). Nên khớp với độ dày text xung quanh.
41
+ * @default 400
42
+ */
43
+ weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700;
44
+
45
+ /**
46
+ * Trục GRAD — tinh chỉnh độ dày thị giác mà không ảnh hưởng tới layout.
47
+ * Dùng mức `-25` trên nền tối để bù trừ hiệu ứng phát sáng (halation).
48
+ * @default 0
49
+ */
50
+ grade?: -50 | -25 | 0 | 100 | 200;
51
+
52
+ /**
53
+ * Trục opsz — kích thước quang học (optical size) tính bằng dp. Dùng để thiết lập `font-size` nếu không truyền `size`.
54
+ * Hãy để giá trị khớp với pixel sẽ render ra để thấy chất lượng tốt nhất.
55
+ * @default 24
56
+ */
57
+ opticalSize?: 20 | 24 | 40 | 48;
58
+
59
+ /**
60
+ * Ghi đè trực tiếp `font-size` bằng px. Trục `opsz` vẫn sẽ tuân theo thuộc tính `opticalSize`.
61
+ * @example size={18} opticalSize={20}
62
+ */
63
+ size?: number | "inherit";
64
+
65
+ /**
66
+ * Kích hoạt hiệu ứng spring mượt mà khi chuyển đổi giá trị FILL (sử dụng cấu hình `SPRING_TRANSITION_FAST`).
67
+ * Yêu cầu dependency `motion/react`.
68
+ * @default false
69
+ * @example <Icon name="favorite" fill={isLiked ? 1 : 0} animateFill />
70
+ */
71
+ animateFill?: boolean;
72
+ }
73
+
74
+ const IconComponent = React.forwardRef<HTMLSpanElement, IconProps>(
75
+ (
76
+ {
77
+ name,
78
+ variant = "outlined",
79
+ fill = 0,
80
+ weight = 400,
81
+ grade = 0,
82
+ opticalSize = 24,
83
+ size,
84
+ animateFill = false,
85
+ className,
86
+ style,
87
+ ...restProps
88
+ },
89
+ ref,
90
+ ) => {
91
+ const fontVariationSettings = `'FILL' ${fill}, 'wght' ${weight}, 'GRAD' ${grade}, 'opsz' ${opticalSize}`;
92
+
93
+ const computedStyle: React.CSSProperties = {
94
+ fontFamily: VARIANT_FONT[variant],
95
+ fontSize:
96
+ size === "inherit"
97
+ ? "inherit"
98
+ : size != null
99
+ ? `${size}px`
100
+ : `${opticalSize}px`,
101
+ fontVariationSettings,
102
+ ...style,
103
+ };
104
+
105
+ if (animateFill) {
106
+ return (
107
+ <LazyMotion features={domMax} strict>
108
+ <m.span
109
+ ref={ref}
110
+ className={cn(
111
+ "md-icon inline-flex items-center justify-center shrink-0 select-none",
112
+ className,
113
+ )}
114
+ aria-hidden="true"
115
+ animate={{ fontVariationSettings }}
116
+ transition={SPRING_TRANSITION_FAST}
117
+ style={computedStyle}
118
+ // biome-ignore lint/suspicious/noExplicitAny: motion v12 HTMLMotionProps conflicts with React's event types
119
+ {...(restProps as any)}
120
+ >
121
+ {name}
122
+ </m.span>
123
+ </LazyMotion>
124
+ );
125
+ }
126
+
127
+ return (
128
+ <span
129
+ ref={ref}
130
+ className={cn(
131
+ "md-icon inline-flex items-center justify-center shrink-0 select-none",
132
+ className,
133
+ )}
134
+ aria-hidden="true"
135
+ style={computedStyle}
136
+ {...restProps}
137
+ >
138
+ {name}
139
+ </span>
140
+ );
141
+ },
142
+ );
143
+
144
+ IconComponent.displayName = "Icon";
145
+
146
+ /**
147
+ * Component hiển thị Icon bằng Material Symbols (variable font).
148
+ *
149
+ * Hãy đảm bảo đã import CSS chứa font trước khi dùng:
150
+ * ```ts
151
+ * import '@bug-on/md3-react/material-symbols.css';
152
+ * ```
153
+ *
154
+ * @remarks
155
+ * - Đặt tên icon dùng snake_case: `"arrow_forward"`, KHÔNG PHẢI `"ArrowForward"`.
156
+ * - Thuộc tính `aria-hidden="true"` được tự động thêm vào — bạn cần thêm label đọc bằng giọng nói (accessible labels) ở phần tử cha.
157
+ *
158
+ * @example
159
+ * ```tsx
160
+ * // Icon cơ bản
161
+ * <Icon name="home" />
162
+ *
163
+ * // Tùy chỉnh trực quan (filled, nét dày)
164
+ * <Icon name="favorite" variant="rounded" fill={1} weight={300} />
165
+ *
166
+ * // Animate khi trạng thái thay đổi
167
+ * <Icon name="bookmark" fill={saved ? 1 : 0} animateFill />
168
+ *
169
+ * // Đổi kích thước icon cụ thể
170
+ * <Icon name="close" size={18} opticalSize={20} />
171
+ *
172
+ * // Kết hợp với các component khác
173
+ * <Button icon={<Icon name="add" />}>Thêm vào giỏ</Button>
174
+ * ```
175
+ *
176
+ * @see https://fonts.google.com/icons
177
+ * @see https://m3.material.io/styles/icons/overview
178
+ */
179
+ export const Icon = React.memo(IconComponent);
@@ -0,0 +1,73 @@
1
+ import { act, render } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { LoadingIndicator } from "./loading-indicator";
4
+
5
+ describe("LoadingIndicator Component", () => {
6
+ beforeEach(() => {
7
+ vi.useFakeTimers();
8
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation(
9
+ (cb: FrameRequestCallback) => {
10
+ return setTimeout(() => cb(Date.now()), 16) as unknown as number;
11
+ },
12
+ );
13
+ vi.spyOn(window, "cancelAnimationFrame").mockImplementation(
14
+ (id: number) => {
15
+ clearTimeout(id);
16
+ },
17
+ );
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks();
22
+ vi.useRealTimers();
23
+ });
24
+
25
+ it("renders an <svg> element with correct structure", () => {
26
+ const { container } = render(
27
+ <LoadingIndicator size={24} aria-label="Loading" />,
28
+ );
29
+ const svg = container.querySelector("svg");
30
+ expect(svg).toBeInTheDocument();
31
+ expect(svg).toHaveAttribute("viewBox", "4 4 40 40");
32
+ expect(svg).toHaveAttribute("width", "24");
33
+ expect(svg).toHaveAttribute("height", "24");
34
+ });
35
+
36
+ it("delays rendering of <animate> tags by 1 frame to prevent SMIL freezing", async () => {
37
+ const { container } = render(<LoadingIndicator aria-label="Loading" />);
38
+ const svg = container.querySelector("svg");
39
+
40
+ // Ngay Frame 0 (lúc thẻ SVG vừa vẽ lên DOM), chưa được phép có <animate>
41
+ // để tránh đụng độ Chromium layout optimization (culling) gây tê liệt animation
42
+ expect(svg?.querySelector("animate")).toBeNull();
43
+ expect(svg?.querySelector("animateTransform")).toBeNull();
44
+
45
+ // Tua nhanh thời gian vượt qua 16ms để giả lập hoàn tất requestAnimationFrame
46
+ await act(async () => {
47
+ vi.advanceTimersByTime(20);
48
+ });
49
+
50
+ // Sang Frame 1, thẻ <animate> phải được inject vào trong DOM để khởi sinh đồng hồ SMIL
51
+ expect(svg?.querySelector("animate")).not.toBeNull();
52
+ expect(svg?.querySelector("animateTransform")).not.toBeNull();
53
+ expect(svg?.querySelector("animate")?.getAttribute("attributeName")).toBe(
54
+ "d",
55
+ );
56
+ expect(
57
+ svg?.querySelector("animateTransform")?.getAttribute("attributeName"),
58
+ ).toBe("transform");
59
+ });
60
+
61
+ it("supports custom size and color", () => {
62
+ const { container } = render(
63
+ <LoadingIndicator size={48} color="red" aria-label="Loading" />,
64
+ );
65
+ const wrapper = container.firstChild as HTMLElement;
66
+ const svg = container.querySelector("svg");
67
+ expect(svg).toHaveAttribute("width", "48");
68
+ expect(svg).toHaveAttribute("height", "48");
69
+
70
+ const styleStr = wrapper.getAttribute("style") || "";
71
+ expect(styleStr).toMatch(/color:\s*(red|rgb\(255,\s*0,\s*0\))/);
72
+ });
73
+ });