@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,476 @@
1
+ /**
2
+ * @file snackbar.tsx
3
+ *
4
+ * MD3 Expressive Snackbar component.
5
+ *
6
+ * Architecture:
7
+ * - `Snackbar` → Pure display component (motion.div, role="status", aria-live="polite")
8
+ * - `SnackbarHost` → AnimatePresence container + queue flush (place once in layout)
9
+ * - `SnackbarProvider` → Context provider that wires SnackbarHost + exposes `useSnackbar`
10
+ * - `useSnackbarState` → Low-level ref-based queue hook (mutex pattern)
11
+ * - `useSnackbar` → Consumer hook for imperative `showSnackbar(visuals)` calls
12
+ *
13
+ * Queue strategy:
14
+ * - One snackbar visible at a time (MD3 spec).
15
+ * - Subsequent `showSnackbar()` calls are enqueued, shown as soon as current one dismisses.
16
+ * - Cleanup: on unmount, all pending promises resolve as 'dismissed'.
17
+ *
18
+ * @see https://m3.material.io/components/snackbar/overview
19
+ */
20
+
21
+ import {
22
+ AnimatePresence,
23
+ domMax,
24
+ LazyMotion,
25
+ m,
26
+ useReducedMotion,
27
+ } from "motion/react";
28
+ import * as React from "react";
29
+ import { cn } from "../../lib/utils";
30
+ import { Icon } from "../icon";
31
+ import { IconButton } from "../icon-button";
32
+
33
+ // ─── Constants ────────────────────────────────────────────────────────────────
34
+
35
+ const DURATION_MAP = {
36
+ short: 4000,
37
+ long: 7000,
38
+ } as const;
39
+
40
+ const RESULT = {
41
+ ACTION: "action-performed",
42
+ DISMISSED: "dismissed",
43
+ } as const satisfies Record<string, SnackbarResult>;
44
+
45
+ // ─── Animation Config (Framer Motion — NOT CSS transitions) ──────────────────
46
+
47
+ const SNACKBAR_SPRING = {
48
+ type: "spring" as const,
49
+ bounce: 0.15,
50
+ duration: 0.4,
51
+ };
52
+
53
+ const SNACKBAR_ANIM = {
54
+ initial: { opacity: 0, y: 56, scale: 0.9 },
55
+ animate: {
56
+ opacity: 1,
57
+ y: 0,
58
+ scale: 1,
59
+ transition: SNACKBAR_SPRING,
60
+ },
61
+ exit: {
62
+ opacity: 0,
63
+ y: 24,
64
+ scale: 0.95,
65
+ transition: { duration: 0.2, ease: "easeIn" as const },
66
+ },
67
+ } as const;
68
+
69
+ const REDUCED_MOTION_ANIM = {
70
+ initial: { opacity: 0 },
71
+ animate: { opacity: 1 },
72
+ exit: { opacity: 0 },
73
+ } as const;
74
+
75
+ // ─── Types ────────────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * Duration preset for the snackbar auto-dismiss timer.
79
+ * - `'short'` → 4 000 ms (default, MD3 spec)
80
+ * - `'long'` → 7 000 ms
81
+ * - `number` → custom milliseconds
82
+ */
83
+ export type SnackbarDuration = "short" | "long" | number;
84
+
85
+ /**
86
+ * Resolution value returned by the `showSnackbar()` promise.
87
+ * - `'action-performed'` → user clicked the action button
88
+ * - `'dismissed'` → auto-dismissed or close button clicked
89
+ */
90
+ export type SnackbarResult = "action-performed" | "dismissed";
91
+
92
+ /**
93
+ * Visual configuration for a single snackbar instance.
94
+ */
95
+ export interface SnackbarVisuals {
96
+ /** Main message text. */
97
+ message: string;
98
+ /** Label for the optional action button. */
99
+ actionLabel?: string;
100
+ /** When `true`, renders a close (X) icon button. @default false */
101
+ withDismissAction?: boolean;
102
+ /**
103
+ * When `true`, renders the action button below the message (Column layout).
104
+ * Use when both message and actionLabel are long.
105
+ * @default false
106
+ */
107
+ actionOnNewLine?: boolean;
108
+ /**
109
+ * Auto-dismiss duration.
110
+ * @default 'short' (4 000 ms)
111
+ */
112
+ duration?: SnackbarDuration;
113
+ /** Additional className applied to the snackbar container. */
114
+ className?: string;
115
+ }
116
+
117
+ /**
118
+ * Internal runtime data for a currently-displayed snackbar.
119
+ * Includes the resolve callback to settle the caller's promise.
120
+ */
121
+ export interface SnackbarData {
122
+ /** Unique key for AnimatePresence element diffing. */
123
+ id: string;
124
+ /** Visual configuration. */
125
+ visuals: SnackbarVisuals;
126
+ /** Settles the promise returned by `showSnackbar()`. */
127
+ resolve: (result: SnackbarResult) => void;
128
+ }
129
+
130
+ /** Props for the pure `Snackbar` display component. */
131
+ export interface SnackbarProps {
132
+ /** Runtime data including message, actions, and resolve callback. */
133
+ data: SnackbarData;
134
+ /** Additional className merged onto the snackbar container. */
135
+ className?: string;
136
+ }
137
+
138
+ /** Props for the `SnackbarHost` component. */
139
+ export interface SnackbarHostProps {
140
+ /** State returned by `useSnackbarState()`. */
141
+ state: UseSnackbarStateReturn;
142
+ /** Additional className applied to the fixed host wrapper. */
143
+ className?: string;
144
+ }
145
+
146
+ // ─── Internal Queue Item ──────────────────────────────────────────────────────
147
+
148
+ interface QueueItem {
149
+ visuals: SnackbarVisuals;
150
+ resolve: (result: SnackbarResult) => void;
151
+ }
152
+
153
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
154
+
155
+ function resolveDuration(duration: SnackbarDuration | undefined): number {
156
+ if (duration === undefined) return DURATION_MAP.short;
157
+ if (typeof duration === "number") return duration;
158
+ return DURATION_MAP[duration];
159
+ }
160
+
161
+ function generateId(): string {
162
+ return typeof crypto !== "undefined" && crypto.randomUUID
163
+ ? crypto.randomUUID()
164
+ : Math.random().toString(36).slice(2);
165
+ }
166
+
167
+ function toSnackbarData(item: QueueItem): SnackbarData {
168
+ return { id: generateId(), visuals: item.visuals, resolve: item.resolve };
169
+ }
170
+
171
+ // ─── useSnackbarState hook ────────────────────────────────────────────────────
172
+
173
+ /** Return type of `useSnackbarState`. */
174
+ export interface UseSnackbarStateReturn {
175
+ /** Currently visible snackbar data, or `null` when idle. */
176
+ current: SnackbarData | null;
177
+ /**
178
+ * Show a snackbar with the given visuals.
179
+ * Returns a promise that resolves when the snackbar is dismissed or the action is triggered.
180
+ */
181
+ showSnackbar: (visuals: SnackbarVisuals) => Promise<SnackbarResult>;
182
+ /** Internal dismiss handler — called by `SnackbarHost`. */
183
+ _dismiss: (result: SnackbarResult) => void;
184
+ }
185
+
186
+ /**
187
+ * Low-level hook that manages the snackbar queue and current state.
188
+ *
189
+ * Uses a `ref`-based queue (mutex pattern) so that enqueueing never
190
+ * triggers a re-render storm — only the state transition does.
191
+ *
192
+ * @example
193
+ * ```tsx
194
+ * // Used internally by SnackbarProvider
195
+ * const state = useSnackbarState();
196
+ * return <SnackbarHost state={state} />;
197
+ * ```
198
+ */
199
+ export function useSnackbarState(): UseSnackbarStateReturn {
200
+ const [current, setCurrent] = React.useState<SnackbarData | null>(null);
201
+ const queueRef = React.useRef<QueueItem[]>([]);
202
+
203
+ const showSnackbar = React.useCallback(
204
+ (visuals: SnackbarVisuals): Promise<SnackbarResult> => {
205
+ return new Promise<SnackbarResult>((resolve) => {
206
+ const item: QueueItem = { visuals, resolve };
207
+ setCurrent((prev) => {
208
+ if (prev === null) return toSnackbarData(item);
209
+ queueRef.current.push(item);
210
+ return prev;
211
+ });
212
+ });
213
+ },
214
+ [],
215
+ );
216
+
217
+ const _dismiss = React.useCallback((result: SnackbarResult) => {
218
+ setCurrent((prev) => {
219
+ if (prev) prev.resolve(result);
220
+ const next = queueRef.current.shift();
221
+ return next ? toSnackbarData(next) : null;
222
+ });
223
+ }, []);
224
+
225
+ React.useEffect(() => {
226
+ return () => {
227
+ for (const item of queueRef.current) {
228
+ item.resolve(RESULT.DISMISSED);
229
+ }
230
+ queueRef.current = [];
231
+ };
232
+ }, []);
233
+
234
+ return { current, showSnackbar, _dismiss };
235
+ }
236
+
237
+ // ─── Snackbar (Pure Display Component) ───────────────────────────────────────
238
+
239
+ /**
240
+ * MD3 Expressive Snackbar — pure display component.
241
+ *
242
+ * Renders a single snackbar with message, optional action button, and
243
+ * optional dismiss icon button. Handles its own auto-dismiss timer.
244
+ *
245
+ * @remarks
246
+ * - Uses `role="status"` + `aria-live="polite"` for screen reader announcements.
247
+ * - All entrance/exit animation is handled by the parent `SnackbarHost` via
248
+ * `AnimatePresence` + `SNACKBAR_ANIM`.
249
+ * - Do NOT render this component directly — use `SnackbarHost`.
250
+ *
251
+ * @example
252
+ * ```tsx
253
+ * // Internal usage inside SnackbarHost — not for direct use
254
+ * <Snackbar data={currentSnackbarData} />
255
+ * ```
256
+ */
257
+ export const Snackbar = React.memo(function Snackbar({
258
+ data,
259
+ className,
260
+ }: SnackbarProps) {
261
+ const { visuals, resolve } = data;
262
+ const {
263
+ message,
264
+ actionLabel,
265
+ withDismissAction = false,
266
+ actionOnNewLine = false,
267
+ duration,
268
+ } = visuals;
269
+
270
+ const reducedMotion = useReducedMotion();
271
+ const durationMs = resolveDuration(duration);
272
+
273
+ React.useEffect(() => {
274
+ const timer = setTimeout(() => resolve(RESULT.DISMISSED), durationMs);
275
+ return () => clearTimeout(timer);
276
+ }, [resolve, durationMs]);
277
+
278
+ const handleAction = React.useCallback(
279
+ () => resolve(RESULT.ACTION),
280
+ [resolve],
281
+ );
282
+
283
+ const handleDismiss = React.useCallback(
284
+ () => resolve(RESULT.DISMISSED),
285
+ [resolve],
286
+ );
287
+
288
+ const hasActions = actionLabel || withDismissAction;
289
+ const anim = reducedMotion ? REDUCED_MOTION_ANIM : SNACKBAR_ANIM;
290
+
291
+ return (
292
+ <m.div
293
+ role="status"
294
+ aria-live="polite"
295
+ aria-atomic="true"
296
+ {...anim}
297
+ className={cn(
298
+ "flex items-center gap-2",
299
+ "min-w-72 max-w-142 w-max",
300
+ "rounded-sm px-4 py-3 shadow-md",
301
+ "text-m3-inverse-on-surface bg-m3-inverse-surface",
302
+ actionOnNewLine ? "flex-col items-start" : "flex-row",
303
+ className,
304
+ visuals.className,
305
+ )}
306
+ >
307
+ <span className="flex-1 text-sm leading-5 font-normal">{message}</span>
308
+
309
+ {hasActions && (
310
+ <div
311
+ className={cn(
312
+ "flex shrink-0 items-center gap-1",
313
+ actionOnNewLine && "self-end",
314
+ )}
315
+ >
316
+ {actionLabel && (
317
+ <button
318
+ type="button"
319
+ onClick={handleAction}
320
+ className={cn(
321
+ "text-sm font-medium",
322
+ "px-2 py-1 rounded-sm",
323
+ "focus-visible:outline-none focus-visible:ring-2",
324
+ "transition-colors whitespace-nowrap",
325
+ "text-m3-inverse-primary",
326
+ )}
327
+ >
328
+ {actionLabel}
329
+ </button>
330
+ )}
331
+
332
+ {withDismissAction && (
333
+ <IconButton
334
+ size="sm"
335
+ colorStyle="filled"
336
+ aria-label="Dismiss notification"
337
+ onClick={handleDismiss}
338
+ className="text-m3-inverse-on-surface bg-m3-inverse-surface"
339
+ >
340
+ <Icon name="close" aria-hidden="true" />
341
+ </IconButton>
342
+ )}
343
+ </div>
344
+ )}
345
+ </m.div>
346
+ );
347
+ });
348
+
349
+ Snackbar.displayName = "Snackbar";
350
+
351
+ // ─── SnackbarHost ─────────────────────────────────────────────────────────────
352
+
353
+ /**
354
+ * MD3 SnackbarHost — renders the AnimatePresence container for snackbar queue.
355
+ *
356
+ * Place this once in your app layout. It will show snackbars one at a time,
357
+ * dequeuing the next one as each dismisses.
358
+ *
359
+ * @example
360
+ * ```tsx
361
+ * // Typically used inside SnackbarProvider — not directly
362
+ * const state = useSnackbarState();
363
+ * <SnackbarHost state={state} />
364
+ * ```
365
+ */
366
+ export function SnackbarHost({ state, className }: SnackbarHostProps) {
367
+ const { current, _dismiss } = state;
368
+
369
+ const wrappedData = React.useMemo<SnackbarData | null>(() => {
370
+ if (!current) return null;
371
+ return { ...current, resolve: _dismiss };
372
+ }, [current, _dismiss]);
373
+
374
+ return (
375
+ <LazyMotion features={domMax} strict>
376
+ <section
377
+ aria-label="Snackbar notifications"
378
+ className={cn(
379
+ "fixed bottom-4 left-1/2 -translate-x-1/2 z-50",
380
+ "flex flex-col items-center pointer-events-none",
381
+ className,
382
+ )}
383
+ >
384
+ <AnimatePresence mode="wait">
385
+ {wrappedData && (
386
+ <div key={wrappedData.id} className="pointer-events-auto">
387
+ <Snackbar data={wrappedData} />
388
+ </div>
389
+ )}
390
+ </AnimatePresence>
391
+ </section>
392
+ </LazyMotion>
393
+ );
394
+ }
395
+
396
+ SnackbarHost.displayName = "SnackbarHost";
397
+
398
+ // ─── Context ──────────────────────────────────────────────────────────────────
399
+
400
+ interface SnackbarContextValue {
401
+ showSnackbar: (visuals: SnackbarVisuals) => Promise<SnackbarResult>;
402
+ }
403
+
404
+ export const SnackbarContext = React.createContext<SnackbarContextValue | null>(
405
+ null,
406
+ );
407
+
408
+ // ─── SnackbarProvider ─────────────────────────────────────────────────────────
409
+
410
+ /**
411
+ * MD3 SnackbarProvider — context provider for imperative snackbar API.
412
+ *
413
+ * Wrap your application (or a section of it) with this provider.
414
+ * Then use `useSnackbar()` in any descendant to show snackbars.
415
+ *
416
+ * @example
417
+ * ```tsx
418
+ * // In your root layout:
419
+ * <SnackbarProvider>
420
+ * <App />
421
+ * </SnackbarProvider>
422
+ *
423
+ * // In any component:
424
+ * const { showSnackbar } = useSnackbar();
425
+ * await showSnackbar({ message: 'Saved!', actionLabel: 'Undo' });
426
+ * ```
427
+ */
428
+ export function SnackbarProvider({ children }: { children: React.ReactNode }) {
429
+ const state = useSnackbarState();
430
+
431
+ const contextValue = React.useMemo<SnackbarContextValue>(
432
+ () => ({ showSnackbar: state.showSnackbar }),
433
+ [state.showSnackbar],
434
+ );
435
+
436
+ return (
437
+ <SnackbarContext.Provider value={contextValue}>
438
+ {children}
439
+ <SnackbarHost state={state} />
440
+ </SnackbarContext.Provider>
441
+ );
442
+ }
443
+
444
+ SnackbarProvider.displayName = "SnackbarProvider";
445
+
446
+ // ─── useSnackbar hook ─────────────────────────────────────────────────────────
447
+
448
+ /**
449
+ * Hook that returns the `showSnackbar` function from the nearest `SnackbarProvider`.
450
+ *
451
+ * @throws {Error} if used outside of a `SnackbarProvider`.
452
+ *
453
+ * @example
454
+ * ```tsx
455
+ * function SaveButton() {
456
+ * const { showSnackbar } = useSnackbar();
457
+ *
458
+ * const handleSave = async () => {
459
+ * const result = await showSnackbar({
460
+ * message: 'Changes saved',
461
+ * actionLabel: 'Undo',
462
+ * });
463
+ * if (result === 'action-performed') undoSave();
464
+ * };
465
+ *
466
+ * return <button onClick={handleSave}>Save</button>;
467
+ * }
468
+ * ```
469
+ */
470
+ export function useSnackbar(): SnackbarContextValue {
471
+ const ctx = React.useContext(SnackbarContext);
472
+ if (!ctx) {
473
+ throw new Error("useSnackbar must be used within a <SnackbarProvider>.");
474
+ }
475
+ return ctx;
476
+ }
@@ -2,6 +2,7 @@
2
2
  * @file switch/index.ts
3
3
  * Public exports for the MD3 Expressive Switch component.
4
4
  */
5
+
5
6
  export { Switch } from "./switch";
6
7
  export { SwitchColors, SwitchTokens } from "./switch.tokens";
7
8
  export type { SwitchProps } from "./switch.types";