@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,49 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { SearchAppBar } from "./search-app-bar";
4
+ import { SearchView } from "./search-view";
5
+
6
+ describe("SearchAppBar and SearchView", () => {
7
+ describe("SearchAppBar", () => {
8
+ it("renders correctly with search bar", () => {
9
+ render(<SearchAppBar searchPlaceholder="Search here..." />);
10
+
11
+ expect(screen.getByText("Search here...")).toBeInTheDocument();
12
+ });
13
+
14
+ it("triggers onSearchFocus when clicked and via Enter key", () => {
15
+ const onSearchFocus = vi.fn();
16
+ render(<SearchAppBar onSearchFocus={onSearchFocus} />);
17
+
18
+ const searchBar = screen.getByRole("search");
19
+ fireEvent.click(searchBar);
20
+ expect(onSearchFocus).toHaveBeenCalledTimes(1);
21
+
22
+ fireEvent.keyDown(searchBar, { key: "Enter" });
23
+ expect(onSearchFocus).toHaveBeenCalledTimes(2);
24
+ });
25
+ });
26
+
27
+ describe("SearchView", () => {
28
+ it("renders overlay correctly", () => {
29
+ const onClose = vi.fn();
30
+ render(
31
+ <SearchView onClose={onClose} placeholder="Type to search">
32
+ <div data-testid="search-results">Results</div>
33
+ </SearchView>,
34
+ );
35
+
36
+ expect(screen.getByPlaceholderText("Type to search")).toBeInTheDocument();
37
+ expect(screen.getByTestId("search-results")).toBeInTheDocument();
38
+ });
39
+
40
+ it("calls onClose when back button is pressed", () => {
41
+ const onClose = vi.fn();
42
+ render(<SearchView onClose={onClose} />);
43
+
44
+ const btn = screen.getByRole("button", { name: "Close search" });
45
+ fireEvent.click(btn);
46
+ expect(onClose).toHaveBeenCalledTimes(1);
47
+ });
48
+ });
49
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * @file search-app-bar.tsx
3
+ * MD3 Expressive Search App Bar.
4
+ *
5
+ * New variant in MD3 Expressive (May 2025).
6
+ * Replaces the title area with a pill-shaped search bar.
7
+ * Uses Framer Motion layoutId to enable shared element transition with <SearchView>.
8
+ *
9
+ * @see docs/m3/app-bars/AppBar.kt — SearchAppBar
10
+ */
11
+
12
+ import { m, useReducedMotion } from "motion/react";
13
+ import * as React from "react";
14
+ import { cn } from "../../lib/utils";
15
+ import {
16
+ APP_BAR_COLOR_TRANSITION,
17
+ APP_BAR_COLORS,
18
+ AppBarTokens,
19
+ } from "./app-bar.tokens";
20
+ import type { SearchAppBarProps } from "./app-bar.types";
21
+ import { useAppBarScroll } from "./hooks/use-app-bar-scroll";
22
+
23
+ /** Built-in search icon (Material Symbols) used as default. */
24
+ function SearchIcon({ className }: { className?: string }) {
25
+ return (
26
+ <span
27
+ className={cn(
28
+ "material-symbols-rounded text-[20px] leading-none select-none",
29
+ className,
30
+ )}
31
+ aria-hidden="true"
32
+ >
33
+ search
34
+ </span>
35
+ );
36
+ }
37
+
38
+ /**
39
+ * MD3 Expressive Search App Bar.
40
+ *
41
+ * When the search bar is clicked, callers should open a `<SearchView>` overlay.
42
+ * Uses Framer Motion `layoutId` (via `searchBarId`) for a smooth shared-element
43
+ * transition between this bar and the search view.
44
+ *
45
+ * @example
46
+ * ```tsx
47
+ * const [searchOpen, setSearchOpen] = useState(false);
48
+ *
49
+ * <SearchAppBar
50
+ * searchBarId="main-search"
51
+ * searchPlaceholder="Search messages..."
52
+ * onSearchFocus={() => setSearchOpen(true)}
53
+ * trailingSearchActions={
54
+ * <IconButton aria-label="Voice search"><Icon>mic</Icon></IconButton>
55
+ * }
56
+ * externalActions={<Avatar src={user.avatar} />}
57
+ * />
58
+ *
59
+ * <AnimatePresence>
60
+ * {searchOpen && (
61
+ * <SearchView
62
+ * searchBarId="main-search"
63
+ * onClose={() => setSearchOpen(false)}
64
+ * />
65
+ * )}
66
+ * </AnimatePresence>
67
+ * ```
68
+ */
69
+ export function SearchAppBar({
70
+ searchPlaceholder = "Search",
71
+ searchValue,
72
+ onSearchFocus,
73
+ leadingSearchIcon,
74
+ trailingSearchActions,
75
+ externalActions,
76
+ navigationIcon,
77
+ colors,
78
+ scrollBehavior = "pinned",
79
+ scrollElement,
80
+ searchBarId = "search-bar",
81
+ className,
82
+ }: SearchAppBarProps) {
83
+ const shouldReduceMotion = useReducedMotion();
84
+ const [isSearchOpen, setIsSearchOpen] = React.useState(false);
85
+
86
+ const { isScrolled } = useAppBarScroll({
87
+ scrollElement,
88
+ behavior:
89
+ scrollBehavior === "exitUntilCollapsed" ? "pinned" : scrollBehavior,
90
+ });
91
+
92
+ const containerBg = colors?.containerColor ?? APP_BAR_COLORS.container;
93
+ const scrolledBg =
94
+ colors?.scrolledContainerColor ?? APP_BAR_COLORS.scrolledContainer;
95
+ const currentBg = isScrolled ? scrolledBg : containerBg;
96
+
97
+ const cssTransition = shouldReduceMotion
98
+ ? undefined
99
+ : `background-color ${APP_BAR_COLOR_TRANSITION.duration}s cubic-bezier(${APP_BAR_COLOR_TRANSITION.ease.join(",")})`;
100
+
101
+ const handleSearchClick = () => {
102
+ setIsSearchOpen(true);
103
+ onSearchFocus?.();
104
+ };
105
+
106
+ const handleKeyDown = (e: React.KeyboardEvent) => {
107
+ if (e.key === "Enter" || e.key === " ") {
108
+ e.preventDefault();
109
+ handleSearchClick();
110
+ }
111
+ };
112
+
113
+ return (
114
+ <m.header
115
+ role="banner"
116
+ className={cn(
117
+ "fixed top-0 inset-x-0 z-50 flex items-center gap-2 px-4",
118
+ className,
119
+ )}
120
+ style={{
121
+ height: AppBarTokens.heights.small,
122
+ backgroundColor: currentBg,
123
+ transition: cssTransition,
124
+ }}
125
+ >
126
+ {/* Optional navigation icon — leading edge */}
127
+ {navigationIcon && (
128
+ <div
129
+ className="shrink-0 flex items-center justify-center"
130
+ style={{
131
+ width: AppBarTokens.iconButtonTouchTarget,
132
+ height: AppBarTokens.iconButtonTouchTarget,
133
+ }}
134
+ >
135
+ {navigationIcon}
136
+ </div>
137
+ )}
138
+
139
+ <m.div
140
+ layoutId={shouldReduceMotion ? undefined : searchBarId}
141
+ role="search"
142
+ aria-label={searchPlaceholder}
143
+ aria-expanded={isSearchOpen}
144
+ tabIndex={0}
145
+ className="relative flex flex-1 items-center gap-2 rounded-full cursor-text h-10 px-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
146
+ onClick={handleSearchClick}
147
+ onKeyDown={handleKeyDown}
148
+ >
149
+ <div className="absolute inset-0 rounded-full bg-m3-surface-container-high -z-10" />
150
+ <span
151
+ className="shrink-0"
152
+ style={{ color: APP_BAR_COLORS.searchBarContent }}
153
+ >
154
+ {leadingSearchIcon ?? <SearchIcon />}
155
+ </span>
156
+
157
+ <span
158
+ className="flex-1 text-[16px] leading-6 truncate select-none"
159
+ style={{ color: APP_BAR_COLORS.searchBarContent }}
160
+ >
161
+ {searchValue ?? searchPlaceholder}
162
+ </span>
163
+
164
+ {trailingSearchActions && (
165
+ <div className="flex items-center shrink-0">
166
+ {trailingSearchActions}
167
+ </div>
168
+ )}
169
+ </m.div>
170
+
171
+ {externalActions && (
172
+ <div className="flex items-center shrink-0">{externalActions}</div>
173
+ )}
174
+ </m.header>
175
+ );
176
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * @file search-view.tsx
3
+ * MD3 Expressive Search View.
4
+ *
5
+ * Full-screen overlay activated when a SearchAppBar's search bar is clicked.
6
+ * Shares a Framer Motion `layoutId` with the SearchAppBar search bar for a
7
+ * smooth shared element transition.
8
+ *
9
+ * Usage pattern:
10
+ * ```tsx
11
+ * const [open, setOpen] = useState(false);
12
+ *
13
+ * // In render:
14
+ * <SearchAppBar searchBarId="main" onSearchFocus={() => setOpen(true)} />
15
+ * <AnimatePresence>
16
+ * {open && <SearchView searchBarId="main" onClose={() => setOpen(false)} />}
17
+ * </AnimatePresence>
18
+ * ```
19
+ *
20
+ * Design notes:
21
+ * - The SearchView is intentionally separate from SearchAppBar to allow consumers
22
+ * to customize the results/suggestions content without coupling.
23
+ * - The `searchBarId` prop must match between SearchAppBar and SearchView to
24
+ * enable the shared element transition.
25
+ * - Focus is moved to the search input when the view opens.
26
+ * - Escape key closes the view and returns focus to the search bar.
27
+ */
28
+
29
+ import { AnimatePresence, m, useReducedMotion } from "motion/react";
30
+ import * as React from "react";
31
+ import { cn } from "../../lib/utils";
32
+ import {
33
+ APP_BAR_COLORS,
34
+ AppBarTokens,
35
+ SEARCH_VIEW_SPRING,
36
+ } from "./app-bar.tokens";
37
+ import type { SearchViewProps } from "./app-bar.types";
38
+
39
+ /** Built-in back arrow icon. */
40
+ function ArrowBackIcon() {
41
+ return (
42
+ <span
43
+ className="material-symbols-rounded text-[24px] leading-none select-none"
44
+ aria-hidden="true"
45
+ >
46
+ arrow_back
47
+ </span>
48
+ );
49
+ }
50
+
51
+ /** Built-in clear icon. */
52
+ function CloseIcon() {
53
+ return (
54
+ <span
55
+ className="material-symbols-rounded text-[24px] leading-none select-none"
56
+ aria-hidden="true"
57
+ >
58
+ close
59
+ </span>
60
+ );
61
+ }
62
+
63
+ /**
64
+ * MD3 Expressive Search View.
65
+ *
66
+ * Renders a full-screen search overlay with a shared element transition
67
+ * from the triggering `<SearchAppBar>` search bar.
68
+ *
69
+ * Mount/unmount this component via `<AnimatePresence>` in the consumer.
70
+ */
71
+ export function SearchView({
72
+ searchBarId = "search-bar",
73
+ value = "",
74
+ onChange,
75
+ onClose,
76
+ placeholder = "Search",
77
+ children,
78
+ leadingIcon,
79
+ trailingAction,
80
+ className,
81
+ }: SearchViewProps) {
82
+ const shouldReduceMotion = useReducedMotion();
83
+ const inputRef = React.useRef<HTMLInputElement>(null);
84
+
85
+ // Move focus to the search input when the view opens
86
+ React.useEffect(() => {
87
+ const timer = window.setTimeout(() => {
88
+ inputRef.current?.focus();
89
+ }, 50);
90
+ return () => window.clearTimeout(timer);
91
+ }, []);
92
+
93
+ // Close on Escape key
94
+ const handleKeyDown = (e: React.KeyboardEvent) => {
95
+ if (e.key === "Escape") {
96
+ onClose();
97
+ }
98
+ };
99
+
100
+ const viewTransition = shouldReduceMotion
101
+ ? { duration: 0 }
102
+ : SEARCH_VIEW_SPRING;
103
+
104
+ return (
105
+ <m.div
106
+ role="dialog"
107
+ aria-modal="true"
108
+ aria-label="Search"
109
+ className={cn(
110
+ "fixed inset-0 z-60 flex flex-col bg-m3-surface",
111
+ className,
112
+ )}
113
+ initial={shouldReduceMotion ? {} : { opacity: 0 }}
114
+ animate={{ opacity: 1 }}
115
+ exit={shouldReduceMotion ? {} : { opacity: 0 }}
116
+ transition={viewTransition}
117
+ onKeyDown={handleKeyDown}
118
+ >
119
+ {/* Search Bar — matches layoutId of SearchAppBar search bar */}
120
+ <m.div
121
+ layoutId={shouldReduceMotion ? undefined : searchBarId}
122
+ className="flex items-center gap-2 px-4 shrink-0 bg-m3-surface"
123
+ style={{
124
+ height: AppBarTokens.heights.small,
125
+ }}
126
+ >
127
+ {/* Leading: back button or custom icon */}
128
+ <button
129
+ type="button"
130
+ className={cn(
131
+ "shrink-0 flex items-center justify-center rounded-full",
132
+ "focus-visible:outline-none focus-visible:ring-2",
133
+ )}
134
+ style={{
135
+ width: AppBarTokens.iconButtonTouchTarget,
136
+ height: AppBarTokens.iconButtonTouchTarget,
137
+ color: APP_BAR_COLORS.navigationIcon,
138
+ }}
139
+ aria-label="Close search"
140
+ onClick={onClose}
141
+ >
142
+ {leadingIcon ?? <ArrowBackIcon />}
143
+ </button>
144
+
145
+ {/* Search input */}
146
+ <div
147
+ className="relative flex flex-1 items-center rounded-full px-4 gap-2"
148
+ style={{
149
+ height: 40,
150
+ }}
151
+ >
152
+ {/* Background Layer */}
153
+ <div className="absolute inset-0 rounded-full bg-m3-surface-container-high -z-10" />
154
+ <input
155
+ ref={inputRef}
156
+ aria-label={placeholder}
157
+ type="search"
158
+ value={value}
159
+ onChange={(e) => onChange?.(e.target.value)}
160
+ placeholder={placeholder}
161
+ className={cn(
162
+ "flex-1 bg-transparent border-none outline-none",
163
+ "text-[16px] leading-6",
164
+ "placeholder:text-m3-on-surface-variant",
165
+ )}
166
+ style={{ color: APP_BAR_COLORS.title }}
167
+ />
168
+
169
+ {/* Clear button — show when there's a value */}
170
+ {value && (
171
+ <button
172
+ type="button"
173
+ className={cn(
174
+ "shrink-0 flex items-center justify-center rounded-full",
175
+ "focus-visible:outline-none focus-visible:ring-2",
176
+ )}
177
+ style={{
178
+ width: 40,
179
+ height: 40,
180
+ color: APP_BAR_COLORS.searchBarContent,
181
+ }}
182
+ aria-label="Clear search"
183
+ onClick={() => onChange?.("")}
184
+ >
185
+ {trailingAction ?? <CloseIcon />}
186
+ </button>
187
+ )}
188
+ </div>
189
+ </m.div>
190
+
191
+ {/* Results / suggestions area */}
192
+ {children && (
193
+ <m.div
194
+ className="flex-1 overflow-y-auto"
195
+ initial={shouldReduceMotion ? {} : { opacity: 0, y: -8 }}
196
+ animate={{ opacity: 1, y: 0 }}
197
+ transition={{ delay: 0.1, duration: 0.15 }}
198
+ >
199
+ {children}
200
+ </m.div>
201
+ )}
202
+ </m.div>
203
+ );
204
+ }
205
+
206
+ /**
207
+ * Convenience wrapper that handles the AnimatePresence + open state.
208
+ *
209
+ * @example
210
+ * ```tsx
211
+ * <SearchBar
212
+ * isOpen={searchOpen}
213
+ * onClose={() => setSearchOpen(false)}
214
+ * searchBarId="main-search"
215
+ * >
216
+ * <SearchResultsList results={results} />
217
+ * </SearchBar>
218
+ * ```
219
+ */
220
+ export function SearchViewContainer({
221
+ isOpen,
222
+ ...props
223
+ }: SearchViewProps & { isOpen: boolean }) {
224
+ return (
225
+ <AnimatePresence>{isOpen && <SearchView {...props} />}</AnimatePresence>
226
+ );
227
+ }
@@ -0,0 +1,48 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { SmallAppBar } from "./small-app-bar";
4
+
5
+ describe("SmallAppBar", () => {
6
+ it("renders correctly with basic props", () => {
7
+ render(<SmallAppBar title="Inbox" />);
8
+
9
+ const header = screen.getByRole("banner");
10
+ expect(header).toBeInTheDocument();
11
+
12
+ const titleElement = screen.getByText("Inbox");
13
+ expect(titleElement).toBeInTheDocument();
14
+ });
15
+
16
+ it("renders subtitle, actions, and navigationIcon", () => {
17
+ render(
18
+ <SmallAppBar
19
+ title="Profile"
20
+ subtitle="@username"
21
+ navigationIcon={
22
+ <button type="button" data-testid="nav-icon">
23
+ Back
24
+ </button>
25
+ }
26
+ actions={
27
+ <button type="button" data-testid="actions-btn">
28
+ Search
29
+ </button>
30
+ }
31
+ />,
32
+ );
33
+
34
+ expect(screen.getByText("Profile")).toBeInTheDocument();
35
+ expect(screen.getByText("@username")).toBeInTheDocument();
36
+ expect(screen.getByTestId("nav-icon")).toBeInTheDocument();
37
+ expect(screen.getByTestId("actions-btn")).toBeInTheDocument();
38
+ });
39
+
40
+ it("applies custom classNames", () => {
41
+ render(<SmallAppBar title="Custom" className="test-small-bar" />);
42
+
43
+ // Container has the class
44
+ const _container = screen.getByText("Custom").closest("div");
45
+ // Actually banner is inside or might be the returned container
46
+ expect(document.querySelector(".test-small-bar")).toBeInTheDocument();
47
+ });
48
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * @file small-app-bar.tsx
3
+ * MD3 Expressive Small App Bar.
4
+ *
5
+ * Single-row layout: [navigationIcon][title + subtitle][actions]
6
+ * Height: 64px | Title: TitleLarge (22sp) | Subtitle: LabelMedium (12sp)
7
+ *
8
+ * Scroll behaviors:
9
+ * - pinned: changes background color surface → surface-container
10
+ * - enterAlways: slides up when scrolling down, slides down when scrolling up
11
+ *
12
+ * @see docs/m3/app-bars/AppBarSmallTokens.kt
13
+ */
14
+
15
+ import { AnimatePresence, m, useReducedMotion } from "motion/react";
16
+ import { cn } from "../../lib/utils";
17
+ import {
18
+ APP_BAR_COLOR_TRANSITION,
19
+ APP_BAR_COLORS,
20
+ APP_BAR_ENTER_ALWAYS_SPRING,
21
+ AppBarTokens,
22
+ appBarTypography,
23
+ } from "./app-bar.tokens";
24
+ import type { SmallAppBarProps } from "./app-bar.types";
25
+ import { useAppBarScroll } from "./hooks/use-app-bar-scroll";
26
+
27
+ interface SmallAppBarInnerProps
28
+ extends Pick<
29
+ SmallAppBarProps,
30
+ | "title"
31
+ | "subtitle"
32
+ | "titleAlignment"
33
+ | "navigationIcon"
34
+ | "actions"
35
+ | "colors"
36
+ | "className"
37
+ > {
38
+ currentBg: string;
39
+ cssTransition: string | undefined;
40
+ }
41
+
42
+ /** Inner content shared between pinned and enterAlways variants. */
43
+ function SmallAppBarInner({
44
+ title,
45
+ subtitle,
46
+ titleAlignment = "start",
47
+ navigationIcon,
48
+ actions,
49
+ colors,
50
+ className,
51
+ currentBg,
52
+ cssTransition,
53
+ }: SmallAppBarInnerProps) {
54
+ const isCentered = titleAlignment === "center";
55
+
56
+ return (
57
+ <m.header
58
+ role="banner"
59
+ className={cn("flex items-center px-1 h-full w-full", className)}
60
+ style={{ backgroundColor: currentBg, transition: cssTransition }}
61
+ >
62
+ {navigationIcon && (
63
+ <div
64
+ className="shrink-0 flex items-center justify-center"
65
+ style={{
66
+ width: AppBarTokens.iconButtonTouchTarget,
67
+ height: AppBarTokens.iconButtonTouchTarget,
68
+ }}
69
+ >
70
+ {navigationIcon}
71
+ </div>
72
+ )}
73
+
74
+ <div
75
+ className={cn(
76
+ "flex-1 flex flex-col justify-center min-w-0",
77
+ isCentered ? "items-center" : "items-start",
78
+ !navigationIcon && "pl-4",
79
+ )}
80
+ >
81
+ <span
82
+ className={cn(
83
+ appBarTypography.titleLarge,
84
+ "truncate w-full",
85
+ isCentered && "text-center",
86
+ )}
87
+ style={{ color: colors?.titleColor ?? APP_BAR_COLORS.title }}
88
+ >
89
+ {title}
90
+ </span>
91
+ {subtitle && (
92
+ <span
93
+ className={cn(
94
+ appBarTypography.labelMedium,
95
+ "truncate w-full",
96
+ isCentered && "text-center",
97
+ )}
98
+ style={{ color: colors?.subtitleColor ?? APP_BAR_COLORS.subtitle }}
99
+ >
100
+ {subtitle}
101
+ </span>
102
+ )}
103
+ </div>
104
+
105
+ {actions && <div className="flex items-center shrink-0">{actions}</div>}
106
+ </m.header>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * MD3 Expressive Small App Bar.
112
+ *
113
+ * @example
114
+ * ```tsx
115
+ * // Left-aligned (default)
116
+ * <SmallAppBar
117
+ * title="Inbox"
118
+ * navigationIcon={<IconButton aria-label="Go back"><Icon>arrow_back</Icon></IconButton>}
119
+ * actions={<IconButton aria-label="Search"><Icon>search</Icon></IconButton>}
120
+ * scrollBehavior="pinned"
121
+ * />
122
+ *
123
+ * // Center-aligned with subtitle
124
+ * <SmallAppBar
125
+ * title="Profile"
126
+ * subtitle="@username"
127
+ * titleAlignment="center"
128
+ * scrollBehavior="enterAlways"
129
+ * />
130
+ * ```
131
+ */
132
+ export function SmallAppBar({
133
+ title,
134
+ subtitle,
135
+ titleAlignment = "start",
136
+ navigationIcon,
137
+ actions,
138
+ colors,
139
+ scrollBehavior = "pinned",
140
+ scrollElement,
141
+ className,
142
+ }: SmallAppBarProps) {
143
+ const shouldReduceMotion = useReducedMotion();
144
+
145
+ const { isScrolled, isHidden } = useAppBarScroll({
146
+ scrollElement,
147
+ behavior:
148
+ scrollBehavior === "exitUntilCollapsed" ? "pinned" : scrollBehavior,
149
+ });
150
+
151
+ const containerBg = colors?.containerColor ?? APP_BAR_COLORS.container;
152
+ const scrolledBg =
153
+ colors?.scrolledContainerColor ?? APP_BAR_COLORS.scrolledContainer;
154
+ const currentBg = isScrolled ? scrolledBg : containerBg;
155
+
156
+ const cssTransition = shouldReduceMotion
157
+ ? undefined
158
+ : `background-color ${APP_BAR_COLOR_TRANSITION.duration}s cubic-bezier(${APP_BAR_COLOR_TRANSITION.ease.join(",")})`;
159
+
160
+ const innerProps: SmallAppBarInnerProps = {
161
+ title,
162
+ subtitle,
163
+ titleAlignment,
164
+ navigationIcon,
165
+ actions,
166
+ colors,
167
+ currentBg,
168
+ cssTransition,
169
+ };
170
+
171
+ const barHeight = AppBarTokens.heights.small;
172
+
173
+ if (scrollBehavior !== "enterAlways") {
174
+ return (
175
+ <div
176
+ className={cn("fixed top-0 inset-x-0 z-50", className)}
177
+ style={{ height: barHeight }}
178
+ >
179
+ <SmallAppBarInner {...innerProps} />
180
+ </div>
181
+ );
182
+ }
183
+
184
+ return (
185
+ <AnimatePresence initial={false}>
186
+ {!isHidden && (
187
+ <m.div
188
+ key="small-app-bar"
189
+ initial={{ y: "-100%" }}
190
+ animate={{ y: 0 }}
191
+ exit={{ y: "-100%" }}
192
+ transition={
193
+ shouldReduceMotion ? { duration: 0 } : APP_BAR_ENTER_ALWAYS_SPRING
194
+ }
195
+ className={cn("fixed top-0 inset-x-0 z-50", className)}
196
+ style={{ height: barHeight }}
197
+ >
198
+ <SmallAppBarInner {...innerProps} />
199
+ </m.div>
200
+ )}
201
+ </AnimatePresence>
202
+ );
203
+ }