@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,202 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { useSearchKeyboard } from "./use-search-keyboard";
4
+
5
+ describe("useSearchKeyboard", () => {
6
+ const defaultProps = {
7
+ active: true,
8
+ onActiveChange: vi.fn(),
9
+ onSearch: vi.fn(),
10
+ query: "test",
11
+ itemCount: 3,
12
+ };
13
+
14
+ it("initializes with activeIndex -1", () => {
15
+ const { result } = renderHook(() => useSearchKeyboard({ ...defaultProps }));
16
+ expect(result.current.activeIndex).toBe(-1);
17
+ });
18
+
19
+ it("does not handle events if not active", () => {
20
+ const { result } = renderHook(() =>
21
+ useSearchKeyboard({ ...defaultProps, active: false }),
22
+ );
23
+ const e = {
24
+ key: "ArrowDown",
25
+ preventDefault: vi.fn(),
26
+ } as unknown as React.KeyboardEvent;
27
+ act(() => {
28
+ result.current.handleKeyDown(e);
29
+ });
30
+ expect(e.preventDefault).not.toHaveBeenCalled();
31
+ expect(result.current.activeIndex).toBe(-1);
32
+ });
33
+
34
+ it("moves focus down with ArrowDown and clamps at itemCount - 1", () => {
35
+ const { result } = renderHook(() => useSearchKeyboard({ ...defaultProps }));
36
+ const e = {
37
+ key: "ArrowDown",
38
+ preventDefault: vi.fn(),
39
+ } as unknown as React.KeyboardEvent;
40
+
41
+ // Move from -1 to 0
42
+ act(() => result.current.handleKeyDown(e));
43
+ expect(result.current.activeIndex).toBe(0);
44
+
45
+ // Move to 1
46
+ act(() => result.current.handleKeyDown(e));
47
+ expect(result.current.activeIndex).toBe(1);
48
+
49
+ // Move to 2
50
+ act(() => result.current.handleKeyDown(e));
51
+ expect(result.current.activeIndex).toBe(2);
52
+
53
+ // Clamp at 2
54
+ act(() => result.current.handleKeyDown(e));
55
+ expect(result.current.activeIndex).toBe(2);
56
+ expect(e.preventDefault).toHaveBeenCalledTimes(4);
57
+ });
58
+
59
+ it("moves focus up with ArrowUp and clamps at -1", () => {
60
+ const { result } = renderHook(() => useSearchKeyboard({ ...defaultProps }));
61
+ const eDown = {
62
+ key: "ArrowDown",
63
+ preventDefault: vi.fn(),
64
+ } as unknown as React.KeyboardEvent;
65
+ const eUp = {
66
+ key: "ArrowUp",
67
+ preventDefault: vi.fn(),
68
+ } as unknown as React.KeyboardEvent;
69
+
70
+ // Move down to index 1
71
+ act(() => {
72
+ result.current.handleKeyDown(eDown);
73
+ result.current.handleKeyDown(eDown);
74
+ });
75
+ expect(result.current.activeIndex).toBe(1);
76
+
77
+ // Move up to 0
78
+ act(() => result.current.handleKeyDown(eUp));
79
+ expect(result.current.activeIndex).toBe(0);
80
+
81
+ // Move up to -1
82
+ act(() => result.current.handleKeyDown(eUp));
83
+ expect(result.current.activeIndex).toBe(-1);
84
+
85
+ // Clamp at -1
86
+ act(() => result.current.handleKeyDown(eUp));
87
+ expect(result.current.activeIndex).toBe(-1);
88
+ });
89
+
90
+ it("calls onSearch with query when Enter is pressed without an active suggestion", () => {
91
+ const onSearch = vi.fn();
92
+ const { result } = renderHook(() =>
93
+ useSearchKeyboard({ ...defaultProps, onSearch }),
94
+ );
95
+ const e = {
96
+ key: "Enter",
97
+ preventDefault: vi.fn(),
98
+ } as unknown as React.KeyboardEvent;
99
+
100
+ act(() => result.current.handleKeyDown(e));
101
+ expect(e.preventDefault).toHaveBeenCalled();
102
+ expect(onSearch).toHaveBeenCalledWith("test");
103
+ });
104
+
105
+ it("calls onSelectSuggestion when Enter is pressed and an item is active", () => {
106
+ const onSearch = vi.fn();
107
+ const onSelectSuggestion = vi.fn();
108
+ const { result } = renderHook(() =>
109
+ useSearchKeyboard({ ...defaultProps, onSearch, onSelectSuggestion }),
110
+ );
111
+ const eDown = {
112
+ key: "ArrowDown",
113
+ preventDefault: vi.fn(),
114
+ } as unknown as React.KeyboardEvent;
115
+ const eEnter = {
116
+ key: "Enter",
117
+ preventDefault: vi.fn(),
118
+ } as unknown as React.KeyboardEvent;
119
+
120
+ // Move to index 0
121
+ act(() => result.current.handleKeyDown(eDown));
122
+ expect(result.current.activeIndex).toBe(0);
123
+
124
+ // Press Enter
125
+ act(() => result.current.handleKeyDown(eEnter));
126
+ expect(onSelectSuggestion).toHaveBeenCalledWith(0);
127
+ expect(onSearch).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("calls onSearch instead if onSelectSuggestion is missing and Enter is pressed on active item", () => {
131
+ const onSearch = vi.fn();
132
+ const { result } = renderHook(
133
+ () => useSearchKeyboard({ ...defaultProps, onSearch }), // NO onSelectSuggestion
134
+ );
135
+ const eDown = {
136
+ key: "ArrowDown",
137
+ preventDefault: vi.fn(),
138
+ } as unknown as React.KeyboardEvent;
139
+ const eEnter = {
140
+ key: "Enter",
141
+ preventDefault: vi.fn(),
142
+ } as unknown as React.KeyboardEvent;
143
+
144
+ act(() => result.current.handleKeyDown(eDown));
145
+ act(() => result.current.handleKeyDown(eEnter));
146
+
147
+ expect(onSearch).toHaveBeenCalledWith("test");
148
+ });
149
+
150
+ it("calls onActiveChange(false) when Escape is pressed", () => {
151
+ const onActiveChange = vi.fn();
152
+ const { result } = renderHook(() =>
153
+ useSearchKeyboard({ ...defaultProps, onActiveChange }),
154
+ );
155
+ const e = {
156
+ key: "Escape",
157
+ preventDefault: vi.fn(),
158
+ } as unknown as React.KeyboardEvent;
159
+
160
+ act(() => result.current.handleKeyDown(e));
161
+ expect(e.preventDefault).toHaveBeenCalled();
162
+ expect(onActiveChange).toHaveBeenCalledWith(false);
163
+ });
164
+
165
+ it("resets activeIndex when query changes", () => {
166
+ const { result, rerender } = renderHook(
167
+ (props) => useSearchKeyboard(props),
168
+ { initialProps: { ...defaultProps, query: "a" } },
169
+ );
170
+ const e = {
171
+ key: "ArrowDown",
172
+ preventDefault: vi.fn(),
173
+ } as unknown as React.KeyboardEvent;
174
+
175
+ // Move index
176
+ act(() => result.current.handleKeyDown(e));
177
+ expect(result.current.activeIndex).toBe(0);
178
+
179
+ // Change query -> should reset to -1
180
+ rerender({ ...defaultProps, query: "ab" });
181
+ expect(result.current.activeIndex).toBe(-1);
182
+ });
183
+
184
+ it("resets activeIndex when active state changes to false", () => {
185
+ const { result, rerender } = renderHook(
186
+ (props) => useSearchKeyboard(props),
187
+ { initialProps: { ...defaultProps, active: true } },
188
+ );
189
+ const e = {
190
+ key: "ArrowDown",
191
+ preventDefault: vi.fn(),
192
+ } as unknown as React.KeyboardEvent;
193
+
194
+ // Move index
195
+ act(() => result.current.handleKeyDown(e));
196
+ expect(result.current.activeIndex).toBe(0);
197
+
198
+ // Deactivate -> should reset to -1
199
+ rerender({ ...defaultProps, active: false });
200
+ expect(result.current.activeIndex).toBe(-1);
201
+ });
202
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @file use-search-keyboard.ts
3
+ * Keyboard navigation hook for the MD3 Search component.
4
+ *
5
+ * Handles:
6
+ * - ArrowDown / ArrowUp → navigate through suggestions (role="option")
7
+ * - Enter → submit search or select active suggestion
8
+ * - Escape → close the SearchView
9
+ */
10
+
11
+ import * as React from "react";
12
+ import type { UseSearchKeyboardReturn } from "../search.types";
13
+
14
+ interface UseSearchKeyboardOptions {
15
+ /** Whether the SearchView is currently open. */
16
+ active: boolean;
17
+ /** Callback to close the SearchView. */
18
+ onActiveChange: (active: boolean) => void;
19
+ /** Callback for search submission. */
20
+ onSearch: (query: string) => void;
21
+ /** Current search query. */
22
+ query: string;
23
+ /** Total number of suggestion items in the listbox. */
24
+ itemCount: number;
25
+ /** Called when user selects a specific suggestion by index. */
26
+ onSelectSuggestion?: (index: number) => void;
27
+ }
28
+
29
+ /**
30
+ * Manages keyboard navigation for the Search component.
31
+ *
32
+ * Complies with WAI-ARIA Combobox pattern:
33
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
34
+ */
35
+ export function useSearchKeyboard({
36
+ active,
37
+ onActiveChange,
38
+ onSearch,
39
+ query,
40
+ itemCount,
41
+ onSelectSuggestion,
42
+ }: UseSearchKeyboardOptions): UseSearchKeyboardReturn {
43
+ const [activeIndex, setActiveIndex] = React.useState(-1);
44
+
45
+ // Reset active index when SearchView closes or query changes.
46
+ // Done during render phase to prevent double-renders on every keystroke,
47
+ // which would otherwise cause severe layout thrashing.
48
+ const resetKeyRef = React.useRef(`${active}:${query}`);
49
+ const currentKey = `${active}:${query}`;
50
+ if (resetKeyRef.current !== currentKey) {
51
+ resetKeyRef.current = currentKey;
52
+ setActiveIndex(-1);
53
+ }
54
+
55
+ const handleKeyDown = React.useCallback(
56
+ (e: React.KeyboardEvent) => {
57
+ if (!active) return;
58
+
59
+ switch (e.key) {
60
+ case "ArrowDown": {
61
+ e.preventDefault();
62
+ setActiveIndex((i) => (i < itemCount - 1 ? i + 1 : i));
63
+ break;
64
+ }
65
+ case "ArrowUp": {
66
+ e.preventDefault();
67
+ setActiveIndex((i) => (i > -1 ? i - 1 : -1));
68
+ break;
69
+ }
70
+ case "Enter": {
71
+ e.preventDefault();
72
+ if (activeIndex >= 0 && onSelectSuggestion) {
73
+ onSelectSuggestion(activeIndex);
74
+ } else {
75
+ onSearch(query);
76
+ }
77
+ break;
78
+ }
79
+ case "Escape": {
80
+ e.preventDefault();
81
+ onActiveChange(false);
82
+ break;
83
+ }
84
+ default:
85
+ break;
86
+ }
87
+ },
88
+ [
89
+ active,
90
+ activeIndex,
91
+ itemCount,
92
+ onActiveChange,
93
+ onSearch,
94
+ onSelectSuggestion,
95
+ query,
96
+ ],
97
+ );
98
+
99
+ const resetActiveIndex = React.useCallback(() => {
100
+ setActiveIndex(-1);
101
+ }, []);
102
+
103
+ return { activeIndex, handleKeyDown, resetActiveIndex };
104
+ }
@@ -0,0 +1,96 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import type * as React from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { useSearchViewFocus } from "./use-search-view-focus";
5
+
6
+ describe("useSearchViewFocus", () => {
7
+ const originalRequestAnimationFrame = globalThis.requestAnimationFrame;
8
+ const originalCancelAnimationFrame = globalThis.cancelAnimationFrame;
9
+
10
+ afterEach(() => {
11
+ globalThis.requestAnimationFrame = originalRequestAnimationFrame;
12
+ globalThis.cancelAnimationFrame = originalCancelAnimationFrame;
13
+ vi.restoreAllMocks();
14
+ });
15
+
16
+ it("does not focus when active is false", () => {
17
+ const focusMock = vi.fn();
18
+ const ref = {
19
+ current: { focus: focusMock },
20
+ } as unknown as React.RefObject<HTMLInputElement>;
21
+ const rAFMock = vi.fn();
22
+ globalThis.requestAnimationFrame = rAFMock;
23
+
24
+ renderHook(() => useSearchViewFocus(ref, false));
25
+
26
+ expect(rAFMock).not.toHaveBeenCalled();
27
+ expect(focusMock).not.toHaveBeenCalled();
28
+ });
29
+
30
+ it("runs inner focus call on double requestAnimationFrame when active becomes true", () => {
31
+ const focusMock = vi.fn();
32
+ const ref = {
33
+ current: { focus: focusMock },
34
+ } as unknown as React.RefObject<HTMLInputElement>;
35
+
36
+ const rafCallbacks: FrameRequestCallback[] = [];
37
+ globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
38
+ rafCallbacks.push(cb);
39
+ return rafCallbacks.length;
40
+ });
41
+ globalThis.cancelAnimationFrame = vi.fn();
42
+
43
+ const { rerender } = renderHook(
44
+ ({ active }) => useSearchViewFocus(ref, active),
45
+ {
46
+ initialProps: { active: false },
47
+ },
48
+ );
49
+
50
+ expect(globalThis.requestAnimationFrame).not.toHaveBeenCalled();
51
+
52
+ // Activate
53
+ rerender({ active: true });
54
+
55
+ // The hook should schedule the first rAF
56
+ expect(rafCallbacks.length).toBe(1);
57
+
58
+ // Fire first rAF
59
+ rafCallbacks[0](0);
60
+
61
+ // The hook should have scheduled the second rAF
62
+ expect(rafCallbacks.length).toBe(2);
63
+ expect(focusMock).not.toHaveBeenCalled();
64
+
65
+ // Fire second rAF
66
+ rafCallbacks[1](0);
67
+
68
+ // Now it should focus
69
+ expect(focusMock).toHaveBeenCalledTimes(1);
70
+ });
71
+
72
+ it("cancels animation frames if unmounted during animation", () => {
73
+ const focusMock = vi.fn();
74
+ const ref = {
75
+ current: { focus: focusMock },
76
+ } as unknown as React.RefObject<HTMLInputElement>;
77
+
78
+ const rafCallbacks: FrameRequestCallback[] = [];
79
+ globalThis.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => {
80
+ rafCallbacks.push(cb);
81
+ return rafCallbacks.length;
82
+ });
83
+ globalThis.cancelAnimationFrame = vi.fn();
84
+
85
+ const { unmount } = renderHook(() => useSearchViewFocus(ref, true));
86
+
87
+ expect(rafCallbacks.length).toBe(1);
88
+
89
+ // Unmount before first rAF fires
90
+ unmount();
91
+
92
+ // Should have called cancelAnimationFrame with id 1
93
+ expect(globalThis.cancelAnimationFrame).toHaveBeenCalledWith(1);
94
+ expect(focusMock).not.toHaveBeenCalled();
95
+ });
96
+ });
@@ -0,0 +1,24 @@
1
+ import * as React from "react";
2
+
3
+ /**
4
+ * Focuses `inputRef` when `active` becomes true, using a double-rAF
5
+ * to wait for Framer Motion's layout animation to finish painting.
6
+ */
7
+ export function useSearchViewFocus(
8
+ inputRef: React.RefObject<HTMLInputElement | null>,
9
+ active: boolean,
10
+ ): void {
11
+ React.useEffect(() => {
12
+ if (!active) return;
13
+ let raf2: number;
14
+ const raf1 = requestAnimationFrame(() => {
15
+ raf2 = requestAnimationFrame(() => {
16
+ inputRef.current?.focus();
17
+ });
18
+ });
19
+ return () => {
20
+ cancelAnimationFrame(raf1);
21
+ if (raf2) cancelAnimationFrame(raf2);
22
+ };
23
+ }, [active, inputRef]);
24
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @file index.ts
3
+ * MD3 Expressive Search — Public API exports.
4
+ *
5
+ * Components:
6
+ * - Search: Orchestrator (SearchBar + SearchView)
7
+ * - SearchBar: Collapsed pill state (standalone use)
8
+ * - SearchViewDocked: Expanded docked popup (standalone use)
9
+ * - SearchViewFullScreen: Expanded full-screen overlay (standalone use)
10
+ *
11
+ * Hook:
12
+ * - useSearchKeyboard: WAI-ARIA combobox keyboard navigation
13
+ *
14
+ * Tokens:
15
+ * - SearchTokens: Dimensional tokens (heights, sizes)
16
+ * - SEARCH_COLORS: CSS custom property color references
17
+ * - SEARCH_TYPOGRAPHY: Typography class strings
18
+ * - Animation constants
19
+ */
20
+
21
+ export { useSearchKeyboard } from "./hooks/use-search-keyboard";
22
+ // ─── Components ───────────────────────────────────────────────────────────────
23
+ export { Search } from "./search";
24
+ // ─── Tokens ───────────────────────────────────────────────────────────────────
25
+ export {
26
+ SEARCH_BAR_EXIT_SPRING,
27
+ SEARCH_BAR_EXPAND_SPRING,
28
+ SEARCH_COLORS,
29
+ SEARCH_DOCKED_REVEAL_SPRING,
30
+ SEARCH_FULLSCREEN_SPRING,
31
+ SEARCH_TYPOGRAPHY,
32
+ SearchTokens,
33
+ } from "./search.tokens";
34
+ // ─── Types ────────────────────────────────────────────────────────────────────
35
+ export type {
36
+ SearchProps,
37
+ SearchStyleType,
38
+ SearchVariant,
39
+ } from "./search.types";
40
+ export { SearchBar } from "./search-bar";
41
+ // ─── Hook ─────────────────────────────────────────────────────────────────────
42
+ export { useSearch } from "./search-context";
43
+ export { SearchViewDocked } from "./search-view-docked";
44
+ export { SearchViewFullScreen } from "./search-view-fullscreen";
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @file search-bar.tsx
3
+ * MD3 Expressive SearchBar — Collapsed state.
4
+ *
5
+ * Renders a pill-shaped search bar (CornerFull, h-56px).
6
+ * When focused/clicked → calls onActiveChange(true) to open SearchView.
7
+ *
8
+ * Option B (MD3 morphing): SearchBar is wrapped in its own AnimatePresence
9
+ * with mode="popLayout". When SearchView opens, SearchBar plays an exit
10
+ * animation (opacity → 0, scale → 0.95) before unmounting, releasing the
11
+ * shared layoutId so SearchView can claim it and morph from the same origin.
12
+ *
13
+ * Role: combobox (WAI-ARIA Search Combobox pattern).
14
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
15
+ */
16
+
17
+ import { AnimatePresence, m, useReducedMotion } from "motion/react";
18
+ import * as React from "react";
19
+ import { cn } from "../../lib/utils";
20
+ import { AnimatedPlaceholder } from "./animated-placeholder";
21
+ import {
22
+ SEARCH_BAR_EXIT_SPRING,
23
+ SEARCH_BAR_EXPAND_SPRING,
24
+ SEARCH_COLORS,
25
+ SearchTokens,
26
+ } from "./search.tokens";
27
+ import type { SearchInternalProps, SearchProps } from "./search.types";
28
+
29
+ /** Default search icon (Material Symbols). */
30
+ function DefaultSearchIcon() {
31
+ return (
32
+ <span
33
+ className="material-symbols-rounded select-none leading-none"
34
+ style={{ fontSize: SearchTokens.iconSize }}
35
+ aria-hidden="true"
36
+ >
37
+ search
38
+ </span>
39
+ );
40
+ }
41
+
42
+ type SearchBarProps = Pick<
43
+ SearchProps,
44
+ | "query"
45
+ | "onQueryChange"
46
+ | "onSearch"
47
+ | "active"
48
+ | "onActiveChange"
49
+ | "leadingIcon"
50
+ | "trailingIcon"
51
+ | "placeholder"
52
+ | "textAlign"
53
+ | "className"
54
+ | "aria-label"
55
+ > &
56
+ SearchInternalProps & {
57
+ /** KeyDown handler from useSearchKeyboard. */
58
+ onKeyDown: (e: React.KeyboardEvent) => void;
59
+ /** Currently highlighted suggestion index (-1 = none). */
60
+ activeIndex: number;
61
+ };
62
+
63
+ /**
64
+ * SearchBar — collapsed state of the MD3 Search component.
65
+ *
66
+ * Uses Framer Motion `layout` + shared `layoutId` to morph into
67
+ * SearchView when active. Wrapped in AnimatePresence with mode="popLayout"
68
+ * so it exits (fades/scales out) before SearchView claims the layoutId.
69
+ */
70
+ export function SearchBar({
71
+ query,
72
+ onQueryChange,
73
+ onSearch,
74
+ active,
75
+ onActiveChange,
76
+ leadingIcon,
77
+ trailingIcon,
78
+ placeholder = "Search",
79
+ textAlign = "left",
80
+ className,
81
+ "aria-label": ariaLabel = "Search",
82
+ searchId,
83
+ listboxId,
84
+ onKeyDown,
85
+ activeIndex,
86
+ }: SearchBarProps) {
87
+ const shouldReduceMotion = useReducedMotion();
88
+ const inputRef = React.useRef<HTMLInputElement>(null);
89
+
90
+ const prevActiveRef = React.useRef(active);
91
+
92
+ const isRestoringFocusRef = React.useRef(false);
93
+
94
+ // When SearchView opens, focus moves to SearchView's input.
95
+ // When SearchView closes (true → false), restore focus here.
96
+ React.useEffect(() => {
97
+ let rafId: number;
98
+ if (prevActiveRef.current === true && active === false) {
99
+ isRestoringFocusRef.current = true;
100
+ inputRef.current?.focus();
101
+ // Reset after a tick to allow the focus event to fire
102
+ rafId = requestAnimationFrame(() => {
103
+ isRestoringFocusRef.current = false;
104
+ });
105
+ }
106
+ prevActiveRef.current = active;
107
+ return () => {
108
+ if (rafId) cancelAnimationFrame(rafId);
109
+ };
110
+ }, [active]);
111
+
112
+ const handleFocus = () => {
113
+ if (!active && !isRestoringFocusRef.current) {
114
+ onActiveChange(true);
115
+ }
116
+ };
117
+
118
+ const handleFormSubmit = (e: React.FormEvent) => {
119
+ e.preventDefault();
120
+ onSearch(query);
121
+ };
122
+
123
+ // aria-activedescendant points to the highlighted suggestion
124
+ const activeDescendant =
125
+ activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
126
+
127
+ return (
128
+ /*
129
+ * AnimatePresence mode="popLayout":
130
+ * When SearchView opens (active=true), SearchBar plays its exit animation
131
+ * first, then unmounts — releasing the shared layoutId for SearchView to
132
+ * claim and morph from the pill shape.
133
+ */
134
+ <AnimatePresence mode="popLayout">
135
+ {!active && (
136
+ <m.div
137
+ key={searchId}
138
+ layout={!shouldReduceMotion}
139
+ layoutId={shouldReduceMotion ? undefined : searchId}
140
+ transition={shouldReduceMotion ? undefined : SEARCH_BAR_EXPAND_SPRING}
141
+ className={cn("relative", className)}
142
+ style={{ height: SearchTokens.heights.bar }}
143
+ initial={shouldReduceMotion ? false : { opacity: 0, scale: 0.95 }}
144
+ animate={{ opacity: 1, scale: 1 }}
145
+ exit={
146
+ shouldReduceMotion
147
+ ? {}
148
+ : { opacity: 0, scale: 0.95, transition: SEARCH_BAR_EXIT_SPRING }
149
+ }
150
+ >
151
+ {/* Background layer — rounded-full per SearchBarTokens.ContainerShape */}
152
+ <div
153
+ className="absolute inset-0 rounded-full"
154
+ style={{ backgroundColor: SEARCH_COLORS.container }}
155
+ aria-hidden="true"
156
+ />
157
+
158
+ {/* <search> is the semantic element for role="search" */}
159
+ <search
160
+ aria-label={ariaLabel}
161
+ className="relative flex h-full items-center gap-2 rounded-full px-4"
162
+ >
163
+ <form className="contents" onSubmit={handleFormSubmit}>
164
+ <span
165
+ className="flex shrink-0 items-center justify-center"
166
+ style={{ color: SEARCH_COLORS.leadingIcon }}
167
+ aria-hidden="true"
168
+ >
169
+ {leadingIcon ?? <DefaultSearchIcon />}
170
+ </span>
171
+
172
+ {/* AnimatedPlaceholder wraps the input to provide a smooth
173
+ translateX animation from textAlign → left on focus. */}
174
+ <AnimatedPlaceholder
175
+ text={placeholder}
176
+ textAlign={textAlign}
177
+ visible={!query}
178
+ focused={active}
179
+ >
180
+ {/* role="combobox" per WAI-ARIA combobox pattern */}
181
+ <input
182
+ ref={inputRef}
183
+ id={searchId}
184
+ type="search"
185
+ role="combobox"
186
+ aria-expanded={active}
187
+ aria-controls={listboxId}
188
+ aria-autocomplete="list"
189
+ aria-activedescendant={activeDescendant}
190
+ aria-label={placeholder}
191
+ value={query}
192
+ placeholder={placeholder}
193
+ className={cn(
194
+ "w-full bg-transparent border-none outline-none",
195
+ "text-[16px] leading-6 font-normal tracking-[0.5px]",
196
+ "placeholder:text-transparent",
197
+ )}
198
+ style={{ color: SEARCH_COLORS.inputText }}
199
+ onFocus={handleFocus}
200
+ onChange={(e) => onQueryChange(e.target.value)}
201
+ onKeyDown={onKeyDown}
202
+ />
203
+ </AnimatedPlaceholder>
204
+
205
+ {trailingIcon && (
206
+ <span
207
+ className="flex shrink-0 items-center justify-center"
208
+ style={{ color: SEARCH_COLORS.trailingIcon }}
209
+ aria-hidden="true"
210
+ >
211
+ {trailingIcon}
212
+ </span>
213
+ )}
214
+ </form>
215
+ </search>
216
+ </m.div>
217
+ )}
218
+ </AnimatePresence>
219
+ );
220
+ }