@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,290 @@
1
+ "use client";
2
+
3
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { Tab, Tabs, TabsContent, TabsList } from "./index";
6
+
7
+ describe("Tabs Component System", () => {
8
+ it("renders correctly and selects the default tab", async () => {
9
+ render(
10
+ <Tabs defaultValue="tab2">
11
+ <TabsList variant="primary">
12
+ <Tab value="tab1">Tab 1</Tab>
13
+ <Tab value="tab2">Tab 2</Tab>
14
+ </TabsList>
15
+ <TabsContent value="tab1">Content 1</TabsContent>
16
+ <TabsContent value="tab2">Content 2</TabsContent>
17
+ </Tabs>,
18
+ );
19
+
20
+ const tabItems = screen.getAllByRole("tab");
21
+ expect(tabItems).toHaveLength(2);
22
+
23
+ // "tab2" should be selected
24
+ expect(tabItems[1]).toHaveAttribute("aria-selected", "true");
25
+ expect(tabItems[0]).toHaveAttribute("aria-selected", "false");
26
+
27
+ // "Content 2" should be visible
28
+ await waitFor(() => {
29
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Content 2");
30
+ });
31
+ });
32
+
33
+ it("switches tabs on click", async () => {
34
+ render(
35
+ <Tabs defaultValue="tab1">
36
+ <TabsList variant="primary">
37
+ <Tab value="tab1">Tab 1</Tab>
38
+ <Tab value="tab2">Tab 2</Tab>
39
+ </TabsList>
40
+ <TabsContent value="tab1">Content 1</TabsContent>
41
+ <TabsContent value="tab2">Content 2</TabsContent>
42
+ </Tabs>,
43
+ );
44
+
45
+ const tabItems = screen.getAllByRole("tab");
46
+
47
+ // Wait for initial render
48
+ await waitFor(() => {
49
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Content 1");
50
+ });
51
+
52
+ // Click "tab2"
53
+ fireEvent.click(tabItems[1]);
54
+
55
+ expect(tabItems[1]).toHaveAttribute("aria-selected", "true");
56
+
57
+ await waitFor(() => {
58
+ expect(screen.getByText("Content 2")).toBeInTheDocument();
59
+ });
60
+ });
61
+
62
+ it("handles controlled value correctly", async () => {
63
+ const handleChange = vi.fn();
64
+
65
+ render(
66
+ <Tabs value="tab1" onValueChange={handleChange}>
67
+ <TabsList variant="primary">
68
+ <Tab value="tab1">Tab 1</Tab>
69
+ <Tab value="tab2">Tab 2</Tab>
70
+ </TabsList>
71
+ <TabsContent value="tab1">Content 1</TabsContent>
72
+ <TabsContent value="tab2">Content 2</TabsContent>
73
+ </Tabs>,
74
+ );
75
+
76
+ const tabItems = screen.getAllByRole("tab");
77
+
78
+ // Click "tab2"
79
+ fireEvent.click(tabItems[1]);
80
+
81
+ // the value shouldn't update internally if strictly controlled without state update wrapper,
82
+ // but `onValueChange` should be called.
83
+ expect(handleChange).toHaveBeenCalledWith("tab2");
84
+ });
85
+
86
+ it("supports keyboard navigation (Arrow Right/Left)", async () => {
87
+ render(
88
+ <Tabs defaultValue="tab1">
89
+ <TabsList variant="primary">
90
+ <Tab value="tab1">Tab 1</Tab>
91
+ <Tab value="tab2">Tab 2</Tab>
92
+ <Tab value="tab3">Tab 3</Tab>
93
+ </TabsList>
94
+ </Tabs>,
95
+ );
96
+
97
+ const tabItems = screen.getAllByRole("tab");
98
+ tabItems[0].focus();
99
+
100
+ // Move right
101
+ fireEvent.keyDown(tabItems[0], { key: "ArrowRight" });
102
+ expect(tabItems[1]).toHaveFocus();
103
+
104
+ // Move right again
105
+ fireEvent.keyDown(tabItems[1], { key: "ArrowRight" });
106
+ expect(tabItems[2]).toHaveFocus();
107
+
108
+ // Move right again (should wrap to first)
109
+ fireEvent.keyDown(tabItems[2], { key: "ArrowRight" });
110
+ expect(tabItems[0]).toHaveFocus();
111
+
112
+ // Move left (should wrap to last)
113
+ fireEvent.keyDown(tabItems[0], { key: "ArrowLeft" });
114
+ expect(tabItems[2]).toHaveFocus();
115
+ });
116
+
117
+ it("supports Home/End keyboard navigation", () => {
118
+ render(
119
+ <Tabs defaultValue="tab1">
120
+ <TabsList variant="primary">
121
+ <Tab value="tab1">Tab 1</Tab>
122
+ <Tab value="tab2">Tab 2</Tab>
123
+ <Tab value="tab3">Tab 3</Tab>
124
+ </TabsList>
125
+ </Tabs>,
126
+ );
127
+
128
+ const tabItems = screen.getAllByRole("tab");
129
+ tabItems[0].focus();
130
+
131
+ // End key
132
+ fireEvent.keyDown(tabItems[0], { key: "End" });
133
+ expect(tabItems[2]).toHaveFocus();
134
+
135
+ // Home key
136
+ fireEvent.keyDown(tabItems[2], { key: "Home" });
137
+ expect(tabItems[0]).toHaveFocus();
138
+ });
139
+
140
+ it("auto-activates tabs on keyboard focus when autoActivate is true", async () => {
141
+ render(
142
+ <Tabs defaultValue="tab1" autoActivate>
143
+ <TabsList variant="primary">
144
+ <Tab value="tab1">Tab 1</Tab>
145
+ <Tab value="tab2">Tab 2</Tab>
146
+ </TabsList>
147
+ <TabsContent value="tab1">Content 1</TabsContent>
148
+ <TabsContent value="tab2">Content 2</TabsContent>
149
+ </Tabs>,
150
+ );
151
+
152
+ const tabItems = screen.getAllByRole("tab");
153
+ tabItems[0].focus();
154
+
155
+ // Move to second tab
156
+ fireEvent.keyDown(tabItems[0], { key: "ArrowRight" });
157
+ expect(tabItems[1]).toHaveFocus();
158
+
159
+ // Because of autoActivate, tab2 should instantly be selected
160
+ expect(tabItems[1]).toHaveAttribute("aria-selected", "true");
161
+ await waitFor(() => {
162
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Content 2");
163
+ });
164
+ });
165
+
166
+ it("does not select focused tabs with keyboard when autoActivate is false", async () => {
167
+ render(
168
+ <Tabs defaultValue="tab1" autoActivate={false}>
169
+ <TabsList variant="primary">
170
+ <Tab value="tab1">Tab 1</Tab>
171
+ <Tab value="tab2">Tab 2</Tab>
172
+ </TabsList>
173
+ <TabsContent value="tab1">Content 1</TabsContent>
174
+ <TabsContent value="tab2">Content 2</TabsContent>
175
+ </Tabs>,
176
+ );
177
+
178
+ const tabItems = screen.getAllByRole("tab");
179
+ tabItems[0].focus();
180
+
181
+ // Move to second tab
182
+ fireEvent.keyDown(tabItems[0], { key: "ArrowRight" });
183
+ expect(tabItems[1]).toHaveFocus();
184
+
185
+ // Should still be tab1
186
+ expect(tabItems[0]).toHaveAttribute("aria-selected", "true");
187
+ expect(tabItems[1]).toHaveAttribute("aria-selected", "false");
188
+
189
+ // Only updates selection upon Space or Enter
190
+ fireEvent.keyDown(tabItems[1], { key: "Enter" });
191
+ expect(tabItems[1]).toHaveAttribute("aria-selected", "true");
192
+ });
193
+
194
+ it("skips disabled tabs during keyboard navigation", () => {
195
+ render(
196
+ <Tabs defaultValue="tab1">
197
+ <TabsList variant="primary">
198
+ <Tab value="tab1">Tab 1</Tab>
199
+ <Tab value="tab2" disabled>
200
+ Tab 2
201
+ </Tab>
202
+ <Tab value="tab3">Tab 3</Tab>
203
+ </TabsList>
204
+ </Tabs>,
205
+ );
206
+
207
+ const tabItems = screen.getAllByRole("tab");
208
+ tabItems[0].focus();
209
+
210
+ // ArrowRight should skip tab2 and land on tab3
211
+ fireEvent.keyDown(tabItems[0], { key: "ArrowRight" });
212
+ expect(tabItems[2]).toHaveFocus();
213
+ });
214
+
215
+ it("respects tab selection with Enter and Space keys", () => {
216
+ const handleChange = vi.fn();
217
+ render(
218
+ <Tabs value="tab1" onValueChange={handleChange}>
219
+ <TabsList variant="primary">
220
+ <Tab value="tab1">Tab 1</Tab>
221
+ <Tab value="tab2">Tab 2</Tab>
222
+ </TabsList>
223
+ </Tabs>,
224
+ );
225
+
226
+ const tabItems = screen.getAllByRole("tab");
227
+ tabItems[0].focus();
228
+
229
+ // Move focus
230
+ fireEvent.keyDown(tabItems[0], { key: "ArrowRight" });
231
+ expect(tabItems[1]).toHaveFocus();
232
+
233
+ // Press Space
234
+ fireEvent.keyDown(tabItems[1], { key: " " });
235
+ expect(handleChange).toHaveBeenCalledWith("tab2");
236
+
237
+ // Press Enter
238
+ fireEvent.keyDown(tabItems[1], { key: "Enter" });
239
+ expect(handleChange).toHaveBeenCalledTimes(2);
240
+ });
241
+
242
+ it("sets correct attributes for accessibility", () => {
243
+ render(
244
+ <Tabs defaultValue="1">
245
+ <TabsList variant="primary" aria-label="My Tabs">
246
+ <Tab value="1">One</Tab>
247
+ <Tab value="2">Two</Tab>
248
+ </TabsList>
249
+ <TabsContent value="1">Content One</TabsContent>
250
+ </Tabs>,
251
+ );
252
+
253
+ expect(screen.getByRole("tablist")).toHaveAttribute(
254
+ "aria-label",
255
+ "My Tabs",
256
+ );
257
+
258
+ const tab = screen.getAllByRole("tab")[0];
259
+ expect(tab.getAttribute("id")).toMatch(/tab-1$/);
260
+ expect(tab.getAttribute("aria-controls")).toMatch(/panel-1$/);
261
+ expect(tab).toHaveAttribute("tabindex", "0");
262
+
263
+ const panel = screen.getByRole("tabpanel");
264
+ expect(panel.getAttribute("id")).toMatch(/panel-1$/);
265
+ expect(panel.getAttribute("aria-labelledby")).toMatch(/tab-1$/);
266
+ });
267
+
268
+ it("selects first enabled tab if no defaultValue is provided", async () => {
269
+ render(
270
+ <Tabs>
271
+ <TabsList variant="primary">
272
+ <Tab value="tab1" disabled>
273
+ Tab 1
274
+ </Tab>
275
+ <Tab value="tab2">Tab 2</Tab>
276
+ <Tab value="tab3">Tab 3</Tab>
277
+ </TabsList>
278
+ <TabsContent value="tab2">Content 2</TabsContent>
279
+ </Tabs>,
280
+ );
281
+
282
+ const tabItems = screen.getAllByRole("tab");
283
+
284
+ await waitFor(() => {
285
+ expect(tabItems[1]).toHaveAttribute("aria-selected", "true");
286
+ });
287
+
288
+ expect(screen.getByRole("tabpanel")).toHaveTextContent("Content 2");
289
+ });
290
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @file tabs.tokens.ts
3
+ * MD3 Expressive Tabs — Design tokens ported from:
4
+ * - PrimaryNavigationTabTokens.kt (v0_162)
5
+ * - SecondaryNavigationTabTokens.kt (v0_162)
6
+ *
7
+ * All dimensional values are in px (dp equivalent for web).
8
+ * @see docs/m3/tabs/PrimaryNavigationTabTokens.kt
9
+ * @see docs/m3/tabs/SecondaryNavigationTabTokens.kt
10
+ */
11
+
12
+ /**
13
+ * Dimensional design tokens for the MD3 Tabs component.
14
+ *
15
+ * Maps directly from the `.kt` token files to CSS/JS values.
16
+ * Use as the single source of truth for sizing.
17
+ */
18
+ export const TabsTokens = {
19
+ // ── Container ─────────────────────────────────────────────────────────────
20
+ /** ContainerHeight = 48dp (text-only tab) */
21
+ containerHeight: 48,
22
+ /** IconAndLabelTextContainerHeight = 64dp (tab with icon + label stacked) */
23
+ containerHeightWithIcon: 64,
24
+
25
+ // ── Indicator ─────────────────────────────────────────────────────────────
26
+ /** ActiveIndicatorHeight (Primary) = 3dp */
27
+ primaryIndicatorHeight: 3,
28
+ /** ActiveIndicatorHeight (Secondary) = 2dp */
29
+ secondaryIndicatorHeight: 2,
30
+ /**
31
+ * ActiveIndicatorShape = 3dp top-left and top-right (per MD3 token, not a full pill).
32
+ * Google reference: `var(--_active-indicator-shape)` resolves to `3px 3px 0 0` effectively.
33
+ */
34
+ indicatorBorderRadius: "3px 3px 0 0",
35
+
36
+ // ── Icon ──────────────────────────────────────────────────────────────────
37
+ /** IconSize = 24dp */
38
+ iconSize: 24,
39
+
40
+ // ── Scrollable layout ─────────────────────────────────────────────────────
41
+ /**
42
+ * Edge start/end padding for scrollable mode = 52px.
43
+ * Per MD3 spec: tabs have padding on both leading and trailing edges.
44
+ */
45
+ scrollableEdgePadding: 52,
46
+ /** Minimum tab width in scrollable mode = 90px. */
47
+ scrollableMinTabWidth: 90,
48
+
49
+ // ── Divider (Secondary only) ───────────────────────────────────────────────
50
+ /** DividerHeight = 1dp */
51
+ dividerHeight: 1,
52
+
53
+ // ── Focus ring ────────────────────────────────────────────────────────────
54
+ /**
55
+ * Focus ring border-radius = 8px.
56
+ * Google reference: `focus-ring.theme({ shape: 8px })` in _tab.scss.
57
+ */
58
+ focusRingBorderRadius: 8,
59
+ } as const;
60
+
61
+ // ── MD3 Color token references (CSS custom properties) ────────────────────────
62
+
63
+ /**
64
+ * CSS custom property references for Tabs colors.
65
+ * Maps to `--md-sys-color-*` tokens in the MD3 theme system.
66
+ *
67
+ * DO NOT hardcode hex values — use these references for automatic
68
+ * light/dark theme adaptation.
69
+ */
70
+ export const TabsColors = {
71
+ // ── Primary variant ───────────────────────────────────────────────────────
72
+ /** Primary: ActiveLabelTextColor / ActiveIconColor = Primary */
73
+ primaryActiveText: "var(--md-sys-color-primary)",
74
+ /** Primary: InactiveLabelTextColor / InactiveIconColor = OnSurfaceVariant */
75
+ primaryInactiveText: "var(--md-sys-color-on-surface-variant)",
76
+ /** Primary: ActiveIndicatorColor = Primary */
77
+ primaryIndicator: "var(--md-sys-color-primary)",
78
+
79
+ // ── Secondary variant ─────────────────────────────────────────────────────
80
+ /** Secondary: ActiveLabelTextColor / ActiveIconColor = OnSurface */
81
+ secondaryActiveText: "var(--md-sys-color-on-surface)",
82
+ /** Secondary: InactiveLabelTextColor / InactiveIconColor = OnSurfaceVariant */
83
+ secondaryInactiveText: "var(--md-sys-color-on-surface-variant)",
84
+ /** Secondary: Indicator color = Primary (same as primary variant) */
85
+ secondaryIndicator: "var(--md-sys-color-primary)",
86
+ /** Secondary: DividerColor = SurfaceVariant */
87
+ divider: "var(--md-sys-color-surface-variant)",
88
+
89
+ // ── Shared ────────────────────────────────────────────────────────────────
90
+ /** ContainerColor = Surface */
91
+ container: "var(--md-sys-color-surface)",
92
+ /** Focus ring indicator = Secondary */
93
+ focusIndicator: "var(--md-sys-color-secondary)",
94
+
95
+ // ── State overlays ────────────────────────────────────────────────────────
96
+ /** Hover state layer (primary active) */
97
+ primaryActiveHover: "var(--md-sys-color-primary)",
98
+ /** Hover state layer (inactive, both variants) */
99
+ inactiveHover: "var(--md-sys-color-on-surface)",
100
+ } as const;
101
+
102
+ // ── Animation constants ────────────────────────────────────────────────────────
103
+
104
+ /** Spring transition for the sliding indicator (FastSpatial equivalent). */
105
+ export const TABS_INDICATOR_SPRING = {
106
+ type: "spring",
107
+ stiffness: 500,
108
+ damping: 40,
109
+ } as const;
110
+
111
+ /** Color transition for label/icon color animate (active ↔ inactive). */
112
+ export const TABS_COLOR_TRANSITION = {
113
+ duration: 0.2,
114
+ ease: "easeInOut",
115
+ } as const;
116
+
117
+ /** Content fade transition when switching tabs. */
118
+ export const TABS_CONTENT_TRANSITION = {
119
+ duration: 0.15,
120
+ ease: "easeInOut",
121
+ } as const;
@@ -0,0 +1,229 @@
1
+ /**
2
+ * @file tabs.tsx
3
+ * MD3 Expressive Tabs — Root context provider and state manager.
4
+ * Implements compound component pattern (similar to Radix UI).
5
+ * Spec: https://m3.material.io/components/tabs/overview
6
+ */
7
+
8
+ import { domMax, LazyMotion } from "motion/react";
9
+ import * as React from "react";
10
+ import { cn } from "../../lib/utils";
11
+ import type { TabsContextValue, TabsProps } from "./tabs.types";
12
+
13
+ // ─── Context ───────────────────────────────────────────────────────────────────
14
+
15
+ const TabsContext = React.createContext<TabsContextValue | null>(null);
16
+
17
+ /**
18
+ * Hook to consume the Tabs context.
19
+ * Throws if used outside a `<Tabs>` root.
20
+ * @internal
21
+ */
22
+ export function useTabsContext(): TabsContextValue {
23
+ const ctx = React.useContext(TabsContext);
24
+ if (!ctx) {
25
+ throw new Error(
26
+ "[MD3 Tabs] Component must be used within a <Tabs> root. " +
27
+ "Ensure <TabsList>, <Tab>, and <TabsContent> are descendants of <Tabs>.",
28
+ );
29
+ }
30
+ return ctx;
31
+ }
32
+
33
+ // ─── Tabs Root ─────────────────────────────────────────────────────────────────
34
+
35
+ const TabsComponent = React.forwardRef<HTMLDivElement, TabsProps>(
36
+ (
37
+ {
38
+ value: controlledValue,
39
+ defaultValue = "",
40
+ onValueChange,
41
+ autoActivate = false,
42
+ children,
43
+ className,
44
+ },
45
+ ref,
46
+ ) => {
47
+ // ── State: selected value (controlled + uncontrolled) ──────────────────
48
+ const [internalValue, setInternalValue] = React.useState(defaultValue);
49
+ const isControlled = controlledValue !== undefined;
50
+ const value = isControlled ? controlledValue : internalValue;
51
+
52
+ const handleValueChange = React.useCallback(
53
+ (newValue: string) => {
54
+ if (!isControlled) setInternalValue(newValue);
55
+ onValueChange?.(newValue);
56
+ },
57
+ [isControlled, onValueChange],
58
+ );
59
+
60
+ // ── State: keyboard focus (roving tabindex) ────────────────────────────
61
+ // focusedValue is separate from `value` so focus can move without selecting.
62
+ const [focusedValue, setFocusedValue] = React.useState(value);
63
+
64
+ // Sync focusedValue when controlled value changes externally
65
+ React.useEffect(() => {
66
+ setFocusedValue(value);
67
+ }, [value]);
68
+
69
+ // ── Registered tabs registry ───────────────────────────────────────────
70
+ // Tabs register/unregister themselves to enable ArrowKey navigation.
71
+ const [tabValues, setTabValues] = React.useState<string[]>([]);
72
+
73
+ const registerTab = React.useCallback((tabValue: string) => {
74
+ setTabValues((prev) => {
75
+ if (prev.includes(tabValue)) return prev;
76
+ return [...prev, tabValue];
77
+ });
78
+ }, []);
79
+
80
+ const unregisterTab = React.useCallback((tabValue: string) => {
81
+ setTabValues((prev) => prev.filter((v) => v !== tabValue));
82
+ }, []);
83
+
84
+ // ── Auto-select first enabled tab when no defaultValue is provided ─────
85
+ // Mirrors Google's behavior: if no tab is active, select the first one.
86
+ const hasAutoSelected = React.useRef(false);
87
+
88
+ // ── Disabled tab tracking ──────────────────────────────────────────────
89
+ const [disabledValues, setDisabledValues] = React.useState<Set<string>>(
90
+ new Set(),
91
+ );
92
+
93
+ const markTabDisabled = React.useCallback(
94
+ (tabValue: string, disabled: boolean) => {
95
+ setDisabledValues((prev) => {
96
+ const next = new Set(prev);
97
+ if (disabled) {
98
+ next.add(tabValue);
99
+ } else {
100
+ next.delete(tabValue);
101
+ }
102
+ return next;
103
+ });
104
+ },
105
+ [],
106
+ );
107
+
108
+ // Auto-select first enabled tab if no value is set
109
+ React.useEffect(() => {
110
+ if (isControlled || hasAutoSelected.current || tabValues.length === 0) {
111
+ return;
112
+ }
113
+ if (value && tabValues.includes(value)) {
114
+ hasAutoSelected.current = true;
115
+ return;
116
+ }
117
+ const firstEnabled = tabValues.find((v) => !disabledValues.has(v));
118
+ if (firstEnabled) {
119
+ hasAutoSelected.current = true;
120
+ setInternalValue(firstEnabled);
121
+ setFocusedValue(firstEnabled);
122
+ }
123
+ }, [tabValues, disabledValues, isControlled, value]);
124
+
125
+ // ── Unique layout group ID for Framer Motion ───────────────────────────
126
+ const id = React.useId();
127
+ const layoutGroupId = `tabs-${id}`;
128
+
129
+ // ── Context value ──────────────────────────────────────────────────────
130
+ const contextValue = React.useMemo<TabsContextValue>(
131
+ () => ({
132
+ value,
133
+ onValueChange: handleValueChange,
134
+ focusedValue,
135
+ setFocusedValue,
136
+ tabValues,
137
+ registerTab,
138
+ unregisterTab,
139
+ layoutGroupId,
140
+ disabledValues,
141
+ markTabDisabled,
142
+ autoActivate,
143
+ }),
144
+ [
145
+ value,
146
+ handleValueChange,
147
+ focusedValue,
148
+ tabValues,
149
+ registerTab,
150
+ unregisterTab,
151
+ layoutGroupId,
152
+ disabledValues,
153
+ markTabDisabled,
154
+ autoActivate,
155
+ ],
156
+ );
157
+
158
+ return (
159
+ <LazyMotion features={domMax} strict>
160
+ <TabsContext.Provider value={contextValue}>
161
+ <div ref={ref} className={cn("w-full", className)}>
162
+ {children}
163
+ </div>
164
+ </TabsContext.Provider>
165
+ </LazyMotion>
166
+ );
167
+ },
168
+ );
169
+
170
+ TabsComponent.displayName = "Tabs";
171
+
172
+ /**
173
+ * MD3 Expressive Tabs root component.
174
+ *
175
+ * Manages tab selection state and provides context to all
176
+ * compound sub-components. Supports both controlled and
177
+ * uncontrolled usage.
178
+ *
179
+ * @example
180
+ * ```tsx
181
+ * // Uncontrolled
182
+ * <Tabs defaultValue="flights">
183
+ * <TabsList variant="primary">
184
+ * <Tab value="flights">Flights</Tab>
185
+ * <Tab value="trips">Trips</Tab>
186
+ * </TabsList>
187
+ * <TabsContent value="flights">Flight content</TabsContent>
188
+ * <TabsContent value="trips">Trip content</TabsContent>
189
+ * </Tabs>
190
+ *
191
+ * // Controlled
192
+ * const [tab, setTab] = useState("flights");
193
+ * <Tabs value={tab} onValueChange={setTab}>...</Tabs>
194
+ *
195
+ * // Auto-activate mode (focus = select)
196
+ * <Tabs defaultValue="flights" autoActivate>...</Tabs>
197
+ * ```
198
+ *
199
+ * @see https://m3.material.io/components/tabs/overview
200
+ */
201
+ export const Tabs = React.memo(TabsComponent);
202
+
203
+ // ─── TabsListContext ───────────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Secondary context carrying variant + scrollable from <TabsList>.
207
+ * Separate from TabsContext so Tabs root doesn't need these props —
208
+ * they belong to the list, not the root.
209
+ * @internal
210
+ */
211
+ export interface TabsListContextValue {
212
+ variant: "primary" | "secondary";
213
+ scrollable: boolean;
214
+ }
215
+
216
+ export const TabsListContext = React.createContext<TabsListContextValue | null>(
217
+ null,
218
+ );
219
+
220
+ /**
221
+ * Hook to consume TabsList-level context (variant, scrollable).
222
+ * @internal
223
+ */
224
+ export function useTabsListContext(): TabsListContextValue {
225
+ const ctx = React.useContext(TabsListContext);
226
+ // Fallback to sensible defaults instead of throwing, since
227
+ // Tab might be rendered without explicit context in tests.
228
+ return ctx ?? { variant: "primary", scrollable: false };
229
+ }