@bug-on/md3-react 2.0.3 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/.turbo/turbo-build.log +42 -0
  2. package/CHANGELOG.md +69 -0
  3. package/dist/index.css +178 -0
  4. package/dist/index.css.d.ts +2 -0
  5. package/dist/index.d.mts +6135 -0
  6. package/dist/index.d.ts +6135 -71
  7. package/dist/index.js +1688 -631
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +1600 -564
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/material-symbols-cdn.css.d.ts +2 -0
  12. package/dist/material-symbols-self-hosted.css.d.ts +2 -0
  13. package/dist/plugin.d.mts +1 -0
  14. package/dist/plugin.d.ts +1 -0
  15. package/dist/plugin.js +13 -0
  16. package/dist/plugin.js.map +1 -0
  17. package/dist/plugin.mjs +3 -0
  18. package/dist/plugin.mjs.map +1 -0
  19. package/dist/typography.css.d.ts +2 -0
  20. package/package.json +28 -19
  21. package/scripts/copy-assets.js +115 -0
  22. package/src/assets/fonts/GoogleSansFlex-VariableFont.woff2 +0 -0
  23. package/src/assets/fonts/MaterialSymbolsOutlined-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  24. package/src/assets/fonts/MaterialSymbolsRounded-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  25. package/src/assets/fonts/MaterialSymbolsSharp-VariableFont_FILL,GRAD,opsz,wght.ttf +0 -0
  26. package/src/assets/loading-indicator.svg +19 -0
  27. package/src/assets/material-symbols-cdn.css +65 -0
  28. package/src/assets/material-symbols-self-hosted.css +90 -0
  29. package/src/css.d.ts +20 -0
  30. package/src/hooks/useClickOutside.ts +37 -0
  31. package/src/hooks/useMediaQuery.ts +28 -0
  32. package/src/hooks/useRipple.ts +88 -0
  33. package/src/index.css +23 -0
  34. package/src/index.ts +349 -0
  35. package/src/lib/material-symbols-preconnect.tsx +82 -0
  36. package/src/lib/theme-utils.ts +195 -0
  37. package/src/lib/utils.ts +6 -0
  38. package/src/plugin.ts +12 -0
  39. package/src/test/button.test.tsx +59 -0
  40. package/src/test/icon.test.tsx +91 -0
  41. package/src/test/loading-indicator.test.tsx +128 -0
  42. package/src/test/progress-indicator.test.tsx +306 -0
  43. package/src/test/setup.ts +80 -0
  44. package/src/test/typography.test.tsx +206 -0
  45. package/src/types/index.ts +7 -0
  46. package/src/types/md3.ts +31 -0
  47. package/src/ui/Text.tsx +60 -0
  48. package/src/ui/__snapshots__/divider.test.tsx.snap +63 -0
  49. package/src/ui/app-bar/app-bar-column.tsx +99 -0
  50. package/src/ui/app-bar/app-bar-item-button.tsx +71 -0
  51. package/src/ui/app-bar/app-bar-items.test.tsx +89 -0
  52. package/src/ui/app-bar/app-bar-overflow-indicator.tsx +108 -0
  53. package/src/ui/app-bar/app-bar-row.tsx +104 -0
  54. package/src/ui/app-bar/app-bar.test.tsx +87 -0
  55. package/src/ui/app-bar/app-bar.tokens.ts +223 -0
  56. package/src/ui/app-bar/app-bar.types.ts +441 -0
  57. package/src/ui/app-bar/bottom-app-bar.test.tsx +42 -0
  58. package/src/ui/app-bar/bottom-app-bar.tsx +84 -0
  59. package/src/ui/app-bar/docked-toolbar.test.tsx +34 -0
  60. package/src/ui/app-bar/docked-toolbar.tsx +54 -0
  61. package/src/ui/app-bar/flexible-app-bar.test.tsx +75 -0
  62. package/src/ui/app-bar/hooks/use-app-bar-scroll.ts +110 -0
  63. package/src/ui/app-bar/hooks/use-flexible-app-bar.ts +123 -0
  64. package/{dist/ui/app-bar/index.d.ts → src/ui/app-bar/index.ts} +35 -2
  65. package/src/ui/app-bar/large-flexible-app-bar.tsx +165 -0
  66. package/src/ui/app-bar/medium-flexible-app-bar.tsx +167 -0
  67. package/src/ui/app-bar/search-app-bar.test.tsx +49 -0
  68. package/src/ui/app-bar/search-app-bar.tsx +176 -0
  69. package/src/ui/app-bar/search-view.tsx +227 -0
  70. package/src/ui/app-bar/small-app-bar.test.tsx +48 -0
  71. package/src/ui/app-bar/small-app-bar.tsx +203 -0
  72. package/src/ui/badge.test.tsx +345 -0
  73. package/src/ui/badge.tsx +282 -0
  74. package/src/ui/button-group.test.tsx +71 -0
  75. package/src/ui/button-group.tsx +350 -0
  76. package/src/ui/button.test.tsx +306 -0
  77. package/src/ui/button.tsx +665 -0
  78. package/src/ui/card.test.tsx +187 -0
  79. package/src/ui/card.tsx +259 -0
  80. package/src/ui/checkbox.test.tsx +423 -0
  81. package/src/ui/checkbox.tsx +525 -0
  82. package/src/ui/chip.test.tsx +292 -0
  83. package/src/ui/chip.tsx +548 -0
  84. package/src/ui/code-block.tsx +219 -0
  85. package/src/ui/dialog.test.tsx +300 -0
  86. package/src/ui/dialog.tsx +384 -0
  87. package/src/ui/divider.test.tsx +314 -0
  88. package/src/ui/divider.tsx +412 -0
  89. package/src/ui/drawer.tsx +240 -0
  90. package/src/ui/fab-menu.test.tsx +494 -0
  91. package/src/ui/fab-menu.tsx +739 -0
  92. package/src/ui/fab.test.tsx +232 -0
  93. package/src/ui/fab.tsx +505 -0
  94. package/src/ui/icon-button.test.tsx +515 -0
  95. package/src/ui/icon-button.tsx +525 -0
  96. package/src/ui/icon.test.tsx +197 -0
  97. package/src/ui/icon.tsx +179 -0
  98. package/src/ui/loading-indicator.test.tsx +73 -0
  99. package/src/ui/loading-indicator.tsx +312 -0
  100. package/src/ui/menu/context-menu.tsx +275 -0
  101. package/src/ui/menu/index.ts +77 -0
  102. package/src/ui/menu/menu-animations.ts +102 -0
  103. package/src/ui/menu/menu-context.tsx +99 -0
  104. package/src/ui/menu/menu-divider.tsx +47 -0
  105. package/src/ui/menu/menu-group.tsx +200 -0
  106. package/src/ui/menu/menu-item.tsx +294 -0
  107. package/src/ui/menu/menu-tokens.ts +208 -0
  108. package/src/ui/menu/menu-types.ts +313 -0
  109. package/src/ui/menu/menu.test.tsx +624 -0
  110. package/src/ui/menu/menu.tsx +289 -0
  111. package/src/ui/menu/sub-menu.tsx +223 -0
  112. package/src/ui/menu/vertical-menu.tsx +382 -0
  113. package/src/ui/navigation-rail.test.tsx +404 -0
  114. package/src/ui/navigation-rail.tsx +607 -0
  115. package/src/ui/progress-indicator/circular.tsx +248 -0
  116. package/src/ui/progress-indicator/hooks.ts +51 -0
  117. package/{dist/ui/progress-indicator/index.d.ts → src/ui/progress-indicator/index.tsx} +20 -2
  118. package/src/ui/progress-indicator/linear-flat.tsx +83 -0
  119. package/src/ui/progress-indicator/linear-wavy.tsx +243 -0
  120. package/src/ui/progress-indicator/linear.tsx +143 -0
  121. package/src/ui/progress-indicator/types.ts +158 -0
  122. package/src/ui/progress-indicator/utils.ts +73 -0
  123. package/src/ui/radio-button.test.tsx +407 -0
  124. package/src/ui/radio-button.tsx +551 -0
  125. package/src/ui/ripple.test.tsx +72 -0
  126. package/src/ui/ripple.tsx +234 -0
  127. package/src/ui/scroll-area.test.tsx +58 -0
  128. package/src/ui/scroll-area.tsx +139 -0
  129. package/src/ui/search/animated-placeholder.tsx +145 -0
  130. package/src/ui/search/hooks/use-search-keyboard.test.ts +202 -0
  131. package/src/ui/search/hooks/use-search-keyboard.ts +104 -0
  132. package/src/ui/search/hooks/use-search-view-focus.test.ts +96 -0
  133. package/src/ui/search/hooks/use-search-view-focus.ts +24 -0
  134. package/src/ui/search/index.ts +44 -0
  135. package/src/ui/search/search-bar.tsx +220 -0
  136. package/src/ui/search/search-context.tsx +42 -0
  137. package/src/ui/search/search-view-docked.tsx +194 -0
  138. package/src/ui/search/search-view-fullscreen.tsx +247 -0
  139. package/src/ui/search/search.test.tsx +233 -0
  140. package/src/ui/search/search.tokens.ts +134 -0
  141. package/src/ui/search/search.tsx +131 -0
  142. package/src/ui/search/search.types.ts +154 -0
  143. package/src/ui/search/trailing-action.tsx +49 -0
  144. package/src/ui/shared/constants.ts +135 -0
  145. package/{dist/ui/shared/touch-target.d.ts → src/ui/shared/touch-target.tsx} +13 -1
  146. package/src/ui/slider/hooks/useSliderMath.ts +195 -0
  147. package/{dist/ui/slider/index.d.ts → src/ui/slider/index.ts} +12 -1
  148. package/src/ui/slider/range-slider.tsx +561 -0
  149. package/src/ui/slider/slider-thumb.tsx +379 -0
  150. package/src/ui/slider/slider-track.tsx +912 -0
  151. package/src/ui/slider/slider.tokens.ts +189 -0
  152. package/src/ui/slider/slider.tsx +259 -0
  153. package/src/ui/slider/slider.types.ts +288 -0
  154. package/src/ui/snackbar/index.ts +20 -0
  155. package/src/ui/snackbar/snackbar.test.tsx +338 -0
  156. package/src/ui/snackbar/snackbar.tsx +476 -0
  157. package/{dist/ui/switch/index.d.ts → src/ui/switch/index.ts} +1 -0
  158. package/src/ui/switch/switch.stories.tsx +309 -0
  159. package/src/ui/switch/switch.test.tsx +243 -0
  160. package/src/ui/switch/switch.tokens.ts +89 -0
  161. package/src/ui/switch/switch.tsx +504 -0
  162. package/src/ui/switch/switch.types.ts +62 -0
  163. package/{dist/ui/tabs/index.d.ts → src/ui/tabs/index.ts} +8 -1
  164. package/src/ui/tabs/tab.tsx +407 -0
  165. package/src/ui/tabs/tabs-content.tsx +89 -0
  166. package/src/ui/tabs/tabs-list.tsx +146 -0
  167. package/src/ui/tabs/tabs.test.tsx +290 -0
  168. package/src/ui/tabs/tabs.tokens.ts +121 -0
  169. package/src/ui/tabs/tabs.tsx +229 -0
  170. package/src/ui/tabs/tabs.types.ts +185 -0
  171. package/{dist/ui/text-field/index.d.ts → src/ui/text-field/index.ts} +8 -1
  172. package/src/ui/text-field/subcomponents/active-indicator.tsx +67 -0
  173. package/src/ui/text-field/subcomponents/floating-label.tsx +161 -0
  174. package/src/ui/text-field/subcomponents/leading-icon.tsx +46 -0
  175. package/src/ui/text-field/subcomponents/outline-container.tsx +170 -0
  176. package/src/ui/text-field/subcomponents/prefix-suffix.tsx +59 -0
  177. package/src/ui/text-field/subcomponents/supporting-text.tsx +145 -0
  178. package/src/ui/text-field/subcomponents/trailing-icon.tsx +199 -0
  179. package/src/ui/text-field/text-field.test.tsx +454 -0
  180. package/src/ui/text-field/text-field.tokens.ts +104 -0
  181. package/src/ui/text-field/text-field.tsx +548 -0
  182. package/src/ui/text-field/text-field.types.ts +180 -0
  183. package/src/ui/theme-provider/index.tsx +215 -0
  184. package/src/ui/toc.test.tsx +108 -0
  185. package/src/ui/toc.tsx +172 -0
  186. package/src/ui/tooltip/plain-tooltip.tsx +63 -0
  187. package/src/ui/tooltip/rich-tooltip.tsx +94 -0
  188. package/src/ui/tooltip/tooltip-box.tsx +266 -0
  189. package/src/ui/tooltip/tooltip-caret-shape.tsx +68 -0
  190. package/src/ui/tooltip/tooltip.tokens.ts +26 -0
  191. package/src/ui/tooltip/tooltip.types.ts +70 -0
  192. package/src/ui/tooltip/use-tooltip-position.ts +208 -0
  193. package/src/ui/tooltip/use-tooltip-state.ts +41 -0
  194. package/src/ui/typography/__tests__/typography.test.tsx +170 -0
  195. package/{dist/ui/typography/index.d.ts → src/ui/typography/index.ts} +21 -3
  196. package/src/ui/typography/type-scale-tokens.ts +205 -0
  197. package/src/ui/typography/typography-key-tokens.ts +43 -0
  198. package/src/ui/typography/typography-tokens.ts +360 -0
  199. package/src/ui/typography/typography.css +22 -0
  200. package/src/ui/typography/typography.tsx +559 -0
  201. package/test-render.tsx +4 -0
  202. package/test-shadow.html +26 -0
  203. package/test_output.txt +164 -0
  204. package/test_output_v2.txt +5 -0
  205. package/tsconfig.build.json +10 -0
  206. package/tsconfig.json +18 -0
  207. package/tsup.config.ts +20 -0
  208. package/vitest.config.ts +11 -0
  209. package/dist/hooks/useClickOutside.d.ts +0 -8
  210. package/dist/hooks/useMediaQuery.d.ts +0 -11
  211. package/dist/hooks/useRipple.d.ts +0 -26
  212. package/dist/lib/material-symbols-preconnect.d.ts +0 -42
  213. package/dist/lib/theme-utils.d.ts +0 -63
  214. package/dist/lib/utils.d.ts +0 -2
  215. package/dist/types/index.d.ts +0 -1
  216. package/dist/types/md3.d.ts +0 -14
  217. package/dist/ui/app-bar/app-bar-column.d.ts +0 -28
  218. package/dist/ui/app-bar/app-bar-item-button.d.ts +0 -16
  219. package/dist/ui/app-bar/app-bar-overflow-indicator.d.ts +0 -18
  220. package/dist/ui/app-bar/app-bar-row.d.ts +0 -36
  221. package/dist/ui/app-bar/app-bar.tokens.d.ts +0 -184
  222. package/dist/ui/app-bar/app-bar.types.d.ts +0 -392
  223. package/dist/ui/app-bar/bottom-app-bar.d.ts +0 -31
  224. package/dist/ui/app-bar/docked-toolbar.d.ts +0 -25
  225. package/dist/ui/app-bar/hooks/use-app-bar-scroll.d.ts +0 -42
  226. package/dist/ui/app-bar/hooks/use-flexible-app-bar.d.ts +0 -37
  227. package/dist/ui/app-bar/large-flexible-app-bar.d.ts +0 -26
  228. package/dist/ui/app-bar/medium-flexible-app-bar.d.ts +0 -28
  229. package/dist/ui/app-bar/search-app-bar.d.ts +0 -43
  230. package/dist/ui/app-bar/search-view.d.ts +0 -54
  231. package/dist/ui/app-bar/small-app-bar.d.ts +0 -37
  232. package/dist/ui/badge.d.ts +0 -125
  233. package/dist/ui/button-group.d.ts +0 -59
  234. package/dist/ui/button.d.ts +0 -148
  235. package/dist/ui/card.d.ts +0 -62
  236. package/dist/ui/checkbox.d.ts +0 -82
  237. package/dist/ui/chip.d.ts +0 -110
  238. package/dist/ui/code-block.d.ts +0 -14
  239. package/dist/ui/dialog.d.ts +0 -111
  240. package/dist/ui/divider.d.ts +0 -164
  241. package/dist/ui/drawer.d.ts +0 -39
  242. package/dist/ui/dropdown.d.ts +0 -29
  243. package/dist/ui/fab-menu.d.ts +0 -204
  244. package/dist/ui/fab.d.ts +0 -162
  245. package/dist/ui/icon-button.d.ts +0 -131
  246. package/dist/ui/icon.d.ts +0 -88
  247. package/dist/ui/loading-indicator.d.ts +0 -42
  248. package/dist/ui/navigation-rail.d.ts +0 -29
  249. package/dist/ui/progress-indicator/circular.d.ts +0 -3
  250. package/dist/ui/progress-indicator/hooks.d.ts +0 -3
  251. package/dist/ui/progress-indicator/linear-flat.d.ts +0 -10
  252. package/dist/ui/progress-indicator/linear-wavy.d.ts +0 -18
  253. package/dist/ui/progress-indicator/linear.d.ts +0 -3
  254. package/dist/ui/progress-indicator/types.d.ts +0 -151
  255. package/dist/ui/progress-indicator/utils.d.ts +0 -3
  256. package/dist/ui/radio-button.d.ts +0 -106
  257. package/dist/ui/ripple.d.ts +0 -126
  258. package/dist/ui/scroll-area.d.ts +0 -27
  259. package/dist/ui/search/animated-placeholder.d.ts +0 -54
  260. package/dist/ui/search/hooks/use-search-keyboard.d.ts +0 -32
  261. package/dist/ui/search/hooks/use-search-view-focus.d.ts +0 -6
  262. package/dist/ui/search/index.d.ts +0 -27
  263. package/dist/ui/search/search-bar.d.ts +0 -32
  264. package/dist/ui/search/search-context.d.ts +0 -24
  265. package/dist/ui/search/search-view-docked.d.ts +0 -25
  266. package/dist/ui/search/search-view-fullscreen.d.ts +0 -36
  267. package/dist/ui/search/search.d.ts +0 -50
  268. package/dist/ui/search/search.tokens.d.ts +0 -112
  269. package/dist/ui/search/search.types.d.ts +0 -131
  270. package/dist/ui/search/trailing-action.d.ts +0 -9
  271. package/dist/ui/shared/constants.d.ts +0 -86
  272. package/dist/ui/slider/hooks/useSliderMath.d.ts +0 -101
  273. package/dist/ui/slider/range-slider.d.ts +0 -47
  274. package/dist/ui/slider/slider-thumb.d.ts +0 -33
  275. package/dist/ui/slider/slider-track.d.ts +0 -25
  276. package/dist/ui/slider/slider.d.ts +0 -60
  277. package/dist/ui/slider/slider.tokens.d.ts +0 -151
  278. package/dist/ui/slider/slider.types.d.ts +0 -259
  279. package/dist/ui/snackbar/index.d.ts +0 -6
  280. package/dist/ui/snackbar/snackbar.d.ts +0 -197
  281. package/dist/ui/switch/switch.d.ts +0 -30
  282. package/dist/ui/switch/switch.stories.d.ts +0 -48
  283. package/dist/ui/switch/switch.tokens.d.ts +0 -67
  284. package/dist/ui/switch/switch.types.d.ts +0 -59
  285. package/dist/ui/tabs/tab.d.ts +0 -43
  286. package/dist/ui/tabs/tabs-content.d.ts +0 -36
  287. package/dist/ui/tabs/tabs-list.d.ts +0 -40
  288. package/dist/ui/tabs/tabs.d.ts +0 -60
  289. package/dist/ui/tabs/tabs.tokens.d.ts +0 -94
  290. package/dist/ui/tabs/tabs.types.d.ts +0 -172
  291. package/dist/ui/text-field/subcomponents/active-indicator.d.ts +0 -24
  292. package/dist/ui/text-field/subcomponents/floating-label.d.ts +0 -43
  293. package/dist/ui/text-field/subcomponents/leading-icon.d.ts +0 -23
  294. package/dist/ui/text-field/subcomponents/outline-container.d.ts +0 -42
  295. package/dist/ui/text-field/subcomponents/prefix-suffix.d.ts +0 -24
  296. package/dist/ui/text-field/subcomponents/supporting-text.d.ts +0 -37
  297. package/dist/ui/text-field/subcomponents/trailing-icon.d.ts +0 -41
  298. package/dist/ui/text-field/text-field.d.ts +0 -49
  299. package/dist/ui/text-field/text-field.tokens.d.ts +0 -76
  300. package/dist/ui/text-field/text-field.types.d.ts +0 -126
  301. package/dist/ui/theme-provider/index.d.ts +0 -48
  302. package/dist/ui/toc.d.ts +0 -80
  303. package/dist/ui/tooltip/plain-tooltip.d.ts +0 -2
  304. package/dist/ui/tooltip/rich-tooltip.d.ts +0 -2
  305. package/dist/ui/tooltip/tooltip-box.d.ts +0 -2
  306. package/dist/ui/tooltip/tooltip-caret-shape.d.ts +0 -9
  307. package/dist/ui/tooltip/tooltip.tokens.d.ts +0 -26
  308. package/dist/ui/tooltip/tooltip.types.d.ts +0 -56
  309. package/dist/ui/tooltip/use-tooltip-position.d.ts +0 -8
  310. package/dist/ui/tooltip/use-tooltip-state.d.ts +0 -2
  311. package/dist/ui/typography/type-scale-tokens.d.ts +0 -162
  312. package/dist/ui/typography/typography-key-tokens.d.ts +0 -40
  313. package/dist/ui/typography/typography-tokens.d.ts +0 -220
  314. package/dist/ui/typography/typography.d.ts +0 -265
  315. /package/{dist/hooks/index.d.ts → src/hooks/index.ts} +0 -0
  316. /package/{dist/ui/tooltip/index.d.ts → src/ui/tooltip/index.ts} +0 -0
@@ -0,0 +1,548 @@
1
+ /**
2
+ * @file text-field.tsx
3
+ * MD3 Expressive TextField — Filled & Outlined variants.
4
+ *
5
+ * Features:
6
+ * - Floating label animation (Framer Motion)
7
+ * - Filled (active indicator) and Outlined (notch border) variants
8
+ * - Controlled & uncontrolled value handling
9
+ * - Built-in clear button, password toggle
10
+ * - Prefix / suffix text
11
+ * - Supporting text, error text, character counter
12
+ * - Leading & trailing icon slots
13
+ * - Textarea support
14
+ * - Native form validation + imperative handle
15
+ * - Full WCAG AA accessibility
16
+ * - prefers-reduced-motion support
17
+ *
18
+ * @see https://m3.material.io/components/text-fields/overview
19
+ */
20
+
21
+ "use client";
22
+
23
+ import { domMax, LazyMotion, useReducedMotion } from "motion/react";
24
+ import * as React from "react";
25
+ import { cn } from "../../lib/utils";
26
+ import { ScrollArea } from "../scroll-area";
27
+ import { ActiveIndicator } from "./subcomponents/active-indicator";
28
+ import { FloatingLabel } from "./subcomponents/floating-label";
29
+ import { LeadingIcon } from "./subcomponents/leading-icon";
30
+ import { OutlineContainer } from "./subcomponents/outline-container";
31
+ import { PrefixSuffix } from "./subcomponents/prefix-suffix";
32
+ import { SupportingText } from "./subcomponents/supporting-text";
33
+ import { TrailingIcon } from "./subcomponents/trailing-icon";
34
+ import { TF_SIZE } from "./text-field.tokens";
35
+ import type { TextFieldHandle, TextFieldProps } from "./text-field.types";
36
+
37
+ // ─── Constants ────────────────────────────────────────────────────────────────
38
+
39
+ /** Textarea line height in px — matches leading-6 (1.5rem at 16px base). */
40
+ const LINE_HEIGHT_PX = 24;
41
+
42
+ // ─── Component ────────────────────────────────────────────────────────────────
43
+
44
+ const TextFieldComponent = React.forwardRef<TextFieldHandle, TextFieldProps>(
45
+ (
46
+ {
47
+ // Core
48
+ variant = "filled",
49
+ label,
50
+ value: valueProp,
51
+ defaultValue = "",
52
+ onChange,
53
+ // Input config
54
+ type = "text",
55
+ placeholder,
56
+ name,
57
+ id: idProp,
58
+ autoComplete,
59
+ inputMode,
60
+ autoResize = false,
61
+ maxRows,
62
+ rows = autoResize ? 1 : 2,
63
+ cols,
64
+ textDirection,
65
+ // Validation
66
+ required = false,
67
+ noAsterisk = false,
68
+ error: errorProp = false,
69
+ errorText,
70
+ minLength,
71
+ maxLength,
72
+ min,
73
+ max,
74
+ step,
75
+ pattern,
76
+ multiple,
77
+ // Supporting text
78
+ supportingText,
79
+ // Decorators
80
+ prefixText,
81
+ suffixText,
82
+ leadingIcon,
83
+ trailingIcon,
84
+ trailingIconMode = "none",
85
+ // States
86
+ disabled = false,
87
+ readOnly = false,
88
+ noSpinner = false,
89
+ // Form
90
+ form,
91
+ // Accessibility
92
+ "aria-label": ariaLabel,
93
+ "aria-describedby": ariaDescribedby,
94
+ "aria-labelledby": ariaLabelledby,
95
+ // Layout
96
+ className,
97
+ fullWidth = true,
98
+ dense = false,
99
+ // ScrollArea
100
+ scrollAreaType = "hover",
101
+ // Callbacks
102
+ onFocus,
103
+ onBlur,
104
+ onKeyDown,
105
+ onKeyUp,
106
+ },
107
+ ref,
108
+ ) => {
109
+ const prefersReduced = useReducedMotion() ?? false;
110
+
111
+ // ── IDs ───────────────────────────────────────────────────────────────
112
+ const generatedId = React.useId();
113
+ const inputId = idProp ?? `tf-${generatedId}`;
114
+ const supportingId = `${inputId}-supporting`;
115
+
116
+ // ── Value state ───────────────────────────────────────────────────────
117
+ const isControlled = valueProp !== undefined;
118
+ const [internalValue, setInternalValue] = React.useState(defaultValue);
119
+ const currentValue = isControlled ? (valueProp as string) : internalValue;
120
+
121
+ // ── Interaction state ─────────────────────────────────────────────────
122
+ const [isFocused, setIsFocused] = React.useState(false);
123
+ // Hover color changes are handled by CSS (group-hover/tf) for a11y compliance.
124
+
125
+ // ── Password toggle ───────────────────────────────────────────────────
126
+ const [showPassword, setShowPassword] = React.useState(false);
127
+ const resolvedInputType =
128
+ type === "password" && showPassword ? "text" : type;
129
+
130
+ // ── Native validation error ───────────────────────────────────────────
131
+ const [nativeError, setNativeError] = React.useState("");
132
+
133
+ // ── Label width for outlined notch ────────────────────────────────────
134
+ const [labelWidth, setLabelWidth] = React.useState(0);
135
+
136
+ // ── Derived state ─────────────────────────────────────────────────────
137
+ const hasValue = currentValue.length > 0;
138
+ /** Label is floated when focused OR when there is a value. */
139
+ const isFloated = isFocused || hasValue;
140
+ const isError =
141
+ errorProp ||
142
+ !!nativeError ||
143
+ (maxLength !== undefined && currentValue.length > maxLength);
144
+ const containerHeight = dense ? TF_SIZE.denseHeight : TF_SIZE.height;
145
+ const showAsterisk = required && !noAsterisk;
146
+
147
+ // ── Refs ──────────────────────────────────────────────────────────────
148
+ const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(null);
149
+ const labelSpanRef = React.useRef<HTMLSpanElement>(null);
150
+
151
+ // Measure label span width for outlined notch (once on mount)
152
+ React.useLayoutEffect(() => {
153
+ if (labelSpanRef.current) {
154
+ setLabelWidth(labelSpanRef.current.offsetWidth);
155
+ }
156
+ // eslint-disable-next-line react-hooks/exhaustive-deps
157
+ }, []); // mount only — label text changes are rare in production
158
+
159
+ // ── Auto-resize textarea ──────────────────────────────────────────────
160
+ React.useLayoutEffect(() => {
161
+ if (type !== "textarea" || !inputRef.current) return;
162
+ const textarea = inputRef.current as HTMLTextAreaElement;
163
+
164
+ if (autoResize) {
165
+ // currentValue in deps triggers re-measure on each keystroke
166
+ void currentValue;
167
+ textarea.style.height = "auto";
168
+ textarea.style.height = `${textarea.scrollHeight}px`;
169
+
170
+ if (maxRows) {
171
+ textarea.style.maxHeight = `${maxRows * LINE_HEIGHT_PX}px`;
172
+ }
173
+ }
174
+
175
+ // Hide native scrollbar — ScrollArea handles it
176
+ textarea.style.overflowY = "hidden";
177
+ }, [type, autoResize, maxRows, currentValue]);
178
+
179
+ // ── Handlers ──────────────────────────────────────────────────────────
180
+
181
+ const handleValueChange = React.useCallback(
182
+ (newValue: string) => {
183
+ if (!isControlled) setInternalValue(newValue);
184
+ // Clear native validation error when user types
185
+ setNativeError("");
186
+ (inputRef.current as HTMLInputElement)?.setCustomValidity?.("");
187
+ },
188
+ [isControlled],
189
+ );
190
+
191
+ const handleChange = React.useCallback(
192
+ (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
193
+ const newVal = e.target.value;
194
+ handleValueChange(newVal);
195
+ onChange?.(newVal, e);
196
+ },
197
+ [handleValueChange, onChange],
198
+ );
199
+
200
+ const handleFocus = React.useCallback(
201
+ (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
202
+ setIsFocused(true);
203
+ onFocus?.(e);
204
+ },
205
+ [onFocus],
206
+ );
207
+
208
+ const handleBlur = React.useCallback(
209
+ (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
210
+ setIsFocused(false);
211
+ // Run native validation on blur
212
+ const el = inputRef.current as HTMLInputElement;
213
+ if (el && !el.validity.valid) {
214
+ setNativeError(el.validationMessage);
215
+ } else {
216
+ setNativeError("");
217
+ }
218
+ onBlur?.(e);
219
+ },
220
+ [onBlur],
221
+ );
222
+
223
+ const handleClear = React.useCallback(() => {
224
+ handleValueChange("");
225
+ onChange?.("", {
226
+ target: { value: "" },
227
+ } as React.ChangeEvent<HTMLInputElement>);
228
+ inputRef.current?.focus();
229
+ }, [handleValueChange, onChange]);
230
+
231
+ const handlePasswordToggle = React.useCallback(() => {
232
+ setShowPassword((prev) => !prev);
233
+ inputRef.current?.focus();
234
+ }, []);
235
+
236
+ // ── Imperative Handle ─────────────────────────────────────────────────
237
+ React.useImperativeHandle(
238
+ ref,
239
+ () => ({
240
+ focus: () => inputRef.current?.focus(),
241
+ blur: () => inputRef.current?.blur(),
242
+ select: () => (inputRef.current as HTMLInputElement)?.select?.(),
243
+ clear: () => handleValueChange(""),
244
+ setCustomValidity: (msg: string) =>
245
+ (inputRef.current as HTMLInputElement)?.setCustomValidity?.(msg),
246
+ checkValidity: () =>
247
+ (inputRef.current as HTMLInputElement)?.checkValidity?.() ?? true,
248
+ reportValidity: () => {
249
+ const el = inputRef.current as HTMLInputElement;
250
+ const valid = el?.reportValidity?.() ?? true;
251
+ if (!valid) setNativeError(el?.validationMessage ?? "");
252
+ return valid;
253
+ },
254
+ getValue: () => currentValue,
255
+ getInputElement: () => inputRef.current,
256
+ }),
257
+ [currentValue, handleValueChange],
258
+ );
259
+
260
+ // ── ARIA describedby ──────────────────────────────────────────────────
261
+ const hasSupporting = !!(
262
+ supportingText ||
263
+ errorText ||
264
+ maxLength !== undefined
265
+ );
266
+ const computedDescribedby =
267
+ [hasSupporting ? supportingId : "", ariaDescribedby ?? ""]
268
+ .filter(Boolean)
269
+ .join(" ") || undefined;
270
+
271
+ // ── Input classes ─────────────────────────────────────────────────────
272
+ const inputClass = cn(
273
+ "bg-transparent outline-none w-full self-end",
274
+ "text-base leading-6 text-m3-on-surface",
275
+ "caret-[var(--color-m3-primary)]",
276
+ "placeholder:text-m3-on-surface-variant/60",
277
+ noSpinner &&
278
+ type === "number" &&
279
+ "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
280
+ disabled && "cursor-not-allowed",
281
+ );
282
+
283
+ // Shared input element props
284
+ const inputProps = {
285
+ id: inputId,
286
+ name,
287
+ value: currentValue,
288
+ placeholder: isFloated ? (placeholder ?? "") : "",
289
+ disabled,
290
+ readOnly,
291
+ required,
292
+ minLength,
293
+ maxLength,
294
+ autoComplete,
295
+ inputMode,
296
+ form,
297
+ "aria-label": ariaLabel,
298
+ "aria-labelledby": ariaLabelledby,
299
+ "aria-describedby": computedDescribedby,
300
+ "aria-invalid": isError || undefined,
301
+ "aria-required": required || undefined,
302
+ onChange: handleChange,
303
+ onFocus: handleFocus,
304
+ onBlur: handleBlur,
305
+ onKeyDown,
306
+ onKeyUp,
307
+ };
308
+
309
+ // ── Container classes ─────────────────────────────────────────────────
310
+ const hasLeading = !!leadingIcon;
311
+ // ALWAYS 16px gap between text and container/icons
312
+ const paddingStart = "pl-4";
313
+ const paddingEnd = "pr-4";
314
+
315
+ const containerClass = cn(
316
+ "relative flex items-center",
317
+ variant === "filled"
318
+ ? "bg-[var(--color-m3-surface-container-highest)] rounded-tl-sm rounded-tr-sm"
319
+ : "bg-transparent rounded-sm",
320
+ fullWidth ? "w-full" : "w-fit",
321
+ );
322
+
323
+ const innerClass = cn(
324
+ "relative flex flex-col flex-1 min-w-0",
325
+ paddingStart,
326
+ paddingEnd,
327
+ variant === "filled"
328
+ ? dense
329
+ ? "pt-5 pb-2"
330
+ : "pt-6 pb-2"
331
+ : dense
332
+ ? "py-3"
333
+ : "py-4",
334
+ );
335
+
336
+ const wrapperClass = cn(
337
+ "group/tf inline-flex flex-col",
338
+ fullWidth ? "w-full" : "w-fit",
339
+ disabled && "opacity-[0.38] pointer-events-none",
340
+ className,
341
+ );
342
+
343
+ const containerHeightClass = dense ? "h-12" : "h-14";
344
+
345
+ // ── Render ────────────────────────────────────────────────────────────
346
+ return (
347
+ <LazyMotion features={domMax} strict>
348
+ <div className={wrapperClass}>
349
+ {/* Main container */}
350
+ <div
351
+ className={cn(
352
+ containerClass,
353
+ type !== "textarea" && containerHeightClass,
354
+ )}
355
+ >
356
+ {/* Outlined border with animated notch */}
357
+ {variant === "outlined" && (
358
+ <OutlineContainer
359
+ isFloated={isFloated}
360
+ isFocused={isFocused}
361
+ isError={isError}
362
+ isDisabled={disabled}
363
+ isHovered={false}
364
+ labelWidth={labelWidth}
365
+ prefersReduced={prefersReduced}
366
+ />
367
+ )}
368
+
369
+ {/* Hover state layer (filled only) */}
370
+ {variant === "filled" && (
371
+ <div
372
+ aria-hidden="true"
373
+ className={cn(
374
+ "absolute inset-0 pointer-events-none rounded-[inherit] bg-m3-on-surface",
375
+ "opacity-0 transition-opacity duration-150",
376
+ "group-hover/tf:opacity-[0.08]",
377
+ )}
378
+ />
379
+ )}
380
+
381
+ {/* Leading icon */}
382
+ {leadingIcon && (
383
+ <LeadingIcon isError={isError} isDisabled={disabled}>
384
+ {leadingIcon}
385
+ </LeadingIcon>
386
+ )}
387
+
388
+ {/* Inner column: floating label + input row */}
389
+ <div className={innerClass}>
390
+ {/* Floating label — absolutely positioned over the inner region */}
391
+ {label && (
392
+ <FloatingLabel
393
+ text={label}
394
+ isFloated={isFloated}
395
+ isFocused={isFocused}
396
+ isError={isError}
397
+ isDisabled={disabled}
398
+ variant={variant}
399
+ containerHeight={containerHeight}
400
+ prefersReduced={prefersReduced}
401
+ showAsterisk={showAsterisk}
402
+ htmlFor={inputId}
403
+ labelRef={labelSpanRef}
404
+ hasLeading={hasLeading}
405
+ />
406
+ )}
407
+
408
+ {/* Input row: prefix + input/textarea + suffix */}
409
+ <div className="flex items-center w-full">
410
+ {prefixText && (
411
+ <PrefixSuffix
412
+ text={prefixText}
413
+ type="prefix"
414
+ visible={isFloated}
415
+ prefersReduced={prefersReduced}
416
+ />
417
+ )}
418
+
419
+ {type === "textarea" ? (
420
+ <ScrollArea
421
+ type={scrollAreaType}
422
+ className="w-full flex-1"
423
+ style={{
424
+ height: !autoResize ? rows * LINE_HEIGHT_PX : undefined,
425
+ maxHeight:
426
+ autoResize && maxRows
427
+ ? maxRows * LINE_HEIGHT_PX
428
+ : undefined,
429
+ }}
430
+ viewportClassName="max-h-[inherit]"
431
+ >
432
+ <textarea
433
+ ref={inputRef as React.Ref<HTMLTextAreaElement>}
434
+ rows={rows}
435
+ cols={cols}
436
+ className={cn(
437
+ inputClass,
438
+ "resize-none mt-2",
439
+ autoResize ? "h-auto" : "h-full",
440
+ )}
441
+ style={{ direction: textDirection || undefined }}
442
+ {...inputProps}
443
+ />
444
+ </ScrollArea>
445
+ ) : (
446
+ <input
447
+ ref={inputRef as React.Ref<HTMLInputElement>}
448
+ type={type === "password" ? resolvedInputType : type}
449
+ min={min}
450
+ max={max}
451
+ step={step}
452
+ pattern={pattern}
453
+ multiple={multiple}
454
+ className={inputClass}
455
+ style={{ direction: textDirection || undefined }}
456
+ {...inputProps}
457
+ />
458
+ )}
459
+
460
+ {suffixText && (
461
+ <PrefixSuffix
462
+ text={suffixText}
463
+ type="suffix"
464
+ visible={isFloated}
465
+ prefersReduced={prefersReduced}
466
+ />
467
+ )}
468
+ </div>
469
+ </div>
470
+
471
+ {/* Trailing icon: clear, password-toggle, or custom */}
472
+ {(trailingIconMode !== "none" || trailingIcon) && (
473
+ <TrailingIcon
474
+ mode={trailingIcon ? "custom" : trailingIconMode}
475
+ value={currentValue}
476
+ showPassword={showPassword}
477
+ onClear={handleClear}
478
+ onPasswordToggle={handlePasswordToggle}
479
+ isError={isError}
480
+ isDisabled={disabled}
481
+ prefersReduced={prefersReduced}
482
+ >
483
+ {trailingIcon}
484
+ </TrailingIcon>
485
+ )}
486
+
487
+ {/* Active indicator: bottom border line (filled variant only) */}
488
+ {variant === "filled" && (
489
+ <ActiveIndicator
490
+ isFocused={isFocused}
491
+ isError={isError}
492
+ isDisabled={disabled}
493
+ isHovered={false}
494
+ prefersReduced={prefersReduced}
495
+ />
496
+ )}
497
+ </div>
498
+
499
+ {/* Supporting text area: helper text + character counter */}
500
+ {hasSupporting && (
501
+ <SupportingText
502
+ supportingText={supportingText}
503
+ errorText={errorText ?? (nativeError || undefined)}
504
+ isError={isError}
505
+ charCount={
506
+ maxLength !== undefined ? currentValue.length : undefined
507
+ }
508
+ maxLength={maxLength}
509
+ id={supportingId}
510
+ prefersReduced={prefersReduced}
511
+ />
512
+ )}
513
+ </div>
514
+ </LazyMotion>
515
+ );
516
+ },
517
+ );
518
+
519
+ TextFieldComponent.displayName = "TextField";
520
+
521
+ /**
522
+ * MD3 Expressive TextField.
523
+ *
524
+ * Supports `filled` and `outlined` variants, floating label animation,
525
+ * prefix/suffix, leading/trailing icons, supporting text, character counter,
526
+ * textarea mode, clear button, password toggle, and native form validation.
527
+ *
528
+ * @example
529
+ * ```tsx
530
+ * // Filled (default)
531
+ * <TextField label="Email" type="email" />
532
+ *
533
+ * // Outlined with error
534
+ * <TextField variant="outlined" label="Username" error errorText="Already taken" />
535
+ *
536
+ * // With clear button
537
+ * <TextField label="Search" trailingIconMode="clear" />
538
+ *
539
+ * // Password with toggle
540
+ * <TextField label="Password" type="password" trailingIconMode="password-toggle" />
541
+ *
542
+ * // With character counter
543
+ * <TextField label="Bio" maxLength={140} supportingText="Describe yourself" />
544
+ * ```
545
+ *
546
+ * @see https://m3.material.io/components/text-fields/overview
547
+ */
548
+ export const TextField = React.memo(TextFieldComponent);