@bug-on/md3-react 2.0.3 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/.turbo/turbo-build.log +42 -0
  2. package/CHANGELOG.md +69 -0
  3. package/dist/index.css +178 -0
  4. package/dist/index.css.d.ts +2 -0
  5. package/dist/index.d.mts +6135 -0
  6. package/dist/index.d.ts +6135 -71
  7. package/dist/index.js +1688 -631
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +1600 -564
  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/plugin.d.mts +1 -0
  14. package/dist/plugin.d.ts +1 -0
  15. package/dist/plugin.js +13 -0
  16. package/dist/plugin.js.map +1 -0
  17. package/dist/plugin.mjs +3 -0
  18. package/dist/plugin.mjs.map +1 -0
  19. package/dist/typography.css.d.ts +2 -0
  20. package/package.json +28 -19
  21. package/scripts/copy-assets.js +115 -0
  22. package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
  23. package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  24. package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  25. package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  26. package/src/assets/loading-indicator.svg +19 -0
  27. package/src/assets/material-symbols-cdn.css +65 -0
  28. package/src/assets/material-symbols-self-hosted.css +90 -0
  29. package/src/css.d.ts +20 -0
  30. package/src/hooks/useClickOutside.ts +37 -0
  31. package/src/hooks/useMediaQuery.ts +28 -0
  32. package/src/hooks/useRipple.ts +88 -0
  33. package/src/index.css +23 -0
  34. package/src/index.ts +349 -0
  35. package/src/lib/material-symbols-preconnect.tsx +82 -0
  36. package/src/lib/theme-utils.ts +195 -0
  37. package/src/lib/utils.ts +6 -0
  38. package/src/plugin.ts +12 -0
  39. package/src/test/button.test.tsx +59 -0
  40. package/src/test/icon.test.tsx +91 -0
  41. package/src/test/loading-indicator.test.tsx +128 -0
  42. package/src/test/progress-indicator.test.tsx +306 -0
  43. package/src/test/setup.ts +80 -0
  44. package/src/test/typography.test.tsx +206 -0
  45. package/src/types/index.ts +7 -0
  46. package/src/types/md3.ts +31 -0
  47. package/src/ui/Text.tsx +60 -0
  48. package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
  49. package/src/ui/app-bar/app-bar-column.tsx +99 -0
  50. package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
  51. package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
  52. package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
  53. package/src/ui/app-bar/app-bar-row.tsx +104 -0
  54. package/src/ui/app-bar/app-bar.test.tsx +87 -0
  55. package/src/ui/app-bar/app-bar.tokens.ts +223 -0
  56. package/src/ui/app-bar/app-bar.types.ts +441 -0
  57. package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
  58. package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
  59. package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
  60. package/src/ui/app-bar/docked-toolbar.tsx +54 -0
  61. package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
  62. package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
  63. package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
  64. package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
  65. package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
  66. package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
  67. package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
  68. package/src/ui/app-bar/search-app-bar.tsx +176 -0
  69. package/src/ui/app-bar/search-view.tsx +227 -0
  70. package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
  71. package/src/ui/app-bar/small-app-bar.tsx +203 -0
  72. package/src/ui/badge.test.tsx +345 -0
  73. package/src/ui/badge.tsx +282 -0
  74. package/src/ui/button-group.test.tsx +71 -0
  75. package/src/ui/button-group.tsx +350 -0
  76. package/src/ui/button.test.tsx +306 -0
  77. package/src/ui/button.tsx +665 -0
  78. package/src/ui/card.test.tsx +187 -0
  79. package/src/ui/card.tsx +259 -0
  80. package/src/ui/checkbox.test.tsx +423 -0
  81. package/src/ui/checkbox.tsx +525 -0
  82. package/src/ui/chip.test.tsx +292 -0
  83. package/src/ui/chip.tsx +548 -0
  84. package/src/ui/code-block.tsx +219 -0
  85. package/src/ui/dialog.test.tsx +300 -0
  86. package/src/ui/dialog.tsx +384 -0
  87. package/src/ui/divider.test.tsx +314 -0
  88. package/src/ui/divider.tsx +412 -0
  89. package/src/ui/drawer.tsx +240 -0
  90. package/src/ui/fab-menu.test.tsx +494 -0
  91. package/src/ui/fab-menu.tsx +739 -0
  92. package/src/ui/fab.test.tsx +232 -0
  93. package/src/ui/fab.tsx +505 -0
  94. package/src/ui/icon-button.test.tsx +515 -0
  95. package/src/ui/icon-button.tsx +525 -0
  96. package/src/ui/icon.test.tsx +197 -0
  97. package/src/ui/icon.tsx +179 -0
  98. package/src/ui/loading-indicator.test.tsx +73 -0
  99. package/src/ui/loading-indicator.tsx +312 -0
  100. package/src/ui/menu/context-menu.tsx +275 -0
  101. package/src/ui/menu/index.ts +77 -0
  102. package/src/ui/menu/menu-animations.ts +102 -0
  103. package/src/ui/menu/menu-context.tsx +99 -0
  104. package/src/ui/menu/menu-divider.tsx +47 -0
  105. package/src/ui/menu/menu-group.tsx +200 -0
  106. package/src/ui/menu/menu-item.tsx +294 -0
  107. package/src/ui/menu/menu-tokens.ts +208 -0
  108. package/src/ui/menu/menu-types.ts +313 -0
  109. package/src/ui/menu/menu.test.tsx +624 -0
  110. package/src/ui/menu/menu.tsx +289 -0
  111. package/src/ui/menu/sub-menu.tsx +223 -0
  112. package/src/ui/menu/vertical-menu.tsx +382 -0
  113. package/src/ui/navigation-rail.test.tsx +404 -0
  114. package/src/ui/navigation-rail.tsx +607 -0
  115. package/src/ui/progress-indicator/circular.tsx +248 -0
  116. package/src/ui/progress-indicator/hooks.ts +51 -0
  117. package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
  118. package/src/ui/progress-indicator/linear-flat.tsx +83 -0
  119. package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
  120. package/src/ui/progress-indicator/linear.tsx +143 -0
  121. package/src/ui/progress-indicator/types.ts +158 -0
  122. package/src/ui/progress-indicator/utils.ts +73 -0
  123. package/src/ui/radio-button.test.tsx +407 -0
  124. package/src/ui/radio-button.tsx +551 -0
  125. package/src/ui/ripple.test.tsx +72 -0
  126. package/src/ui/ripple.tsx +234 -0
  127. package/src/ui/scroll-area.test.tsx +58 -0
  128. package/src/ui/scroll-area.tsx +139 -0
  129. package/src/ui/search/animated-placeholder.tsx +145 -0
  130. package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
  131. package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
  132. package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
  133. package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
  134. package/src/ui/search/index.ts +44 -0
  135. package/src/ui/search/search-bar.tsx +220 -0
  136. package/src/ui/search/search-context.tsx +42 -0
  137. package/src/ui/search/search-view-docked.tsx +194 -0
  138. package/src/ui/search/search-view-fullscreen.tsx +247 -0
  139. package/src/ui/search/search.test.tsx +233 -0
  140. package/src/ui/search/search.tokens.ts +134 -0
  141. package/src/ui/search/search.tsx +131 -0
  142. package/src/ui/search/search.types.ts +154 -0
  143. package/src/ui/search/trailing-action.tsx +49 -0
  144. package/src/ui/shared/constants.ts +135 -0
  145. package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
  146. package/src/ui/slider/hooks/useSliderMath.ts +195 -0
  147. package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
  148. package/src/ui/slider/range-slider.tsx +561 -0
  149. package/src/ui/slider/slider-thumb.tsx +379 -0
  150. package/src/ui/slider/slider-track.tsx +912 -0
  151. package/src/ui/slider/slider.tokens.ts +189 -0
  152. package/src/ui/slider/slider.tsx +259 -0
  153. package/src/ui/slider/slider.types.ts +288 -0
  154. package/src/ui/snackbar/index.ts +20 -0
  155. package/src/ui/snackbar/snackbar.test.tsx +338 -0
  156. package/src/ui/snackbar/snackbar.tsx +476 -0
  157. package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
  158. package/src/ui/switch/switch.stories.tsx +309 -0
  159. package/src/ui/switch/switch.test.tsx +243 -0
  160. package/src/ui/switch/switch.tokens.ts +89 -0
  161. package/src/ui/switch/switch.tsx +504 -0
  162. package/src/ui/switch/switch.types.ts +62 -0
  163. package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
  164. package/src/ui/tabs/tab.tsx +407 -0
  165. package/src/ui/tabs/tabs-content.tsx +89 -0
  166. package/src/ui/tabs/tabs-list.tsx +146 -0
  167. package/src/ui/tabs/tabs.test.tsx +290 -0
  168. package/src/ui/tabs/tabs.tokens.ts +121 -0
  169. package/src/ui/tabs/tabs.tsx +229 -0
  170. package/src/ui/tabs/tabs.types.ts +185 -0
  171. package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
  172. package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
  173. package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
  174. package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
  175. package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
  176. package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
  177. package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
  178. package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
  179. package/src/ui/text-field/text-field.test.tsx +454 -0
  180. package/src/ui/text-field/text-field.tokens.ts +104 -0
  181. package/src/ui/text-field/text-field.tsx +548 -0
  182. package/src/ui/text-field/text-field.types.ts +180 -0
  183. package/src/ui/theme-provider/index.tsx +215 -0
  184. package/src/ui/toc.test.tsx +108 -0
  185. package/src/ui/toc.tsx +172 -0
  186. package/src/ui/tooltip/plain-tooltip.tsx +63 -0
  187. package/src/ui/tooltip/rich-tooltip.tsx +94 -0
  188. package/src/ui/tooltip/tooltip-box.tsx +266 -0
  189. package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
  190. package/src/ui/tooltip/tooltip.tokens.ts +26 -0
  191. package/src/ui/tooltip/tooltip.types.ts +70 -0
  192. package/src/ui/tooltip/use-tooltip-position.ts +208 -0
  193. package/src/ui/tooltip/use-tooltip-state.ts +41 -0
  194. package/src/ui/typography/__tests__/typography.test.tsx +170 -0
  195. package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
  196. package/src/ui/typography/type-scale-tokens.ts +205 -0
  197. package/src/ui/typography/typography-key-tokens.ts +43 -0
  198. package/src/ui/typography/typography-tokens.ts +360 -0
  199. package/src/ui/typography/typography.css +22 -0
  200. package/src/ui/typography/typography.tsx +559 -0
  201. package/test-render.tsx +4 -0
  202. package/test-shadow.html +26 -0
  203. package/test_output.txt +164 -0
  204. package/test_output_v2.txt +5 -0
  205. package/tsconfig.build.json +10 -0
  206. package/tsconfig.json +18 -0
  207. package/tsup.config.ts +20 -0
  208. package/vitest.config.ts +11 -0
  209. package/dist/hooks/useClickOutside.d.ts +0 -8
  210. package/dist/hooks/useMediaQuery.d.ts +0 -11
  211. package/dist/hooks/useRipple.d.ts +0 -26
  212. package/dist/lib/material-symbols-preconnect.d.ts +0 -42
  213. package/dist/lib/theme-utils.d.ts +0 -63
  214. package/dist/lib/utils.d.ts +0 -2
  215. package/dist/types/index.d.ts +0 -1
  216. package/dist/types/md3.d.ts +0 -14
  217. package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
  218. package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
  219. package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
  220. package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
  221. package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
  222. package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
  223. package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
  224. package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
  225. package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
  226. package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
  227. package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
  228. package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
  229. package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
  230. package/dist/ui/app-bar/search-view.d.ts +0 -54
  231. package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
  232. package/dist/ui/badge.d.ts +0 -125
  233. package/dist/ui/button-group.d.ts +0 -59
  234. package/dist/ui/button.d.ts +0 -148
  235. package/dist/ui/card.d.ts +0 -62
  236. package/dist/ui/checkbox.d.ts +0 -82
  237. package/dist/ui/chip.d.ts +0 -110
  238. package/dist/ui/code-block.d.ts +0 -14
  239. package/dist/ui/dialog.d.ts +0 -111
  240. package/dist/ui/divider.d.ts +0 -164
  241. package/dist/ui/drawer.d.ts +0 -39
  242. package/dist/ui/dropdown.d.ts +0 -29
  243. package/dist/ui/fab-menu.d.ts +0 -204
  244. package/dist/ui/fab.d.ts +0 -162
  245. package/dist/ui/icon-button.d.ts +0 -131
  246. package/dist/ui/icon.d.ts +0 -88
  247. package/dist/ui/loading-indicator.d.ts +0 -42
  248. package/dist/ui/navigation-rail.d.ts +0 -29
  249. package/dist/ui/progress-indicator/circular.d.ts +0 -3
  250. package/dist/ui/progress-indicator/hooks.d.ts +0 -3
  251. package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
  252. package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
  253. package/dist/ui/progress-indicator/linear.d.ts +0 -3
  254. package/dist/ui/progress-indicator/types.d.ts +0 -151
  255. package/dist/ui/progress-indicator/utils.d.ts +0 -3
  256. package/dist/ui/radio-button.d.ts +0 -106
  257. package/dist/ui/ripple.d.ts +0 -126
  258. package/dist/ui/scroll-area.d.ts +0 -27
  259. package/dist/ui/search/animated-placeholder.d.ts +0 -54
  260. package/dist/ui/search/hooks/use-search-keyboard.d.ts +0 -32
  261. package/dist/ui/search/hooks/use-search-view-focus.d.ts +0 -6
  262. package/dist/ui/search/index.d.ts +0 -27
  263. package/dist/ui/search/search-bar.d.ts +0 -32
  264. package/dist/ui/search/search-context.d.ts +0 -24
  265. package/dist/ui/search/search-view-docked.d.ts +0 -25
  266. package/dist/ui/search/search-view-fullscreen.d.ts +0 -36
  267. package/dist/ui/search/search.d.ts +0 -50
  268. package/dist/ui/search/search.tokens.d.ts +0 -112
  269. package/dist/ui/search/search.types.d.ts +0 -131
  270. package/dist/ui/search/trailing-action.d.ts +0 -9
  271. package/dist/ui/shared/constants.d.ts +0 -86
  272. package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
  273. package/dist/ui/slider/range-slider.d.ts +0 -47
  274. package/dist/ui/slider/slider-thumb.d.ts +0 -33
  275. package/dist/ui/slider/slider-track.d.ts +0 -25
  276. package/dist/ui/slider/slider.d.ts +0 -60
  277. package/dist/ui/slider/slider.tokens.d.ts +0 -151
  278. package/dist/ui/slider/slider.types.d.ts +0 -259
  279. package/dist/ui/snackbar/index.d.ts +0 -6
  280. package/dist/ui/snackbar/snackbar.d.ts +0 -197
  281. package/dist/ui/switch/switch.d.ts +0 -30
  282. package/dist/ui/switch/switch.stories.d.ts +0 -48
  283. package/dist/ui/switch/switch.tokens.d.ts +0 -67
  284. package/dist/ui/switch/switch.types.d.ts +0 -59
  285. package/dist/ui/tabs/tab.d.ts +0 -43
  286. package/dist/ui/tabs/tabs-content.d.ts +0 -36
  287. package/dist/ui/tabs/tabs-list.d.ts +0 -40
  288. package/dist/ui/tabs/tabs.d.ts +0 -60
  289. package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
  290. package/dist/ui/tabs/tabs.types.d.ts +0 -172
  291. package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
  292. package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
  293. package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
  294. package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
  295. package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
  296. package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
  297. package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
  298. package/dist/ui/text-field/text-field.d.ts +0 -49
  299. package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
  300. package/dist/ui/text-field/text-field.types.d.ts +0 -126
  301. package/dist/ui/theme-provider/index.d.ts +0 -48
  302. package/dist/ui/toc.d.ts +0 -80
  303. package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
  304. package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
  305. package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
  306. package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
  307. package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
  308. package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
  309. package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
  310. package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
  311. package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
  312. package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
  313. package/dist/ui/typography/typography-tokens.d.ts +0 -220
  314. package/dist/ui/typography/typography.d.ts +0 -265
  315. /package/{dist/hooks/index.d.ts → src/hooks/index.ts} +0 -0
  316. /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * @file tab.tsx
3
+ * MD3 Expressive Tab — Individual tab button with Framer Motion indicator.
4
+ *
5
+ * Design decisions:
6
+ * 1. PRIMARY indicator nested inside content wrapper → width = content width (not full button).
7
+ * 2. SECONDARY indicator outside content wrapper → `inset-x-0` = full button width.
8
+ * 3. ROVING TABINDEX (WAI-ARIA): only focused tab has tabIndex=0; ArrowKey moves focus, Enter/Space selects.
9
+ * 4. DISABLED tabs are skipped in ArrowKey navigation.
10
+ * 5. RTL: ArrowLeft/Right directions are swapped when `direction: rtl` is detected.
11
+ * 6. INLINE ICON: icon beside label, height stays 48dp (stacked = 64dp).
12
+ * 7. AUTO-ACTIVATE: when parent `<Tabs autoActivate>`, ArrowKey also selects.
13
+ *
14
+ * @see https://m3.material.io/components/tabs/overview
15
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
16
+ */
17
+
18
+ import { m, useReducedMotion } from "motion/react";
19
+ import * as React from "react";
20
+ import { cn } from "../../lib/utils";
21
+ import { BadgedBox } from "../badge";
22
+ import { useTabsContext, useTabsListContext } from "./tabs";
23
+ import {
24
+ TABS_COLOR_TRANSITION,
25
+ TABS_INDICATOR_SPRING,
26
+ TabsColors,
27
+ TabsTokens,
28
+ } from "./tabs.tokens";
29
+ import type { TabProps } from "./tabs.types";
30
+
31
+ // ─── Constants ──────────────────────────────────────────────────────────────────
32
+
33
+ /** Minimum indicator width per MD3 spec (24dp). */
34
+ const INDICATOR_MIN_WIDTH = 24;
35
+
36
+ // ─── Tab ───────────────────────────────────────────────────────────────────────
37
+
38
+ const TabComponent = React.forwardRef<HTMLButtonElement, TabProps>(
39
+ (
40
+ {
41
+ value,
42
+ icon,
43
+ inlineIcon = false,
44
+ disabled = false,
45
+ badge,
46
+ className,
47
+ children,
48
+ },
49
+ ref,
50
+ ) => {
51
+ const {
52
+ value: selectedValue,
53
+ onValueChange,
54
+ focusedValue,
55
+ setFocusedValue,
56
+ tabValues,
57
+ registerTab,
58
+ unregisterTab,
59
+ layoutGroupId,
60
+ disabledValues,
61
+ markTabDisabled,
62
+ autoActivate,
63
+ } = useTabsContext();
64
+
65
+ const { variant, scrollable } = useTabsListContext();
66
+
67
+ const prefersReduced = useReducedMotion() ?? false;
68
+
69
+ const isActive = selectedValue === value;
70
+ const isFocused = focusedValue === value;
71
+ const hasIcon = icon != null;
72
+ const isStackedIcon = hasIcon && !inlineIcon;
73
+
74
+ // ── Refs ───────────────────────────────────────────────────────────────
75
+ const buttonRef = React.useRef<HTMLButtonElement | null>(null);
76
+ const isFirstMount = React.useRef(true);
77
+
78
+ // Merge forwarded ref with internal ref
79
+ const mergedRef = React.useCallback(
80
+ (node: HTMLButtonElement | null) => {
81
+ buttonRef.current = node;
82
+ if (typeof ref === "function") ref(node);
83
+ else if (ref) ref.current = node;
84
+ },
85
+ [ref],
86
+ );
87
+
88
+ // ── Register/unregister with parent context on mount/unmount ──────────
89
+ React.useEffect(() => {
90
+ registerTab(value);
91
+ return () => unregisterTab(value);
92
+ }, [value, registerTab, unregisterTab]);
93
+
94
+ // ── Sync disabled state with parent context ────────────────────────────
95
+ React.useEffect(() => {
96
+ markTabDisabled(value, disabled);
97
+ return () => markTabDisabled(value, false);
98
+ }, [value, disabled, markTabDisabled]);
99
+
100
+ // ── Keyboard navigation ────────────────────────────────────────────────
101
+ const handleKeyDown = React.useCallback(
102
+ (e: React.KeyboardEvent<HTMLButtonElement>) => {
103
+ const isRtl = buttonRef.current
104
+ ? getComputedStyle(buttonRef.current).direction === "rtl"
105
+ : false;
106
+
107
+ const enabledValues = tabValues.filter((v) => !disabledValues.has(v));
108
+ const currentIndex = enabledValues.indexOf(value);
109
+
110
+ switch (e.key) {
111
+ case "ArrowRight":
112
+ case "ArrowLeft": {
113
+ e.preventDefault();
114
+ const goForward = isRtl
115
+ ? e.key === "ArrowLeft"
116
+ : e.key === "ArrowRight";
117
+ const nextIndex = goForward
118
+ ? (currentIndex + 1) % enabledValues.length
119
+ : (currentIndex - 1 + enabledValues.length) %
120
+ enabledValues.length;
121
+ const nextValue = enabledValues[nextIndex];
122
+ if (nextValue) {
123
+ setFocusedValue(nextValue);
124
+ if (autoActivate) onValueChange(nextValue);
125
+ }
126
+ break;
127
+ }
128
+ case "Home": {
129
+ e.preventDefault();
130
+ const firstValue = enabledValues[0];
131
+ if (firstValue) {
132
+ setFocusedValue(firstValue);
133
+ if (autoActivate) onValueChange(firstValue);
134
+ }
135
+ break;
136
+ }
137
+ case "End": {
138
+ e.preventDefault();
139
+ const lastValue = enabledValues[enabledValues.length - 1];
140
+ if (lastValue) {
141
+ setFocusedValue(lastValue);
142
+ if (autoActivate) onValueChange(lastValue);
143
+ }
144
+ break;
145
+ }
146
+ case "Enter":
147
+ case " ": {
148
+ e.preventDefault();
149
+ if (!disabled) onValueChange(value);
150
+ break;
151
+ }
152
+ }
153
+ },
154
+ [
155
+ tabValues,
156
+ disabledValues,
157
+ value,
158
+ disabled,
159
+ setFocusedValue,
160
+ onValueChange,
161
+ autoActivate,
162
+ ],
163
+ );
164
+
165
+ // Focus DOM node when focusedValue changes via keyboard (skip initial mount)
166
+ React.useEffect(() => {
167
+ if (isFirstMount.current) {
168
+ isFirstMount.current = false;
169
+ return;
170
+ }
171
+ if (isFocused && buttonRef.current) {
172
+ buttonRef.current.focus({ preventScroll: true });
173
+ }
174
+ }, [isFocused]);
175
+
176
+ // ── Auto-scroll active tab into view (scrollable mode) ─────────────────
177
+ // Horizontally scrolls the nearest overflow-x container to reveal the
178
+ // active tab. Uses scrollTo (not scrollIntoView) to avoid vertical page jumps.
179
+ React.useEffect(() => {
180
+ if (!isActive || !scrollable || !buttonRef.current) return;
181
+
182
+ const btn = buttonRef.current;
183
+ let container: HTMLElement | null = btn.parentElement;
184
+ while (container) {
185
+ const { overflowX } = getComputedStyle(container);
186
+ if (overflowX === "auto" || overflowX === "scroll") break;
187
+ container = container.parentElement;
188
+ }
189
+ if (!container) return;
190
+
191
+ const btnRect = btn.getBoundingClientRect();
192
+ const containerRect = container.getBoundingClientRect();
193
+ const overflowLeft = containerRect.left - btnRect.left;
194
+ const overflowRight = btnRect.right - containerRect.right;
195
+
196
+ if (overflowLeft > 0) {
197
+ container.scrollTo({
198
+ left: container.scrollLeft - overflowLeft,
199
+ behavior: "smooth",
200
+ });
201
+ } else if (overflowRight > 0) {
202
+ container.scrollTo({
203
+ left: container.scrollLeft + overflowRight,
204
+ behavior: "smooth",
205
+ });
206
+ }
207
+ }, [isActive, scrollable]);
208
+
209
+ // ── Derived tokens ─────────────────────────────────────────────────────
210
+ const containerHeight = isStackedIcon
211
+ ? TabsTokens.containerHeightWithIcon
212
+ : TabsTokens.containerHeight;
213
+
214
+ const activeColor =
215
+ variant === "primary"
216
+ ? TabsColors.primaryActiveText
217
+ : TabsColors.secondaryActiveText;
218
+
219
+ const inactiveColor =
220
+ variant === "primary"
221
+ ? TabsColors.primaryInactiveText
222
+ : TabsColors.secondaryInactiveText;
223
+
224
+ const indicatorColor =
225
+ variant === "primary"
226
+ ? TabsColors.primaryIndicator
227
+ : TabsColors.secondaryIndicator;
228
+
229
+ const indicatorLayoutId = `${layoutGroupId}-indicator`;
230
+
231
+ const colorTransition = prefersReduced
232
+ ? { duration: 0 }
233
+ : TABS_COLOR_TRANSITION;
234
+ const springTransition = prefersReduced
235
+ ? { duration: 0 }
236
+ : TABS_INDICATOR_SPRING;
237
+
238
+ // ── IDs for ARIA wiring ────────────────────────────────────────────────
239
+ const tabId = `${layoutGroupId}-tab-${value}`;
240
+ const panelId = `${layoutGroupId}-panel-${value}`;
241
+
242
+ // ── Content wrapper layout ─────────────────────────────────────────────
243
+ // inlineIcon → flex-row; stacked icon → flex-col gap-0.5; text only → flex-col
244
+ const contentFlexClass = inlineIcon
245
+ ? "flex-row gap-2"
246
+ : isStackedIcon
247
+ ? "flex-col gap-0.5"
248
+ : "flex-col gap-0";
249
+
250
+ // Badge placement
251
+ const shouldWrapIconWithBadge = isStackedIcon && badge != null;
252
+ const shouldAppendInlineBadge = !isStackedIcon && badge != null;
253
+
254
+ return (
255
+ <button
256
+ ref={mergedRef}
257
+ id={tabId}
258
+ type="button"
259
+ role="tab"
260
+ aria-selected={isActive}
261
+ aria-controls={panelId}
262
+ aria-disabled={disabled || undefined}
263
+ disabled={disabled}
264
+ tabIndex={isFocused ? 0 : -1}
265
+ onClick={() => {
266
+ if (!disabled) {
267
+ onValueChange(value);
268
+ setFocusedValue(value);
269
+ }
270
+ }}
271
+ onFocus={() => setFocusedValue(value)}
272
+ onKeyDown={handleKeyDown}
273
+ className={cn(
274
+ "relative inline-flex items-center justify-center",
275
+ "cursor-pointer select-none",
276
+ scrollable ? "shrink-0" : "flex-1",
277
+ "focus-visible:outline-2 focus-visible:outline-offset-2",
278
+ "focus-visible:outline-(--md-sys-color-secondary)",
279
+ "focus-visible:rounded-lg",
280
+ "rounded-none",
281
+ disabled && "pointer-events-none opacity-[0.38]",
282
+ className,
283
+ )}
284
+ style={{
285
+ height: containerHeight,
286
+ zIndex: isActive ? 1 : 0,
287
+ ...(scrollable && { minWidth: TabsTokens.scrollableMinTabWidth }),
288
+ }}
289
+ >
290
+ {/*
291
+ * Content wrapper — PRIMARY INDICATOR TECHNIQUE:
292
+ * Indicator lives inside this wrapper → width matches content (not button).
293
+ * inlineIcon: flex-row places icon beside label, height stays 48dp.
294
+ */}
295
+ <m.div
296
+ className={cn(
297
+ "relative flex h-full items-center justify-center",
298
+ contentFlexClass,
299
+ )}
300
+ animate={{ color: isActive ? activeColor : inactiveColor }}
301
+ transition={colorTransition}
302
+ >
303
+ {/* Icon (optional) — 24dp per MD3 token */}
304
+ {hasIcon && (
305
+ <span
306
+ aria-hidden={!shouldWrapIconWithBadge ? "true" : undefined}
307
+ className={cn("flex shrink-0 items-center justify-center")}
308
+ style={{
309
+ width: TabsTokens.iconSize,
310
+ height: TabsTokens.iconSize,
311
+ }}
312
+ >
313
+ {shouldWrapIconWithBadge ? (
314
+ <BadgedBox badge={badge}>
315
+ <span aria-hidden="true">{icon}</span>
316
+ </BadgedBox>
317
+ ) : (
318
+ <span className="size-full" aria-hidden="true">
319
+ {icon}
320
+ </span>
321
+ )}
322
+ </span>
323
+ )}
324
+
325
+ {/* Label text — TitleSmall per MD3 typography token */}
326
+ <span className="text-title-sm font-medium whitespace-nowrap">
327
+ {children}
328
+ </span>
329
+
330
+ {/* Inline Badge */}
331
+ {shouldAppendInlineBadge && (
332
+ <span className="ml-1 flex items-center justify-center">
333
+ {badge}
334
+ </span>
335
+ )}
336
+
337
+ {/*
338
+ * PRIMARY INDICATOR
339
+ * Inside content wrapper → width matches content.
340
+ * `layoutId` enables shared layout animation across tabs.
341
+ */}
342
+ {variant === "primary" && isActive && (
343
+ <m.div
344
+ layoutId={indicatorLayoutId}
345
+ aria-hidden="true"
346
+ className="absolute bottom-0 left-1/2 -translate-x-1/2"
347
+ style={{
348
+ height: TabsTokens.primaryIndicatorHeight,
349
+ minWidth: INDICATOR_MIN_WIDTH,
350
+ width: "100%",
351
+ borderRadius: TabsTokens.indicatorBorderRadius,
352
+ backgroundColor: indicatorColor,
353
+ }}
354
+ transition={springTransition}
355
+ />
356
+ )}
357
+ </m.div>
358
+
359
+ {/*
360
+ * SECONDARY INDICATOR
361
+ * Outside content wrapper → `inset-x-0` = full button width.
362
+ */}
363
+ {variant === "secondary" && isActive && (
364
+ <m.div
365
+ layoutId={indicatorLayoutId}
366
+ aria-hidden="true"
367
+ className="absolute bottom-0 inset-x-0"
368
+ style={{
369
+ height: TabsTokens.secondaryIndicatorHeight,
370
+ borderRadius: TabsTokens.indicatorBorderRadius,
371
+ backgroundColor: indicatorColor,
372
+ }}
373
+ transition={springTransition}
374
+ />
375
+ )}
376
+ </button>
377
+ );
378
+ },
379
+ );
380
+
381
+ TabComponent.displayName = "Tab";
382
+
383
+ /**
384
+ * MD3 Expressive Tab component — individual tab button.
385
+ *
386
+ * Must be a direct child of `<TabsList>`. Implements WAI-ARIA Tabs pattern
387
+ * with roving tabindex keyboard navigation.
388
+ *
389
+ * - **Primary variant**: indicator width = content (text + icon) width.
390
+ * - **Secondary variant**: indicator width = full button hit area.
391
+ * - **Disabled**: Skipped entirely in ArrowKey navigation (cannot be focused).
392
+ * - **inlineIcon**: Icon beside (not above) label; height stays 48dp.
393
+ * - Framer Motion `layoutId` animates indicator with spring physics.
394
+ * - ArrowLeft/Right respect RTL direction automatically.
395
+ *
396
+ * @example
397
+ * ```tsx
398
+ * <Tab value="flights" icon={<Icon name="flight" />}>Flights</Tab>
399
+ * <Tab value="trips">Trips</Tab>
400
+ * <Tab value="explore" disabled>Explore</Tab>
401
+ * <Tab value="hotels" icon={<Icon name="hotel" />} inlineIcon>Hotels</Tab>
402
+ * ```
403
+ *
404
+ * @see https://m3.material.io/components/tabs/overview
405
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
406
+ */
407
+ export const Tab = React.memo(TabComponent);
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @file tabs-content.tsx
3
+ * MD3 Expressive TabsContent — Animated panel component.
4
+ *
5
+ * Implements WAI-ARIA tabpanel role with:
6
+ * - AnimatePresence for fade transition on tab switch
7
+ * - Proper aria-labelledby pointing to the associated <Tab>
8
+ * - tabIndex=0 so keyboard users can Tab from the tablist into the panel
9
+ * - Hidden panels are removed from the DOM (not just visually hidden)
10
+ * to prevent screen readers from reading inactive content
11
+ */
12
+
13
+ import { AnimatePresence, m, useReducedMotion } from "motion/react";
14
+ import * as React from "react";
15
+ import { cn } from "../../lib/utils";
16
+ import { useTabsContext } from "./tabs";
17
+ import { TABS_CONTENT_TRANSITION } from "./tabs.tokens";
18
+ import type { TabsContentProps } from "./tabs.types";
19
+
20
+ // ─── TabsContent ───────────────────────────────────────────────────────────────
21
+
22
+ const TabsContentComponent = React.forwardRef<HTMLDivElement, TabsContentProps>(
23
+ ({ value, className, children }, ref) => {
24
+ const { value: selectedValue, layoutGroupId } = useTabsContext();
25
+ const isActive = selectedValue === value;
26
+ const prefersReduced = useReducedMotion() ?? false;
27
+
28
+ // ARIA wiring: panel is labelled by its corresponding <Tab> button
29
+ const tabId = `${layoutGroupId}-tab-${value}`;
30
+ const panelId = `${layoutGroupId}-panel-${value}`;
31
+
32
+ const contentTransition = prefersReduced
33
+ ? { duration: 0 }
34
+ : TABS_CONTENT_TRANSITION;
35
+
36
+ return (
37
+ <AnimatePresence mode="popLayout" initial={false}>
38
+ {isActive && (
39
+ <m.div
40
+ ref={ref}
41
+ key={value}
42
+ id={panelId}
43
+ role="tabpanel"
44
+ aria-labelledby={tabId}
45
+ tabIndex={0}
46
+ className={cn(
47
+ "focus:outline-none w-full",
48
+ "focus-visible:outline-2 focus-visible:outline-offset-2",
49
+ "focus-visible:outline-(--md-sys-color-secondary)",
50
+ className,
51
+ )}
52
+ initial={{ opacity: 0 }}
53
+ animate={{ opacity: 1 }}
54
+ exit={{ opacity: 0 }}
55
+ transition={contentTransition}
56
+ >
57
+ {children}
58
+ </m.div>
59
+ )}
60
+ </AnimatePresence>
61
+ );
62
+ },
63
+ );
64
+
65
+ TabsContentComponent.displayName = "TabsContent";
66
+
67
+ /**
68
+ * MD3 Expressive TabsContent panel component.
69
+ *
70
+ * Each panel corresponds to a `<Tab>` with the same `value`.
71
+ * Only the active panel is rendered in the DOM — inactive panels
72
+ * are fully unmounted (not `display: none`) to prevent screen readers
73
+ * from reading hidden content.
74
+ *
75
+ * Fade animation is applied on both enter and exit via Framer Motion
76
+ * `AnimatePresence`. We use `mode="popLayout"` to prevent height layout shifting
77
+ * during tab transitions. Animation is automatically disabled when the user
78
+ * has enabled `prefers-reduced-motion`.
79
+ *
80
+ * @example
81
+ * ```tsx
82
+ * <TabsContent value="flights">
83
+ * <p>Available flights...</p>
84
+ * </TabsContent>
85
+ * ```
86
+ *
87
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
88
+ */
89
+ export const TabsContent = React.memo(TabsContentComponent);
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @file tabs-list.tsx
3
+ * MD3 Expressive TabsList — Container component for tab buttons.
4
+ *
5
+ * Responsibilities:
6
+ * - Applies variant (primary/secondary) layout and styling
7
+ * - Manages horizontal scroll for scrollable mode (52px edge padding per MD3)
8
+ * - Renders the bottom divider for secondary variant
9
+ * - Scopes Framer Motion LayoutGroup so indicators animate correctly
10
+ * when multiple <Tabs> instances are on the same page
11
+ * - Restores focus to activeTab when keyboard focus leaves the tablist
12
+ * (matches Google's `focusout` handler on <md-tabs>)
13
+ */
14
+
15
+ import { LayoutGroup } from "motion/react";
16
+ import * as React from "react";
17
+ import { cn } from "../../lib/utils";
18
+ import { TabsListContext, useTabsContext } from "./tabs";
19
+ import { TabsTokens } from "./tabs.tokens";
20
+ import type { TabsListProps } from "./tabs.types";
21
+
22
+ // ─── TabsList ──────────────────────────────────────────────────────────────────
23
+
24
+ const TabsListComponent = React.forwardRef<HTMLDivElement, TabsListProps>(
25
+ (
26
+ {
27
+ variant,
28
+ scrollable = false,
29
+ backgroundColor,
30
+ children,
31
+ className,
32
+ "aria-label": ariaLabel,
33
+ },
34
+ ref,
35
+ ) => {
36
+ const { layoutGroupId, value, setFocusedValue } = useTabsContext();
37
+
38
+ // Unique layout group ID scoped to this TabsList instance.
39
+ const listLayoutId = `${layoutGroupId}-list`;
40
+
41
+ // ── TabsListContext: provide variant + scrollable to children ──────────
42
+ const listContextValue = React.useMemo(
43
+ () => ({ variant, scrollable }),
44
+ [variant, scrollable],
45
+ );
46
+
47
+ // ── Background color ───────────────────────────────────────────────────
48
+ const bgColor = backgroundColor ?? "var(--md-sys-color-surface)";
49
+
50
+ // ── Focusout handler — restore roving focus to active tab ──────────────
51
+ // When keyboard focus leaves the tablist entirely (e.g. user presses Tab
52
+ // to move to the panel), reset `focusedValue` back to the selected tab.
53
+ // This ensures the next time the user Tabs back into the tablist, focus
54
+ // lands on the active tab — not the last arrow-key-focused tab.
55
+ // Mirrors Google's `handleFocusout` in tabs.ts:
56
+ // "restore focus to selected item when blurring the tab bar"
57
+ const handleBlur = React.useCallback(
58
+ (e: React.FocusEvent<HTMLDivElement>) => {
59
+ // `relatedTarget` is the element receiving focus.
60
+ // If it's still inside the tablist, this is an internal focus move
61
+ // (e.g. clicking another tab) — don't restore.
62
+ const listEl = e.currentTarget;
63
+ if (listEl.contains(e.relatedTarget as Node | null)) return;
64
+
65
+ // Focus left the tablist — restore focusedValue to the active tab.
66
+ setFocusedValue(value);
67
+ },
68
+ [value, setFocusedValue],
69
+ );
70
+
71
+ return (
72
+ <TabsListContext.Provider value={listContextValue}>
73
+ {/* LayoutGroup scopes shared layout animation so indicators from different Tabs instances don't bleed into each other. */}
74
+ <LayoutGroup id={listLayoutId}>
75
+ {/* Outer wrapper: positioning context for the secondary divider */}
76
+ <div
77
+ ref={ref}
78
+ className={cn("relative w-full", className)}
79
+ style={{ backgroundColor: bgColor }}
80
+ >
81
+ <div
82
+ role="tablist"
83
+ aria-label={ariaLabel}
84
+ onBlur={handleBlur}
85
+ className={cn(
86
+ "flex flex-row items-stretch",
87
+ scrollable &&
88
+ "overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
89
+ )}
90
+ style={
91
+ scrollable
92
+ ? {
93
+ paddingLeft: TabsTokens.scrollableEdgePadding,
94
+ paddingRight: TabsTokens.scrollableEdgePadding,
95
+ }
96
+ : undefined
97
+ }
98
+ >
99
+ {children}
100
+ </div>
101
+
102
+ {/* Secondary variant: bottom divider — absolute so it doesn't affect tab layout flow */}
103
+ {variant === "secondary" && (
104
+ <div
105
+ aria-hidden="true"
106
+ className="absolute bottom-0 left-0 right-0"
107
+ style={{
108
+ height: TabsTokens.dividerHeight,
109
+ backgroundColor: "var(--md-sys-color-surface-variant)",
110
+ }}
111
+ />
112
+ )}
113
+ </div>
114
+ </LayoutGroup>
115
+ </TabsListContext.Provider>
116
+ );
117
+ },
118
+ );
119
+
120
+ TabsListComponent.displayName = "TabsList";
121
+
122
+ /**
123
+ * MD3 Expressive TabsList container component.
124
+ *
125
+ * Renders a horizontal row of `<Tab>` components with MD3-compliant
126
+ * layout (fixed or scrollable) and variant styling (primary or secondary).
127
+ *
128
+ * - **Primary**: Tabs divide available width equally, indicator width = content width.
129
+ * - **Secondary**: Tabs divide equally + full-width indicator + bottom divider line.
130
+ * - **Scrollable**: Tabs have min-width (90px), scroll horizontally with 52px edge padding.
131
+ * - **Focusout**: When focus leaves the tablist, roving focus resets to the active tab.
132
+ *
133
+ * @example
134
+ * ```tsx
135
+ * <TabsList variant="primary" scrollable={false}>
136
+ * <Tab value="tab1">Tab 1</Tab>
137
+ * <Tab value="tab2">Tab 2</Tab>
138
+ * </TabsList>
139
+ *
140
+ * <TabsList variant="secondary" scrollable={true} aria-label="Content sections">
141
+ * <Tab value="a">Alpha</Tab>
142
+ * <Tab value="b">Beta</Tab>
143
+ * </TabsList>
144
+ * ```
145
+ */
146
+ export const TabsList = React.memo(TabsListComponent);