@bug-on/md3-react 2.0.2 → 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 (296) hide show
  1. package/.turbo/turbo-build.log +33 -0
  2. package/CHANGELOG.md +55 -0
  3. package/dist/index.css +23 -0
  4. package/dist/index.css.d.ts +2 -0
  5. package/dist/index.d.mts +6127 -0
  6. package/dist/index.d.ts +6127 -69
  7. package/dist/index.js +2536 -665
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2443 -603
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/material-symbols-cdn.css.d.ts +2 -0
  12. package/dist/material-symbols-self-hosted.css.d.ts +2 -0
  13. package/dist/typography.css.d.ts +2 -0
  14. package/package.json +23 -19
  15. package/scripts/copy-assets.js +82 -0
  16. package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
  17. package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  18. package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  19. package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  20. package/src/assets/loading-indicator.svg +19 -0
  21. package/src/assets/material-symbols-cdn.css +65 -0
  22. package/src/assets/material-symbols-self-hosted.css +90 -0
  23. package/src/css.d.ts +20 -0
  24. package/{dist/hooks/index.d.ts → src/hooks/index.ts} +1 -0
  25. package/src/hooks/useClickOutside.ts +37 -0
  26. package/src/hooks/useMediaQuery.ts +28 -0
  27. package/src/hooks/useRipple.ts +88 -0
  28. package/src/index.css +23 -0
  29. package/src/index.ts +349 -0
  30. package/src/lib/material-symbols-preconnect.tsx +82 -0
  31. package/src/lib/theme-utils.ts +180 -0
  32. package/src/lib/utils.ts +6 -0
  33. package/src/test/button.test.tsx +59 -0
  34. package/src/test/icon.test.tsx +91 -0
  35. package/src/test/loading-indicator.test.tsx +128 -0
  36. package/src/test/progress-indicator.test.tsx +306 -0
  37. package/src/test/setup.ts +80 -0
  38. package/src/test/typography.test.tsx +206 -0
  39. package/src/types/index.ts +7 -0
  40. package/src/types/md3.ts +31 -0
  41. package/src/ui/Text.tsx +60 -0
  42. package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
  43. package/src/ui/app-bar/app-bar-column.tsx +99 -0
  44. package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
  45. package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
  46. package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
  47. package/src/ui/app-bar/app-bar-row.tsx +104 -0
  48. package/src/ui/app-bar/app-bar.test.tsx +87 -0
  49. package/src/ui/app-bar/app-bar.tokens.ts +223 -0
  50. package/src/ui/app-bar/app-bar.types.ts +441 -0
  51. package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
  52. package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
  53. package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
  54. package/src/ui/app-bar/docked-toolbar.tsx +54 -0
  55. package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
  56. package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
  57. package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
  58. package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
  59. package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
  60. package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
  61. package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
  62. package/src/ui/app-bar/search-app-bar.tsx +176 -0
  63. package/src/ui/app-bar/search-view.tsx +227 -0
  64. package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
  65. package/src/ui/app-bar/small-app-bar.tsx +203 -0
  66. package/src/ui/badge.test.tsx +345 -0
  67. package/src/ui/badge.tsx +282 -0
  68. package/src/ui/button-group.test.tsx +71 -0
  69. package/src/ui/button-group.tsx +350 -0
  70. package/src/ui/button.test.tsx +297 -0
  71. package/src/ui/button.tsx +669 -0
  72. package/src/ui/card.test.tsx +187 -0
  73. package/src/ui/card.tsx +259 -0
  74. package/src/ui/checkbox.test.tsx +423 -0
  75. package/src/ui/checkbox.tsx +525 -0
  76. package/src/ui/chip.test.tsx +292 -0
  77. package/src/ui/chip.tsx +548 -0
  78. package/src/ui/code-block.tsx +219 -0
  79. package/src/ui/dialog.test.tsx +300 -0
  80. package/src/ui/dialog.tsx +384 -0
  81. package/src/ui/divider.test.tsx +314 -0
  82. package/src/ui/divider.tsx +412 -0
  83. package/src/ui/drawer.tsx +240 -0
  84. package/src/ui/fab-menu.test.tsx +494 -0
  85. package/src/ui/fab-menu.tsx +739 -0
  86. package/src/ui/fab.test.tsx +232 -0
  87. package/src/ui/fab.tsx +505 -0
  88. package/src/ui/icon-button.test.tsx +515 -0
  89. package/src/ui/icon-button.tsx +525 -0
  90. package/src/ui/icon.test.tsx +197 -0
  91. package/src/ui/icon.tsx +179 -0
  92. package/src/ui/loading-indicator.test.tsx +73 -0
  93. package/src/ui/loading-indicator.tsx +312 -0
  94. package/src/ui/menu/context-menu.tsx +275 -0
  95. package/src/ui/menu/index.ts +77 -0
  96. package/src/ui/menu/menu-animations.ts +102 -0
  97. package/src/ui/menu/menu-context.tsx +99 -0
  98. package/src/ui/menu/menu-divider.tsx +47 -0
  99. package/src/ui/menu/menu-group.tsx +200 -0
  100. package/src/ui/menu/menu-item.tsx +294 -0
  101. package/src/ui/menu/menu-tokens.ts +208 -0
  102. package/src/ui/menu/menu-types.ts +313 -0
  103. package/src/ui/menu/menu.test.tsx +624 -0
  104. package/src/ui/menu/menu.tsx +289 -0
  105. package/src/ui/menu/sub-menu.tsx +223 -0
  106. package/src/ui/menu/vertical-menu.tsx +382 -0
  107. package/src/ui/navigation-rail.test.tsx +404 -0
  108. package/src/ui/navigation-rail.tsx +604 -0
  109. package/src/ui/progress-indicator/circular.tsx +248 -0
  110. package/src/ui/progress-indicator/hooks.ts +51 -0
  111. package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
  112. package/src/ui/progress-indicator/linear-flat.tsx +83 -0
  113. package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
  114. package/src/ui/progress-indicator/linear.tsx +143 -0
  115. package/src/ui/progress-indicator/types.ts +158 -0
  116. package/src/ui/progress-indicator/utils.ts +73 -0
  117. package/src/ui/radio-button.test.tsx +407 -0
  118. package/src/ui/radio-button.tsx +551 -0
  119. package/src/ui/ripple.test.tsx +72 -0
  120. package/src/ui/ripple.tsx +234 -0
  121. package/src/ui/scroll-area.test.tsx +58 -0
  122. package/src/ui/scroll-area.tsx +139 -0
  123. package/src/ui/search/animated-placeholder.tsx +145 -0
  124. package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
  125. package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
  126. package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
  127. package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
  128. package/src/ui/search/index.ts +44 -0
  129. package/src/ui/search/search-bar.tsx +220 -0
  130. package/src/ui/search/search-context.tsx +42 -0
  131. package/src/ui/search/search-view-docked.tsx +194 -0
  132. package/src/ui/search/search-view-fullscreen.tsx +247 -0
  133. package/src/ui/search/search.test.tsx +233 -0
  134. package/src/ui/search/search.tokens.ts +134 -0
  135. package/src/ui/search/search.tsx +131 -0
  136. package/src/ui/search/search.types.ts +154 -0
  137. package/src/ui/search/trailing-action.tsx +49 -0
  138. package/src/ui/shared/constants.ts +122 -0
  139. package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
  140. package/src/ui/slider/hooks/useSliderMath.ts +195 -0
  141. package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
  142. package/src/ui/slider/range-slider.tsx +561 -0
  143. package/src/ui/slider/slider-thumb.tsx +379 -0
  144. package/src/ui/slider/slider-track.tsx +912 -0
  145. package/src/ui/slider/slider.tokens.ts +189 -0
  146. package/src/ui/slider/slider.tsx +259 -0
  147. package/src/ui/slider/slider.types.ts +288 -0
  148. package/src/ui/snackbar/index.ts +20 -0
  149. package/src/ui/snackbar/snackbar.test.tsx +338 -0
  150. package/src/ui/snackbar/snackbar.tsx +476 -0
  151. package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
  152. package/src/ui/switch/switch.stories.tsx +309 -0
  153. package/src/ui/switch/switch.test.tsx +243 -0
  154. package/src/ui/switch/switch.tokens.ts +89 -0
  155. package/src/ui/switch/switch.tsx +504 -0
  156. package/src/ui/switch/switch.types.ts +62 -0
  157. package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
  158. package/src/ui/tabs/tab.tsx +407 -0
  159. package/src/ui/tabs/tabs-content.tsx +89 -0
  160. package/src/ui/tabs/tabs-list.tsx +146 -0
  161. package/src/ui/tabs/tabs.test.tsx +290 -0
  162. package/src/ui/tabs/tabs.tokens.ts +121 -0
  163. package/src/ui/tabs/tabs.tsx +229 -0
  164. package/src/ui/tabs/tabs.types.ts +185 -0
  165. package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
  166. package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
  167. package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
  168. package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
  169. package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
  170. package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
  171. package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
  172. package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
  173. package/src/ui/text-field/text-field.test.tsx +454 -0
  174. package/src/ui/text-field/text-field.tokens.ts +104 -0
  175. package/src/ui/text-field/text-field.tsx +548 -0
  176. package/src/ui/text-field/text-field.types.ts +180 -0
  177. package/src/ui/theme-provider/index.tsx +190 -0
  178. package/src/ui/toc.test.tsx +108 -0
  179. package/src/ui/toc.tsx +172 -0
  180. package/src/ui/tooltip/plain-tooltip.tsx +63 -0
  181. package/src/ui/tooltip/rich-tooltip.tsx +94 -0
  182. package/src/ui/tooltip/tooltip-box.tsx +266 -0
  183. package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
  184. package/src/ui/tooltip/tooltip.tokens.ts +26 -0
  185. package/src/ui/tooltip/tooltip.types.ts +70 -0
  186. package/src/ui/tooltip/use-tooltip-position.ts +208 -0
  187. package/src/ui/tooltip/use-tooltip-state.ts +41 -0
  188. package/src/ui/typography/__tests__/typography.test.tsx +170 -0
  189. package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
  190. package/src/ui/typography/type-scale-tokens.ts +205 -0
  191. package/src/ui/typography/typography-key-tokens.ts +43 -0
  192. package/src/ui/typography/typography-tokens.ts +360 -0
  193. package/src/ui/typography/typography.css +22 -0
  194. package/src/ui/typography/typography.tsx +559 -0
  195. package/test-render.tsx +4 -0
  196. package/test-shadow.html +26 -0
  197. package/test_output.txt +164 -0
  198. package/test_output_v2.txt +5 -0
  199. package/tsconfig.build.json +10 -0
  200. package/tsconfig.json +18 -0
  201. package/tsup.config.ts +20 -0
  202. package/vitest.config.ts +11 -0
  203. package/dist/hooks/useMediaQuery.d.ts +0 -11
  204. package/dist/hooks/useRipple.d.ts +0 -26
  205. package/dist/lib/material-symbols-preconnect.d.ts +0 -42
  206. package/dist/lib/theme-utils.d.ts +0 -63
  207. package/dist/lib/utils.d.ts +0 -2
  208. package/dist/types/index.d.ts +0 -1
  209. package/dist/types/md3.d.ts +0 -14
  210. package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
  211. package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
  212. package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
  213. package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
  214. package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
  215. package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
  216. package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
  217. package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
  218. package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
  219. package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
  220. package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
  221. package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
  222. package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
  223. package/dist/ui/app-bar/search-view.d.ts +0 -54
  224. package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
  225. package/dist/ui/badge.d.ts +0 -125
  226. package/dist/ui/button-group.d.ts +0 -59
  227. package/dist/ui/button.d.ts +0 -148
  228. package/dist/ui/card.d.ts +0 -62
  229. package/dist/ui/checkbox.d.ts +0 -82
  230. package/dist/ui/chip.d.ts +0 -110
  231. package/dist/ui/code-block.d.ts +0 -14
  232. package/dist/ui/dialog.d.ts +0 -111
  233. package/dist/ui/divider.d.ts +0 -164
  234. package/dist/ui/drawer.d.ts +0 -39
  235. package/dist/ui/dropdown.d.ts +0 -29
  236. package/dist/ui/fab-menu.d.ts +0 -204
  237. package/dist/ui/fab.d.ts +0 -162
  238. package/dist/ui/icon-button.d.ts +0 -131
  239. package/dist/ui/icon.d.ts +0 -88
  240. package/dist/ui/loading-indicator.d.ts +0 -42
  241. package/dist/ui/navigation-rail.d.ts +0 -29
  242. package/dist/ui/progress-indicator/circular.d.ts +0 -3
  243. package/dist/ui/progress-indicator/hooks.d.ts +0 -3
  244. package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
  245. package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
  246. package/dist/ui/progress-indicator/linear.d.ts +0 -3
  247. package/dist/ui/progress-indicator/types.d.ts +0 -151
  248. package/dist/ui/progress-indicator/utils.d.ts +0 -3
  249. package/dist/ui/radio-button.d.ts +0 -106
  250. package/dist/ui/ripple.d.ts +0 -126
  251. package/dist/ui/scroll-area.d.ts +0 -27
  252. package/dist/ui/shared/constants.d.ts +0 -86
  253. package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
  254. package/dist/ui/slider/range-slider.d.ts +0 -47
  255. package/dist/ui/slider/slider-thumb.d.ts +0 -33
  256. package/dist/ui/slider/slider-track.d.ts +0 -25
  257. package/dist/ui/slider/slider.d.ts +0 -60
  258. package/dist/ui/slider/slider.tokens.d.ts +0 -151
  259. package/dist/ui/slider/slider.types.d.ts +0 -259
  260. package/dist/ui/snackbar/index.d.ts +0 -6
  261. package/dist/ui/snackbar/snackbar.d.ts +0 -197
  262. package/dist/ui/switch/switch.d.ts +0 -30
  263. package/dist/ui/switch/switch.stories.d.ts +0 -48
  264. package/dist/ui/switch/switch.tokens.d.ts +0 -67
  265. package/dist/ui/switch/switch.types.d.ts +0 -59
  266. package/dist/ui/tabs/tab.d.ts +0 -43
  267. package/dist/ui/tabs/tabs-content.d.ts +0 -36
  268. package/dist/ui/tabs/tabs-list.d.ts +0 -40
  269. package/dist/ui/tabs/tabs.d.ts +0 -60
  270. package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
  271. package/dist/ui/tabs/tabs.types.d.ts +0 -172
  272. package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
  273. package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
  274. package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
  275. package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
  276. package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
  277. package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
  278. package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
  279. package/dist/ui/text-field/text-field.d.ts +0 -49
  280. package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
  281. package/dist/ui/text-field/text-field.types.d.ts +0 -126
  282. package/dist/ui/theme-provider/index.d.ts +0 -48
  283. package/dist/ui/toc.d.ts +0 -80
  284. package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
  285. package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
  286. package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
  287. package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
  288. package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
  289. package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
  290. package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
  291. package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
  292. package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
  293. package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
  294. package/dist/ui/typography/typography-tokens.d.ts +0 -220
  295. package/dist/ui/typography/typography.d.ts +0 -265
  296. /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * @file text-field.test.tsx
3
+ *
4
+ * Comprehensive test suite for the MD3 Expressive TextField component.
5
+ * Tests cover: rendering, controlled/uncontrolled, error state, disabled,
6
+ * clear button, password toggle, character counter, accessibility, and
7
+ * imperative handle.
8
+ */
9
+
10
+ import {
11
+ act,
12
+ cleanup,
13
+ fireEvent,
14
+ render,
15
+ screen,
16
+ } from "@testing-library/react";
17
+ import userEvent from "@testing-library/user-event";
18
+ import * as React from "react";
19
+ import { afterEach, describe, expect, it, vi } from "vitest";
20
+ import { TextField } from "./text-field";
21
+ import type { TextFieldHandle } from "./text-field.types";
22
+
23
+ afterEach(cleanup);
24
+
25
+ // ─────────────────────────────────────────────────────────────────────────────
26
+ // Rendering
27
+ // ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ describe("TextField — Rendering", () => {
30
+ it("renders an input element", () => {
31
+ render(<TextField aria-label="Test field" />);
32
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
33
+ });
34
+
35
+ it("renders with label", () => {
36
+ render(<TextField label="Email address" />);
37
+ expect(screen.getByText("Email address")).toBeInTheDocument();
38
+ });
39
+
40
+ it("renders label associated with input via htmlFor", () => {
41
+ render(<TextField label="Email address" id="email-field" />);
42
+ const label = screen.getByText("Email address").closest("label");
43
+ expect(label).toHaveAttribute("for", "email-field");
44
+ expect(screen.getByRole("textbox")).toHaveAttribute("id", "email-field");
45
+ });
46
+
47
+ it("renders filled variant by default", () => {
48
+ const { container } = render(<TextField label="Test" />);
49
+ expect(
50
+ container.querySelector(
51
+ ".bg-\\[var\\(--color-m3-surface-container-highest\\)\\]",
52
+ ),
53
+ ).toBeInTheDocument();
54
+ });
55
+
56
+ it("renders outlined variant when variant='outlined'", () => {
57
+ const { container } = render(<TextField variant="outlined" label="Test" />);
58
+ expect(container.querySelector(".bg-transparent")).toBeInTheDocument();
59
+ });
60
+
61
+ it("renders with supporting text", () => {
62
+ render(
63
+ <TextField label="Email" supportingText="Enter your email address" />,
64
+ );
65
+ expect(screen.getByText("Enter your email address")).toBeInTheDocument();
66
+ });
67
+
68
+ it("renders asterisk when required=true", () => {
69
+ render(<TextField label="Name" required />);
70
+ expect(screen.getByText("*")).toBeInTheDocument();
71
+ });
72
+
73
+ it("does not render asterisk when noAsterisk=true", () => {
74
+ render(<TextField label="Name" required noAsterisk />);
75
+ expect(screen.queryByText("*")).not.toBeInTheDocument();
76
+ });
77
+
78
+ it("renders textarea when type='textarea'", () => {
79
+ render(<TextField label="Bio" type="textarea" />);
80
+ expect(screen.getByRole("textbox").tagName).toBe("TEXTAREA");
81
+ });
82
+
83
+ it("renders prefix text", () => {
84
+ render(<TextField label="Amount" prefixText="$" value="100" />);
85
+ expect(screen.getByText("$")).toBeInTheDocument();
86
+ });
87
+
88
+ it("renders suffix text", () => {
89
+ render(<TextField label="Price" suffixText=".00" value="10" />);
90
+ expect(screen.getByText(".00")).toBeInTheDocument();
91
+ });
92
+ });
93
+
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+ // Controlled Mode
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+
98
+ describe("TextField — Controlled Mode", () => {
99
+ it("displays the controlled value", () => {
100
+ render(<TextField label="Test" value="Hello" onChange={() => {}} />);
101
+ expect(screen.getByRole("textbox")).toHaveValue("Hello");
102
+ });
103
+
104
+ it("calls onChange with new value string", () => {
105
+ const onChange = vi.fn();
106
+ render(<TextField label="Test" value="" onChange={onChange} />);
107
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc" } });
108
+ expect(onChange).toHaveBeenCalledOnce();
109
+ expect(onChange).toHaveBeenCalledWith("abc", expect.any(Object));
110
+ });
111
+
112
+ it("does not mutate value when controlled", () => {
113
+ const onChange = vi.fn();
114
+ render(<TextField label="Test" value="fixed" onChange={onChange} />);
115
+ fireEvent.change(screen.getByRole("textbox"), {
116
+ target: { value: "changed" },
117
+ });
118
+ // Without parent update, value stays controlled
119
+ expect(screen.getByRole("textbox")).toHaveValue("fixed");
120
+ });
121
+ });
122
+
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+ // Uncontrolled Mode
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+
127
+ describe("TextField — Uncontrolled Mode", () => {
128
+ it("uses defaultValue as initial value", () => {
129
+ render(<TextField label="Test" defaultValue="Initial" />);
130
+ expect(screen.getByRole("textbox")).toHaveValue("Initial");
131
+ });
132
+
133
+ it("updates value on user input in uncontrolled mode", async () => {
134
+ render(<TextField label="Test" />);
135
+ const input = screen.getByRole("textbox");
136
+ await userEvent.type(input, "Hello");
137
+ expect(input).toHaveValue("Hello");
138
+ });
139
+ });
140
+
141
+ // ─────────────────────────────────────────────────────────────────────────────
142
+ // Error State
143
+ // ─────────────────────────────────────────────────────────────────────────────
144
+
145
+ describe("TextField — Error State", () => {
146
+ it("sets aria-invalid=true when error=true", () => {
147
+ render(<TextField label="Email" error aria-label="Email field" />);
148
+ expect(screen.getByRole("textbox")).toHaveAttribute("aria-invalid", "true");
149
+ });
150
+
151
+ it("does not set aria-invalid when error=false", () => {
152
+ render(<TextField label="Email" aria-label="Email field" />);
153
+ expect(screen.getByRole("textbox")).not.toHaveAttribute("aria-invalid");
154
+ });
155
+
156
+ it("shows errorText when error=true", () => {
157
+ render(<TextField label="Email" error errorText="Invalid email format" />);
158
+ expect(screen.getByText("Invalid email format")).toBeInTheDocument();
159
+ });
160
+
161
+ it("shows error state when value exceeds maxLength", () => {
162
+ render(
163
+ <TextField
164
+ label="Name"
165
+ maxLength={5}
166
+ value="toolongvalue"
167
+ onChange={() => {}}
168
+ />,
169
+ );
170
+ const input = screen.getByRole("textbox");
171
+ expect(input).toHaveAttribute("aria-invalid", "true");
172
+ });
173
+ });
174
+
175
+ // ─────────────────────────────────────────────────────────────────────────────
176
+ // Disabled State
177
+ // ─────────────────────────────────────────────────────────────────────────────
178
+
179
+ describe("TextField — Disabled State", () => {
180
+ it("disables the input element when disabled=true", () => {
181
+ render(<TextField label="Name" disabled />);
182
+ expect(screen.getByRole("textbox")).toBeDisabled();
183
+ });
184
+
185
+ it("does not call onChange when disabled", () => {
186
+ const onChange = vi.fn();
187
+ render(<TextField label="Name" disabled onChange={onChange} />);
188
+ // In JSDOM, fireEvent bypasses browser disabled logic.
189
+ // The correct test: verify the input has the disabled attribute
190
+ // and that our component's handler guard works via userEvent path.
191
+ expect(screen.getByRole("textbox")).toBeDisabled();
192
+ // Direct DOM events bypass disabled in JSDOM — we verify the
193
+ // disabled attribute is set correctly instead.
194
+ expect(onChange).not.toHaveBeenCalled();
195
+ });
196
+ });
197
+
198
+ // ─────────────────────────────────────────────────────────────────────────────
199
+ // Clear Button
200
+ // ─────────────────────────────────────────────────────────────────────────────
201
+
202
+ describe("TextField — Clear Button", () => {
203
+ it("shows clear button when trailingIconMode='clear' and value is not empty", () => {
204
+ render(
205
+ <TextField
206
+ label="Search"
207
+ trailingIconMode="clear"
208
+ value="some text"
209
+ onChange={() => {}}
210
+ />,
211
+ );
212
+ expect(
213
+ screen.getByRole("button", { name: "Clear input" }),
214
+ ).toBeInTheDocument();
215
+ });
216
+
217
+ it("does not show clear button when value is empty", () => {
218
+ render(
219
+ <TextField
220
+ label="Search"
221
+ trailingIconMode="clear"
222
+ value=""
223
+ onChange={() => {}}
224
+ />,
225
+ );
226
+ expect(
227
+ screen.queryByRole("button", { name: "Clear input" }),
228
+ ).not.toBeInTheDocument();
229
+ });
230
+
231
+ it("calls onChange with empty string when clear button is clicked", () => {
232
+ const onChange = vi.fn();
233
+ render(
234
+ <TextField
235
+ label="Search"
236
+ trailingIconMode="clear"
237
+ value="hello"
238
+ onChange={onChange}
239
+ />,
240
+ );
241
+ fireEvent.click(screen.getByRole("button", { name: "Clear input" }));
242
+ expect(onChange).toHaveBeenCalledWith("", expect.any(Object));
243
+ });
244
+ });
245
+
246
+ // ─────────────────────────────────────────────────────────────────────────────
247
+ // Password Toggle
248
+ // ─────────────────────────────────────────────────────────────────────────────
249
+
250
+ describe("TextField — Password Toggle", () => {
251
+ it("renders a password input by default for type='password'", () => {
252
+ render(
253
+ <TextField
254
+ label="Password"
255
+ type="password"
256
+ trailingIconMode="password-toggle"
257
+ />,
258
+ );
259
+ expect(screen.getByLabelText("Password")).toHaveAttribute(
260
+ "type",
261
+ "password",
262
+ );
263
+ });
264
+
265
+ it("toggles input type to text when show password is clicked", () => {
266
+ render(
267
+ <TextField
268
+ label="Password"
269
+ type="password"
270
+ trailingIconMode="password-toggle"
271
+ />,
272
+ );
273
+ const toggleBtn = screen.getByRole("button", { name: "Show password" });
274
+ fireEvent.click(toggleBtn);
275
+ expect(screen.getByLabelText("Password")).toHaveAttribute("type", "text");
276
+ });
277
+
278
+ it("toggles input type back to password when hide is clicked", () => {
279
+ render(
280
+ <TextField
281
+ label="Password"
282
+ type="password"
283
+ trailingIconMode="password-toggle"
284
+ />,
285
+ );
286
+ fireEvent.click(screen.getByRole("button", { name: "Show password" }));
287
+ fireEvent.click(screen.getByRole("button", { name: "Hide password" }));
288
+ expect(screen.getByLabelText("Password")).toHaveAttribute(
289
+ "type",
290
+ "password",
291
+ );
292
+ });
293
+ });
294
+
295
+ // ─────────────────────────────────────────────────────────────────────────────
296
+ // Character Counter
297
+ // ─────────────────────────────────────────────────────────────────────────────
298
+
299
+ describe("TextField — Character Counter", () => {
300
+ it("shows character counter when maxLength is set", () => {
301
+ render(
302
+ <TextField
303
+ label="Bio"
304
+ maxLength={140}
305
+ value="Hello"
306
+ onChange={() => {}}
307
+ />,
308
+ );
309
+ expect(screen.getByText("5 / 140")).toBeInTheDocument();
310
+ });
311
+
312
+ it("updates counter on value change in uncontrolled mode", async () => {
313
+ render(<TextField label="Bio" maxLength={10} />);
314
+ const input = screen.getByRole("textbox");
315
+ await userEvent.type(input, "Hi");
316
+ expect(screen.getByText("2 / 10")).toBeInTheDocument();
317
+ });
318
+ });
319
+
320
+ // ─────────────────────────────────────────────────────────────────────────────
321
+ // Accessibility
322
+ // ─────────────────────────────────────────────────────────────────────────────
323
+
324
+ describe("TextField — Accessibility", () => {
325
+ it("supports aria-label on the input", () => {
326
+ render(<TextField aria-label="Email address field" />);
327
+ expect(
328
+ screen.getByRole("textbox", { name: "Email address field" }),
329
+ ).toBeInTheDocument();
330
+ });
331
+
332
+ it("sets aria-required=true when required=true", () => {
333
+ render(<TextField label="Name" required />);
334
+ expect(screen.getByRole("textbox")).toHaveAttribute(
335
+ "aria-required",
336
+ "true",
337
+ );
338
+ });
339
+
340
+ it("links input to supporting text via aria-describedby", () => {
341
+ render(
342
+ <TextField
343
+ label="Email"
344
+ id="email"
345
+ supportingText="We will never share your email"
346
+ />,
347
+ );
348
+ const input = screen.getByRole("textbox");
349
+ const supportingId = input.getAttribute("aria-describedby");
350
+ expect(supportingId).toBeTruthy();
351
+ const desc = supportingId ? document.getElementById(supportingId) : null;
352
+ expect(desc).toBeInTheDocument();
353
+ });
354
+
355
+ it("forwards aria-describedby from prop alongside supporting text id", () => {
356
+ render(
357
+ <TextField
358
+ label="Email"
359
+ aria-describedby="external-desc"
360
+ supportingText="Helper"
361
+ />,
362
+ );
363
+ const input = screen.getByRole("textbox");
364
+ expect(input.getAttribute("aria-describedby")).toContain("external-desc");
365
+ });
366
+
367
+ it("forwards name prop to input", () => {
368
+ render(<TextField label="Username" name="username" />);
369
+ expect(screen.getByRole("textbox")).toHaveAttribute("name", "username");
370
+ });
371
+ });
372
+
373
+ // ─────────────────────────────────────────────────────────────────────────────
374
+ // Imperative Handle
375
+ // ─────────────────────────────────────────────────────────────────────────────
376
+
377
+ describe("TextField — Imperative Handle", () => {
378
+ it("focus() focuses the input element", () => {
379
+ const ref = React.createRef<TextFieldHandle>();
380
+ render(<TextField ref={ref} label="Test" />);
381
+ act(() => ref.current?.focus());
382
+ expect(screen.getByRole("textbox")).toHaveFocus();
383
+ });
384
+
385
+ it("blur() blurs the input element", () => {
386
+ const ref = React.createRef<TextFieldHandle>();
387
+ render(<TextField ref={ref} label="Test" />);
388
+ act(() => {
389
+ ref.current?.focus();
390
+ ref.current?.blur();
391
+ });
392
+ expect(screen.getByRole("textbox")).not.toHaveFocus();
393
+ });
394
+
395
+ it("clear() clears the value", async () => {
396
+ const ref = React.createRef<TextFieldHandle>();
397
+ render(<TextField ref={ref} label="Test" defaultValue="Hello" />);
398
+ expect(screen.getByRole("textbox")).toHaveValue("Hello");
399
+ act(() => ref.current?.clear());
400
+ expect(screen.getByRole("textbox")).toHaveValue("");
401
+ });
402
+
403
+ it("getValue() returns the current value", () => {
404
+ const ref = React.createRef<TextFieldHandle>();
405
+ render(<TextField ref={ref} label="Test" defaultValue="world" />);
406
+ const value = ref.current?.getValue();
407
+ expect(value).toBe("world");
408
+ });
409
+
410
+ it("getInputElement() returns the underlying input", () => {
411
+ const ref = React.createRef<TextFieldHandle>();
412
+ render(<TextField ref={ref} label="Test" />);
413
+ const el = ref.current?.getInputElement();
414
+ expect(el?.tagName).toBe("INPUT");
415
+ });
416
+ });
417
+
418
+ // ─────────────────────────────────────────────────────────────────────────────
419
+ // ScrollArea Integration
420
+ // ─────────────────────────────────────────────────────────────────────────────
421
+
422
+ describe("TextField — ScrollArea Integration", () => {
423
+ it("renders ScrollArea when type='textarea'", () => {
424
+ const { container } = render(<TextField type="textarea" label="Bio" />);
425
+ expect(
426
+ container.querySelector("[data-radix-scroll-area-viewport]"),
427
+ ).toBeInTheDocument();
428
+ });
429
+
430
+ it("passes scrollAreaType to ScrollArea", () => {
431
+ const { container } = render(
432
+ <TextField type="textarea" label="Bio" scrollAreaType="always" />,
433
+ );
434
+ expect(
435
+ container.querySelector("[data-radix-scroll-area-viewport]"),
436
+ ).toBeInTheDocument();
437
+ });
438
+
439
+ it("applies fixed height when autoResize is false", () => {
440
+ const { container } = render(
441
+ <TextField type="textarea" rows={4} autoResize={false} />,
442
+ );
443
+ const scrollArea = container.querySelector(".w-full.flex-1");
444
+ expect(scrollArea).toHaveStyle({ height: "96px" }); // 4 rows * 24px
445
+ });
446
+
447
+ it("applies max-height when autoResize is true and maxRows is set", () => {
448
+ const { container } = render(
449
+ <TextField type="textarea" autoResize maxRows={5} />,
450
+ );
451
+ const scrollArea = container.querySelector(".w-full.flex-1");
452
+ expect(scrollArea).toHaveStyle({ maxHeight: "120px" }); // 5 rows * 24px
453
+ });
454
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @file text-field.tokens.ts
3
+ * MD3 design token mapping for the TextField component.
4
+ *
5
+ * All color values reference CSS custom properties defined by the MD3 theme.
6
+ * Do NOT use raw hex values — always use CSS variables.
7
+ *
8
+ * @see https://m3.material.io/components/text-fields/specs
9
+ */
10
+
11
+ // ─── Color Tokens ────────────────────────────────────────────────────────────
12
+
13
+ export const TF_COLORS = {
14
+ /** Filled container background */
15
+ filledBg: "var(--color-m3-surface-container-highest)",
16
+ /** Input text color */
17
+ inputText: "var(--color-m3-on-surface)",
18
+ /** Label (unfloated) + icons + prefix/suffix + supporting text */
19
+ onSurfaceVariant: "var(--color-m3-on-surface-variant)",
20
+ /** Focused active indicator / outline, floated label */
21
+ primary: "var(--color-m3-primary)",
22
+ /** Error indicator / outline / label / icon / supporting text */
23
+ error: "var(--color-m3-error)",
24
+ /** Outlined border (enabled) */
25
+ outline: "var(--color-m3-outline)",
26
+ /** Transparent */
27
+ transparent: "transparent",
28
+ } as const;
29
+
30
+ // ─── Size Tokens ──────────────────────────────────────────────────────────────
31
+
32
+ export const TF_SIZE = {
33
+ /** Container height — normal */
34
+ height: 56,
35
+ /** Container height — dense variant */
36
+ denseHeight: 48,
37
+ /** Active indicator height — enabled */
38
+ indicatorThin: 1,
39
+ /** Active indicator height — focused */
40
+ indicatorThick: 2,
41
+ /** Outline stroke width — enabled */
42
+ outlineThin: 1,
43
+ /** Outline stroke width — focused */
44
+ outlineThick: 2,
45
+ /** Corner radius — all variants */
46
+ cornerRadius: 4,
47
+ /** Leading/Trailing icon size */
48
+ iconSize: 24,
49
+ /** Padding inline start (no leading icon) */
50
+ paddingStart: 16,
51
+ /** Padding inline start (with leading icon) */
52
+ paddingStartWithIcon: 12,
53
+ /** Padding inline end */
54
+ paddingEnd: 16,
55
+ /** Padding inline end (with trailing icon) */
56
+ paddingEndWithIcon: 12,
57
+ /** Notch extra padding per side on outlined label gap */
58
+ notchPadding: 4,
59
+ } as const;
60
+
61
+ // ─── Typography Tokens ────────────────────────────────────────────────────────
62
+
63
+ export const TF_TYPOGRAPHY = {
64
+ /** Body Large — input text, unfloated label */
65
+ bodyLargePx: 16,
66
+ /** Body Small — floated label, supporting text, counter */
67
+ bodySmallPx: 12,
68
+ /** Scale ratio when label floats: 12/16 = 0.75 */
69
+ labelScaleRatio: 12 / 16,
70
+ } as const;
71
+
72
+ // ─── Tailwind Class Snippets ──────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Tailwind utility classes for key TextField elements.
76
+ * Use via cn() — do NOT use these as standalone strings without merging.
77
+ */
78
+ export const TF_CLASSES = {
79
+ // Container
80
+ filledContainer:
81
+ "bg-[var(--color-m3-surface-container-highest)] rounded-tl-[4px] rounded-tr-[4px] rounded-bl-none rounded-br-none",
82
+ outlinedContainer: "bg-transparent rounded-[4px]",
83
+
84
+ // Input
85
+ input:
86
+ "bg-transparent outline-none w-full text-base text-[var(--color-m3-on-surface)] caret-[var(--color-m3-primary)] placeholder:text-[var(--color-m3-on-surface-variant)]",
87
+ inputNoSpinner:
88
+ "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
89
+
90
+ // States
91
+ disabled: "opacity-[0.38] pointer-events-none cursor-not-allowed",
92
+
93
+ // Supporting text
94
+ supportingText: "text-xs text-[var(--color-m3-on-surface-variant)] px-4",
95
+ errorText: "text-xs text-[var(--color-m3-error)] px-4",
96
+
97
+ // Prefix / Suffix
98
+ prefixSuffix:
99
+ "text-base text-[var(--color-m3-on-surface-variant)] select-none shrink-0",
100
+
101
+ // State layer (hover)
102
+ stateLayer:
103
+ "absolute inset-0 bg-[var(--color-m3-on-surface)] opacity-0 transition-opacity duration-150 pointer-events-none rounded-[inherit]",
104
+ } as const;