@dashforge/tw 0.1.0-beta

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 (299) hide show
  1. package/.swcrc +29 -0
  2. package/CHANGELOG.md +131 -0
  3. package/LICENSE +21 -0
  4. package/README.md +244 -0
  5. package/dist/components/AppShell/AppShell.d.ts +32 -0
  6. package/dist/components/AppShell/AppShell.d.ts.map +1 -0
  7. package/dist/components/AppShell/appShell.types.d.ts +62 -0
  8. package/dist/components/AppShell/appShell.types.d.ts.map +1 -0
  9. package/dist/components/AppShell/appShell.variants.d.ts +65 -0
  10. package/dist/components/AppShell/appShell.variants.d.ts.map +1 -0
  11. package/dist/components/Autocomplete/Autocomplete.d.ts +32 -0
  12. package/dist/components/Autocomplete/Autocomplete.d.ts.map +1 -0
  13. package/dist/components/Autocomplete/autocomplete.types.d.ts +214 -0
  14. package/dist/components/Autocomplete/autocomplete.types.d.ts.map +1 -0
  15. package/dist/components/Autocomplete/autocomplete.variants.d.ts +214 -0
  16. package/dist/components/Autocomplete/autocomplete.variants.d.ts.map +1 -0
  17. package/dist/components/Breadcrumbs/Breadcrumbs.d.ts +23 -0
  18. package/dist/components/Breadcrumbs/Breadcrumbs.d.ts.map +1 -0
  19. package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts +98 -0
  20. package/dist/components/Breadcrumbs/breadcrumbs.types.d.ts.map +1 -0
  21. package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts +85 -0
  22. package/dist/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +1 -0
  23. package/dist/components/Button/Button.d.ts +44 -0
  24. package/dist/components/Button/Button.d.ts.map +1 -0
  25. package/dist/components/Button/button.types.d.ts +66 -0
  26. package/dist/components/Button/button.types.d.ts.map +1 -0
  27. package/dist/components/Button/button.variants.d.ts +104 -0
  28. package/dist/components/Button/button.variants.d.ts.map +1 -0
  29. package/dist/components/Checkbox/Checkbox.d.ts +31 -0
  30. package/dist/components/Checkbox/Checkbox.d.ts.map +1 -0
  31. package/dist/components/Checkbox/checkbox.types.d.ts +86 -0
  32. package/dist/components/Checkbox/checkbox.types.d.ts.map +1 -0
  33. package/dist/components/Checkbox/checkbox.variants.d.ts +109 -0
  34. package/dist/components/Checkbox/checkbox.variants.d.ts.map +1 -0
  35. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts +38 -0
  36. package/dist/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -0
  37. package/dist/components/ConfirmDialog/confirmDialog.types.d.ts +80 -0
  38. package/dist/components/ConfirmDialog/confirmDialog.types.d.ts.map +1 -0
  39. package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts +90 -0
  40. package/dist/components/ConfirmDialog/confirmDialog.variants.d.ts.map +1 -0
  41. package/dist/components/DateTimePicker/DateTimePicker.d.ts +49 -0
  42. package/dist/components/DateTimePicker/DateTimePicker.d.ts.map +1 -0
  43. package/dist/components/DateTimePicker/dateTimePicker.types.d.ts +95 -0
  44. package/dist/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -0
  45. package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts +165 -0
  46. package/dist/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -0
  47. package/dist/components/LeftNav/LeftNav.d.ts +34 -0
  48. package/dist/components/LeftNav/LeftNav.d.ts.map +1 -0
  49. package/dist/components/LeftNav/leftNav.types.d.ts +136 -0
  50. package/dist/components/LeftNav/leftNav.types.d.ts.map +1 -0
  51. package/dist/components/LeftNav/leftNav.variants.d.ts +143 -0
  52. package/dist/components/LeftNav/leftNav.variants.d.ts.map +1 -0
  53. package/dist/components/NumberField/NumberField.d.ts +18 -0
  54. package/dist/components/NumberField/NumberField.d.ts.map +1 -0
  55. package/dist/components/NumberField/numberField.types.d.ts +79 -0
  56. package/dist/components/NumberField/numberField.types.d.ts.map +1 -0
  57. package/dist/components/NumberField/numberField.variants.d.ts +169 -0
  58. package/dist/components/NumberField/numberField.variants.d.ts.map +1 -0
  59. package/dist/components/OTPField/OTPField.d.ts +25 -0
  60. package/dist/components/OTPField/OTPField.d.ts.map +1 -0
  61. package/dist/components/OTPField/otpField.types.d.ts +74 -0
  62. package/dist/components/OTPField/otpField.types.d.ts.map +1 -0
  63. package/dist/components/OTPField/otpField.variants.d.ts +105 -0
  64. package/dist/components/OTPField/otpField.variants.d.ts.map +1 -0
  65. package/dist/components/RadioGroup/RadioGroup.d.ts +35 -0
  66. package/dist/components/RadioGroup/RadioGroup.d.ts.map +1 -0
  67. package/dist/components/RadioGroup/radioGroup.types.d.ts +103 -0
  68. package/dist/components/RadioGroup/radioGroup.types.d.ts.map +1 -0
  69. package/dist/components/RadioGroup/radioGroup.variants.d.ts +166 -0
  70. package/dist/components/RadioGroup/radioGroup.variants.d.ts.map +1 -0
  71. package/dist/components/Snackbar/Snackbar.d.ts +37 -0
  72. package/dist/components/Snackbar/Snackbar.d.ts.map +1 -0
  73. package/dist/components/Snackbar/snackbar.types.d.ts +86 -0
  74. package/dist/components/Snackbar/snackbar.types.d.ts.map +1 -0
  75. package/dist/components/Snackbar/snackbar.variants.d.ts +153 -0
  76. package/dist/components/Snackbar/snackbar.variants.d.ts.map +1 -0
  77. package/dist/components/Switch/Switch.d.ts +15 -0
  78. package/dist/components/Switch/Switch.d.ts.map +1 -0
  79. package/dist/components/Switch/switch.types.d.ts +47 -0
  80. package/dist/components/Switch/switch.types.d.ts.map +1 -0
  81. package/dist/components/Switch/switch.variants.d.ts +105 -0
  82. package/dist/components/Switch/switch.variants.d.ts.map +1 -0
  83. package/dist/components/TextField/TextField.d.ts +25 -0
  84. package/dist/components/TextField/TextField.d.ts.map +1 -0
  85. package/dist/components/TextField/textField.types.d.ts +72 -0
  86. package/dist/components/TextField/textField.types.d.ts.map +1 -0
  87. package/dist/components/TextField/textField.variants.d.ts +160 -0
  88. package/dist/components/TextField/textField.variants.d.ts.map +1 -0
  89. package/dist/components/Textarea/Textarea.d.ts +16 -0
  90. package/dist/components/Textarea/Textarea.d.ts.map +1 -0
  91. package/dist/components/Textarea/textarea.types.d.ts +63 -0
  92. package/dist/components/Textarea/textarea.types.d.ts.map +1 -0
  93. package/dist/components/Textarea/textarea.variants.d.ts +197 -0
  94. package/dist/components/Textarea/textarea.variants.d.ts.map +1 -0
  95. package/dist/components/TopBar/TopBar.d.ts +33 -0
  96. package/dist/components/TopBar/TopBar.d.ts.map +1 -0
  97. package/dist/components/TopBar/topBar.types.d.ts +49 -0
  98. package/dist/components/TopBar/topBar.types.d.ts.map +1 -0
  99. package/dist/components/TopBar/topBar.variants.d.ts +79 -0
  100. package/dist/components/TopBar/topBar.variants.d.ts.map +1 -0
  101. package/dist/components/_shared/resolveValidationState.d.ts +42 -0
  102. package/dist/components/_shared/resolveValidationState.d.ts.map +1 -0
  103. package/dist/hooks/useAccessState.d.ts +37 -0
  104. package/dist/hooks/useAccessState.d.ts.map +1 -0
  105. package/dist/index.d.ts +82 -0
  106. package/dist/index.d.ts.map +1 -0
  107. package/dist/index.esm.js +4958 -0
  108. package/dist/src/components/AppShell/AppShell.d.ts +32 -0
  109. package/dist/src/components/AppShell/AppShell.d.ts.map +1 -0
  110. package/dist/src/components/AppShell/appShell.types.d.ts +62 -0
  111. package/dist/src/components/AppShell/appShell.types.d.ts.map +1 -0
  112. package/dist/src/components/AppShell/appShell.variants.d.ts +65 -0
  113. package/dist/src/components/AppShell/appShell.variants.d.ts.map +1 -0
  114. package/dist/src/components/Autocomplete/Autocomplete.d.ts +32 -0
  115. package/dist/src/components/Autocomplete/Autocomplete.d.ts.map +1 -0
  116. package/dist/src/components/Autocomplete/autocomplete.types.d.ts +214 -0
  117. package/dist/src/components/Autocomplete/autocomplete.types.d.ts.map +1 -0
  118. package/dist/src/components/Autocomplete/autocomplete.variants.d.ts +214 -0
  119. package/dist/src/components/Autocomplete/autocomplete.variants.d.ts.map +1 -0
  120. package/dist/src/components/Breadcrumbs/Breadcrumbs.d.ts +23 -0
  121. package/dist/src/components/Breadcrumbs/Breadcrumbs.d.ts.map +1 -0
  122. package/dist/src/components/Breadcrumbs/breadcrumbs.types.d.ts +98 -0
  123. package/dist/src/components/Breadcrumbs/breadcrumbs.types.d.ts.map +1 -0
  124. package/dist/src/components/Breadcrumbs/breadcrumbs.variants.d.ts +85 -0
  125. package/dist/src/components/Breadcrumbs/breadcrumbs.variants.d.ts.map +1 -0
  126. package/dist/src/components/Button/Button.d.ts +44 -0
  127. package/dist/src/components/Button/Button.d.ts.map +1 -0
  128. package/dist/src/components/Button/button.types.d.ts +66 -0
  129. package/dist/src/components/Button/button.types.d.ts.map +1 -0
  130. package/dist/src/components/Button/button.variants.d.ts +104 -0
  131. package/dist/src/components/Button/button.variants.d.ts.map +1 -0
  132. package/dist/src/components/Checkbox/Checkbox.d.ts +31 -0
  133. package/dist/src/components/Checkbox/Checkbox.d.ts.map +1 -0
  134. package/dist/src/components/Checkbox/checkbox.types.d.ts +86 -0
  135. package/dist/src/components/Checkbox/checkbox.types.d.ts.map +1 -0
  136. package/dist/src/components/Checkbox/checkbox.variants.d.ts +109 -0
  137. package/dist/src/components/Checkbox/checkbox.variants.d.ts.map +1 -0
  138. package/dist/src/components/ConfirmDialog/ConfirmDialog.d.ts +38 -0
  139. package/dist/src/components/ConfirmDialog/ConfirmDialog.d.ts.map +1 -0
  140. package/dist/src/components/ConfirmDialog/confirmDialog.types.d.ts +80 -0
  141. package/dist/src/components/ConfirmDialog/confirmDialog.types.d.ts.map +1 -0
  142. package/dist/src/components/ConfirmDialog/confirmDialog.variants.d.ts +90 -0
  143. package/dist/src/components/ConfirmDialog/confirmDialog.variants.d.ts.map +1 -0
  144. package/dist/src/components/DateTimePicker/DateTimePicker.d.ts +49 -0
  145. package/dist/src/components/DateTimePicker/DateTimePicker.d.ts.map +1 -0
  146. package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts +95 -0
  147. package/dist/src/components/DateTimePicker/dateTimePicker.types.d.ts.map +1 -0
  148. package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts +165 -0
  149. package/dist/src/components/DateTimePicker/dateTimePicker.variants.d.ts.map +1 -0
  150. package/dist/src/components/LeftNav/LeftNav.d.ts +34 -0
  151. package/dist/src/components/LeftNav/LeftNav.d.ts.map +1 -0
  152. package/dist/src/components/LeftNav/leftNav.types.d.ts +136 -0
  153. package/dist/src/components/LeftNav/leftNav.types.d.ts.map +1 -0
  154. package/dist/src/components/LeftNav/leftNav.variants.d.ts +143 -0
  155. package/dist/src/components/LeftNav/leftNav.variants.d.ts.map +1 -0
  156. package/dist/src/components/NumberField/NumberField.d.ts +18 -0
  157. package/dist/src/components/NumberField/NumberField.d.ts.map +1 -0
  158. package/dist/src/components/NumberField/numberField.types.d.ts +79 -0
  159. package/dist/src/components/NumberField/numberField.types.d.ts.map +1 -0
  160. package/dist/src/components/NumberField/numberField.variants.d.ts +169 -0
  161. package/dist/src/components/NumberField/numberField.variants.d.ts.map +1 -0
  162. package/dist/src/components/OTPField/OTPField.d.ts +25 -0
  163. package/dist/src/components/OTPField/OTPField.d.ts.map +1 -0
  164. package/dist/src/components/OTPField/otpField.types.d.ts +74 -0
  165. package/dist/src/components/OTPField/otpField.types.d.ts.map +1 -0
  166. package/dist/src/components/OTPField/otpField.variants.d.ts +105 -0
  167. package/dist/src/components/OTPField/otpField.variants.d.ts.map +1 -0
  168. package/dist/src/components/RadioGroup/RadioGroup.d.ts +35 -0
  169. package/dist/src/components/RadioGroup/RadioGroup.d.ts.map +1 -0
  170. package/dist/src/components/RadioGroup/radioGroup.types.d.ts +103 -0
  171. package/dist/src/components/RadioGroup/radioGroup.types.d.ts.map +1 -0
  172. package/dist/src/components/RadioGroup/radioGroup.variants.d.ts +166 -0
  173. package/dist/src/components/RadioGroup/radioGroup.variants.d.ts.map +1 -0
  174. package/dist/src/components/Snackbar/Snackbar.d.ts +37 -0
  175. package/dist/src/components/Snackbar/Snackbar.d.ts.map +1 -0
  176. package/dist/src/components/Snackbar/snackbar.types.d.ts +86 -0
  177. package/dist/src/components/Snackbar/snackbar.types.d.ts.map +1 -0
  178. package/dist/src/components/Snackbar/snackbar.variants.d.ts +153 -0
  179. package/dist/src/components/Snackbar/snackbar.variants.d.ts.map +1 -0
  180. package/dist/src/components/Switch/Switch.d.ts +15 -0
  181. package/dist/src/components/Switch/Switch.d.ts.map +1 -0
  182. package/dist/src/components/Switch/switch.types.d.ts +47 -0
  183. package/dist/src/components/Switch/switch.types.d.ts.map +1 -0
  184. package/dist/src/components/Switch/switch.variants.d.ts +105 -0
  185. package/dist/src/components/Switch/switch.variants.d.ts.map +1 -0
  186. package/dist/src/components/TextField/TextField.d.ts +25 -0
  187. package/dist/src/components/TextField/TextField.d.ts.map +1 -0
  188. package/dist/src/components/TextField/textField.types.d.ts +72 -0
  189. package/dist/src/components/TextField/textField.types.d.ts.map +1 -0
  190. package/dist/src/components/TextField/textField.variants.d.ts +160 -0
  191. package/dist/src/components/TextField/textField.variants.d.ts.map +1 -0
  192. package/dist/src/components/Textarea/Textarea.d.ts +16 -0
  193. package/dist/src/components/Textarea/Textarea.d.ts.map +1 -0
  194. package/dist/src/components/Textarea/textarea.types.d.ts +63 -0
  195. package/dist/src/components/Textarea/textarea.types.d.ts.map +1 -0
  196. package/dist/src/components/Textarea/textarea.variants.d.ts +197 -0
  197. package/dist/src/components/Textarea/textarea.variants.d.ts.map +1 -0
  198. package/dist/src/components/TopBar/TopBar.d.ts +33 -0
  199. package/dist/src/components/TopBar/TopBar.d.ts.map +1 -0
  200. package/dist/src/components/TopBar/topBar.types.d.ts +49 -0
  201. package/dist/src/components/TopBar/topBar.types.d.ts.map +1 -0
  202. package/dist/src/components/TopBar/topBar.variants.d.ts +79 -0
  203. package/dist/src/components/TopBar/topBar.variants.d.ts.map +1 -0
  204. package/dist/src/components/_shared/resolveValidationState.d.ts +42 -0
  205. package/dist/src/components/_shared/resolveValidationState.d.ts.map +1 -0
  206. package/dist/src/hooks/useAccessState.d.ts +37 -0
  207. package/dist/src/hooks/useAccessState.d.ts.map +1 -0
  208. package/dist/src/index.d.ts +82 -0
  209. package/dist/src/index.d.ts.map +1 -0
  210. package/dist/src/utils/cn.d.ts +26 -0
  211. package/dist/src/utils/cn.d.ts.map +1 -0
  212. package/dist/tsconfig.lib.tsbuildinfo +1 -0
  213. package/dist/utils/cn.d.ts +26 -0
  214. package/dist/utils/cn.d.ts.map +1 -0
  215. package/eslint.config.mjs +47 -0
  216. package/package.json +37 -0
  217. package/project.json +15 -0
  218. package/rollup.config.cjs +16 -0
  219. package/src/components/AppShell/AppShell.test.tsx +223 -0
  220. package/src/components/AppShell/AppShell.tsx +114 -0
  221. package/src/components/AppShell/appShell.types.ts +49 -0
  222. package/src/components/AppShell/appShell.variants.ts +48 -0
  223. package/src/components/Autocomplete/Autocomplete.perf.test.tsx +225 -0
  224. package/src/components/Autocomplete/Autocomplete.test.tsx +556 -0
  225. package/src/components/Autocomplete/Autocomplete.tsx +996 -0
  226. package/src/components/Autocomplete/autocomplete.types.ts +198 -0
  227. package/src/components/Autocomplete/autocomplete.variants.ts +135 -0
  228. package/src/components/Breadcrumbs/Breadcrumbs.perf.test.tsx +125 -0
  229. package/src/components/Breadcrumbs/Breadcrumbs.test.tsx +231 -0
  230. package/src/components/Breadcrumbs/Breadcrumbs.tsx +203 -0
  231. package/src/components/Breadcrumbs/breadcrumbs.types.ts +87 -0
  232. package/src/components/Breadcrumbs/breadcrumbs.variants.ts +49 -0
  233. package/src/components/Button/Button.test.tsx +183 -0
  234. package/src/components/Button/Button.tsx +139 -0
  235. package/src/components/Button/button.types.ts +74 -0
  236. package/src/components/Button/button.variants.ts +159 -0
  237. package/src/components/Checkbox/Checkbox.test.tsx +146 -0
  238. package/src/components/Checkbox/Checkbox.tsx +228 -0
  239. package/src/components/Checkbox/checkbox.types.ts +88 -0
  240. package/src/components/Checkbox/checkbox.variants.ts +68 -0
  241. package/src/components/ConfirmDialog/ConfirmDialog.perf.test.tsx +172 -0
  242. package/src/components/ConfirmDialog/ConfirmDialog.test.tsx +311 -0
  243. package/src/components/ConfirmDialog/ConfirmDialog.tsx +252 -0
  244. package/src/components/ConfirmDialog/confirmDialog.types.ts +70 -0
  245. package/src/components/ConfirmDialog/confirmDialog.variants.ts +76 -0
  246. package/src/components/DateTimePicker/DateTimePicker.perf.test.tsx +153 -0
  247. package/src/components/DateTimePicker/DateTimePicker.test.tsx +204 -0
  248. package/src/components/DateTimePicker/DateTimePicker.tsx +319 -0
  249. package/src/components/DateTimePicker/dateTimePicker.types.ts +83 -0
  250. package/src/components/DateTimePicker/dateTimePicker.variants.ts +84 -0
  251. package/src/components/LeftNav/LeftNav.perf.test.tsx +172 -0
  252. package/src/components/LeftNav/LeftNav.test.tsx +302 -0
  253. package/src/components/LeftNav/LeftNav.tsx +330 -0
  254. package/src/components/LeftNav/leftNav.types.ts +120 -0
  255. package/src/components/LeftNav/leftNav.variants.ts +96 -0
  256. package/src/components/NumberField/NumberField.test.tsx +83 -0
  257. package/src/components/NumberField/NumberField.tsx +293 -0
  258. package/src/components/NumberField/numberField.types.ts +72 -0
  259. package/src/components/NumberField/numberField.variants.ts +94 -0
  260. package/src/components/OTPField/OTPField.test.tsx +121 -0
  261. package/src/components/OTPField/OTPField.tsx +250 -0
  262. package/src/components/OTPField/otpField.types.ts +58 -0
  263. package/src/components/OTPField/otpField.variants.ts +72 -0
  264. package/src/components/RadioGroup/RadioGroup.test.tsx +140 -0
  265. package/src/components/RadioGroup/RadioGroup.tsx +261 -0
  266. package/src/components/RadioGroup/radioGroup.types.ts +85 -0
  267. package/src/components/RadioGroup/radioGroup.variants.ts +87 -0
  268. package/src/components/Snackbar/Snackbar.perf.test.tsx +144 -0
  269. package/src/components/Snackbar/Snackbar.test.tsx +327 -0
  270. package/src/components/Snackbar/Snackbar.tsx +298 -0
  271. package/src/components/Snackbar/snackbar.types.ts +83 -0
  272. package/src/components/Snackbar/snackbar.variants.ts +72 -0
  273. package/src/components/Switch/Switch.test.tsx +131 -0
  274. package/src/components/Switch/Switch.tsx +158 -0
  275. package/src/components/Switch/switch.types.ts +36 -0
  276. package/src/components/Switch/switch.variants.ts +65 -0
  277. package/src/components/TextField/TextField.test.tsx +194 -0
  278. package/src/components/TextField/TextField.tsx +216 -0
  279. package/src/components/TextField/textField.types.ts +70 -0
  280. package/src/components/TextField/textField.variants.ts +87 -0
  281. package/src/components/Textarea/Textarea.test.tsx +75 -0
  282. package/src/components/Textarea/Textarea.tsx +190 -0
  283. package/src/components/Textarea/textarea.types.ts +60 -0
  284. package/src/components/Textarea/textarea.variants.ts +85 -0
  285. package/src/components/TopBar/TopBar.test.tsx +129 -0
  286. package/src/components/TopBar/TopBar.tsx +66 -0
  287. package/src/components/TopBar/topBar.types.ts +42 -0
  288. package/src/components/TopBar/topBar.variants.ts +38 -0
  289. package/src/components/_shared/resolveValidationState.ts +60 -0
  290. package/src/hooks/useAccessState.spec.tsx +161 -0
  291. package/src/hooks/useAccessState.ts +81 -0
  292. package/src/index.ts +191 -0
  293. package/src/utils/cn.spec.ts +16 -0
  294. package/src/utils/cn.ts +29 -0
  295. package/tsconfig.json +13 -0
  296. package/tsconfig.lib.json +55 -0
  297. package/tsconfig.spec.json +34 -0
  298. package/vite.config.ts +25 -0
  299. package/vitest.config.mts +22 -0
@@ -0,0 +1,72 @@
1
+ import { tv, type VariantProps } from 'tailwind-variants';
2
+
3
+ /**
4
+ * Tailwind-variants recipe for `<Snackbar>` (stacked toast surface).
5
+ *
6
+ * Slots:
7
+ * - `container` — fixed-position outer stack
8
+ * - `item` — single snackbar surface
9
+ * - `icon` — leading severity icon
10
+ * - `message` — main text
11
+ * - `action` — optional action button
12
+ * - `closeButton` — trailing `×` button
13
+ */
14
+ export const snackbarVariants = tv({
15
+ slots: {
16
+ container: [
17
+ 'fixed z-50 flex flex-col gap-2 p-4 pointer-events-none',
18
+ 'max-w-[min(28rem,calc(100vw-2rem))]',
19
+ ],
20
+ item: [
21
+ 'pointer-events-auto',
22
+ 'flex items-start gap-3 px-4 py-3 w-full',
23
+ 'rounded-lg border shadow-lg',
24
+ 'text-sm bg-neutral-50 text-neutral-900',
25
+ // Subtle enter transition — opacity + translate, kept short so a
26
+ // burst of snackbars feels snappy.
27
+ 'transition-all duration-200',
28
+ 'data-[state=entered]:opacity-100 data-[state=exited]:opacity-0',
29
+ ],
30
+ icon: 'shrink-0 mt-0.5 w-5 h-5 inline-flex items-center justify-center',
31
+ message: 'flex-1 min-w-0 leading-relaxed',
32
+ action: [
33
+ 'shrink-0 inline-flex items-center justify-center px-2 h-7',
34
+ 'rounded-md text-xs font-medium',
35
+ 'text-primary-700 hover:bg-primary-100',
36
+ 'outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
37
+ 'transition-colors',
38
+ ],
39
+ closeButton: [
40
+ 'shrink-0 inline-flex items-center justify-center w-6 h-6',
41
+ 'rounded-md text-neutral-500 hover:bg-neutral-200 hover:text-neutral-900',
42
+ 'outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
43
+ 'transition-colors',
44
+ ],
45
+ },
46
+ variants: {
47
+ position: {
48
+ 'top-left': { container: 'top-0 left-0 items-start' },
49
+ 'top-center': {
50
+ container: 'top-0 left-1/2 -translate-x-1/2 items-center',
51
+ },
52
+ 'top-right': { container: 'top-0 right-0 items-end' },
53
+ 'bottom-left': { container: 'bottom-0 left-0 items-start' },
54
+ 'bottom-center': {
55
+ container: 'bottom-0 left-1/2 -translate-x-1/2 items-center',
56
+ },
57
+ 'bottom-right': { container: 'bottom-0 right-0 items-end' },
58
+ },
59
+ severity: {
60
+ info: { item: 'border-primary-200', icon: 'text-primary-600' },
61
+ success: { item: 'border-success-200', icon: 'text-success-600' },
62
+ warning: { item: 'border-warning-200', icon: 'text-warning-600' },
63
+ danger: { item: 'border-danger-200', icon: 'text-danger-600' },
64
+ },
65
+ },
66
+ defaultVariants: {
67
+ position: 'bottom-right',
68
+ severity: 'info',
69
+ },
70
+ });
71
+
72
+ export type SnackbarVariants = VariantProps<typeof snackbarVariants>;
@@ -0,0 +1,131 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { render, fireEvent } from '@testing-library/react';
4
+ import { RbacProvider } from '@dashforge/rbac';
5
+ import type { RbacPolicy, Subject } from '@dashforge/rbac';
6
+ import { DashFormProvider } from '@dashforge/forms';
7
+ import { Switch } from './Switch.js';
8
+
9
+ const READ_ONLY: RbacPolicy = {
10
+ roles: [
11
+ {
12
+ name: 'viewer',
13
+ permissions: [{ resource: 'doc', action: 'update', effect: 'deny' }],
14
+ },
15
+ ],
16
+ };
17
+ const viewer: Subject = { id: 'v', roles: ['viewer'] };
18
+
19
+ function withForm(children: React.ReactNode, defaultValues: Record<string, unknown> = {}) {
20
+ return <DashFormProvider defaultValues={defaultValues}>{children}</DashFormProvider>;
21
+ }
22
+
23
+ describe('Switch — standalone', () => {
24
+ it('renders the Radix root with role="switch"', () => {
25
+ const { getByRole } = render(<Switch name="notif" label="Notifications" />);
26
+ expect(getByRole('switch')).toBeTruthy();
27
+ });
28
+
29
+ it('label is associated with the control via htmlFor', () => {
30
+ const { getByText, getByRole } = render(<Switch name="x" label="On" />);
31
+ const label = getByText('On') as HTMLLabelElement;
32
+ const ctrl = getByRole('switch');
33
+ expect(label.htmlFor).toBe(ctrl.id);
34
+ });
35
+
36
+ it('fires onCheckedChange', () => {
37
+ const fn = vi.fn();
38
+ const { getByRole } = render(<Switch name="x" onCheckedChange={fn} />);
39
+ fireEvent.click(getByRole('switch'));
40
+ expect(fn).toHaveBeenCalledWith(true);
41
+ });
42
+
43
+ it('respects defaultChecked when uncontrolled', () => {
44
+ const { getByRole } = render(<Switch name="x" defaultChecked label="X" />);
45
+ expect(getByRole('switch').getAttribute('data-state')).toBe('checked');
46
+ });
47
+
48
+ it('thumb sits in unchecked position by default', () => {
49
+ const { container } = render(<Switch name="x" label="X" />);
50
+ const thumb = container.querySelector('[data-state]')?.children[0];
51
+ expect(thumb?.className).toContain('data-[state=unchecked]:translate-x-0');
52
+ });
53
+
54
+ it('size variants change control + thumb dimensions', () => {
55
+ const { container, rerender } = render(<Switch name="x" size="sm" label="X" />);
56
+ expect(container.querySelector('button[role="switch"]')?.className).toContain('h-4');
57
+ rerender(<Switch name="x" size="lg" label="X" />);
58
+ expect(container.querySelector('button[role="switch"]')?.className).toContain('h-7');
59
+ });
60
+
61
+ it('shows danger styling on error=true', () => {
62
+ const { container } = render(<Switch name="x" error label="X" helperText="bad" />);
63
+ const ctrl = container.querySelector('button[role="switch"]');
64
+ expect(ctrl?.className).toContain('bg-danger-200');
65
+ });
66
+ });
67
+
68
+ describe('Switch — override (sx + slotProps)', () => {
69
+ it('sx applies to root', () => {
70
+ const { container } = render(<Switch name="x" sx="ml-8" />);
71
+ expect((container.firstChild as HTMLElement).className).toContain('ml-8');
72
+ });
73
+
74
+ it('slotProps.thumb decorates the thumb', () => {
75
+ const { container } = render(
76
+ <Switch name="x" slotProps={{ thumb: { className: 'ring-2 ring-purple-500' } }} />
77
+ );
78
+ const thumb = container.querySelector('button[role="switch"]')?.children[0];
79
+ expect(thumb?.className).toContain('ring-purple-500');
80
+ });
81
+ });
82
+
83
+ describe('Switch — RBAC', () => {
84
+ it('hides when access denies with onUnauthorized=hide', () => {
85
+ const { queryByRole } = render(
86
+ <RbacProvider policy={READ_ONLY} subject={viewer}>
87
+ <Switch
88
+ name="x"
89
+ label="X"
90
+ access={{ resource: 'doc', action: 'update', onUnauthorized: 'hide' }}
91
+ />
92
+ </RbacProvider>
93
+ );
94
+ expect(queryByRole('switch')).toBeNull();
95
+ });
96
+
97
+ it('disables when access denies with onUnauthorized=disable', () => {
98
+ const { getByRole } = render(
99
+ <RbacProvider policy={READ_ONLY} subject={viewer}>
100
+ <Switch
101
+ name="x"
102
+ label="X"
103
+ access={{ resource: 'doc', action: 'update', onUnauthorized: 'disable' }}
104
+ />
105
+ </RbacProvider>
106
+ );
107
+ expect((getByRole('switch') as HTMLButtonElement).disabled).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe('Switch — DashFormProvider integration', () => {
112
+ it('renders inside DashFormProvider', () => {
113
+ const { getByRole } = render(withForm(<Switch name="newsletter" label="Subscribe" />));
114
+ expect(getByRole('switch')).toBeTruthy();
115
+ });
116
+
117
+ it('flips state on click (bridge.setValue + onChange)', () => {
118
+ const { getByRole } = render(withForm(<Switch name="x" />, { x: false }));
119
+ const sw = getByRole('switch');
120
+ expect(sw.getAttribute('data-state')).toBe('unchecked');
121
+ fireEvent.click(sw);
122
+ expect(sw.getAttribute('data-state')).toBe('checked');
123
+ });
124
+
125
+ it('respects explicit `checked` (controlled wins over bridge)', () => {
126
+ const { getByRole } = render(
127
+ withForm(<Switch name="x" checked />, { x: false })
128
+ );
129
+ expect(getByRole('switch').getAttribute('data-state')).toBe('checked');
130
+ });
131
+ });
@@ -0,0 +1,158 @@
1
+ import { useContext, useEffect, useId, useRef } from 'react';
2
+ import * as RadixSwitch from '@radix-ui/react-switch';
3
+ import { DashFormContext, useEngineVisibility } from '@dashforge/ui-core';
4
+ import type { DashFormBridge, FieldRegistration } from '@dashforge/ui-core';
5
+ import { useDashFieldMeta } from '@dashforge/forms';
6
+ import { cn } from '../../utils/cn.js';
7
+ import { useAccessState } from '../../hooks/useAccessState.js';
8
+ import { resolveValidationState } from '../_shared/resolveValidationState.js';
9
+ import { switchVariants } from './switch.variants.js';
10
+ import type { SwitchProps } from './switch.types.js';
11
+
12
+ /**
13
+ * Dashforge TW Switch — bridge-integrated toggle.
14
+ *
15
+ * Implementation mirrors `<Checkbox>` (same 8-step bridge integration
16
+ * pattern, same StrictMode-safe unregister, same controlled-vs-
17
+ * uncontrolled Radix props logic) — the only differences are:
18
+ *
19
+ * - Renders Radix `Switch.Root` + `Switch.Thumb` instead of `Checkbox`.
20
+ * - No indicator icon (the thumb position is the visual state).
21
+ * - Variants drive both control track dimensions AND the thumb
22
+ * translate-x distance so the knob lands flush at each end.
23
+ */
24
+ export function Switch(props: SwitchProps) {
25
+ const {
26
+ name,
27
+ rules,
28
+ visibleWhen,
29
+ label,
30
+ helperText,
31
+ error,
32
+ access,
33
+ size,
34
+ disabled,
35
+ checked,
36
+ defaultChecked,
37
+ onCheckedChange,
38
+ sx,
39
+ slotProps,
40
+ } = props;
41
+
42
+ const bridge = useContext(DashFormContext) as DashFormBridge | null;
43
+ const isVisible = useEngineVisibility(bridge?.engine, visibleWhen);
44
+ // Reactive snapshot — see Checkbox.tsx for the same rationale.
45
+ const fieldMeta = useDashFieldMeta(name);
46
+ const accessState = useAccessState(access);
47
+
48
+ const controlId = useId();
49
+
50
+ // StrictMode-safe unregister-on-unmount
51
+ const unregisterRef = useRef({ bridge, name });
52
+ unregisterRef.current = { bridge, name };
53
+ const isMountedRef = useRef(false);
54
+
55
+ useEffect(() => {
56
+ isMountedRef.current = true;
57
+ return () => {
58
+ isMountedRef.current = false;
59
+ const { bridge: cap, name: capName } = unregisterRef.current;
60
+ queueMicrotask(() => {
61
+ if (!isMountedRef.current) cap?.unregister?.(capName);
62
+ });
63
+ };
64
+ }, []);
65
+
66
+ if (!isVisible) return null;
67
+ if (!accessState.visible) return null;
68
+
69
+ const effectiveDisabled =
70
+ Boolean(disabled) || accessState.disabled || accessState.readonly;
71
+
72
+ const isFormMode = Boolean(bridge?.register);
73
+
74
+ let resolvedChecked = checked ?? defaultChecked ?? false;
75
+ let resolvedError = error;
76
+ let resolvedHelperText: typeof helperText = helperText;
77
+ let registration: FieldRegistration | null = null;
78
+
79
+ if (isFormMode && bridge) {
80
+ registration = bridge.register(name, rules);
81
+ const validation = resolveValidationState(name, bridge, error, helperText);
82
+ resolvedError = validation.error;
83
+ resolvedHelperText = validation.helperText;
84
+ resolvedChecked =
85
+ checked !== undefined ? checked : Boolean(fieldMeta.value);
86
+ }
87
+
88
+ const handleCheckedChange = (next: boolean) => {
89
+ if (isFormMode && bridge) {
90
+ bridge.setValue?.(name, next);
91
+ const syntheticEvent = {
92
+ target: { name, checked: next, value: next },
93
+ type: 'change',
94
+ };
95
+ void registration?.onChange?.(syntheticEvent);
96
+ }
97
+ onCheckedChange?.(next);
98
+ };
99
+
100
+ const handleBlur = () => {
101
+ if (!isFormMode || !bridge) return;
102
+ const currentChecked = bridge.getValue(name) === true;
103
+ const syntheticEvent = {
104
+ target: { name, checked: currentChecked, value: currentChecked },
105
+ type: 'blur',
106
+ };
107
+ registration?.onBlur?.(syntheticEvent);
108
+ };
109
+
110
+ const v = switchVariants({ size, error: resolvedError });
111
+
112
+ // Controlled vs uncontrolled (same logic as Checkbox).
113
+ const radixStateProps: { checked?: boolean; defaultChecked?: boolean } = isFormMode
114
+ ? { checked: resolvedChecked }
115
+ : checked !== undefined
116
+ ? { checked }
117
+ : { defaultChecked: defaultChecked ?? false };
118
+
119
+ return (
120
+ <div className={cn(v.root(), sx, slotProps?.root?.className)}>
121
+ <RadixSwitch.Root
122
+ id={controlId}
123
+ name={name}
124
+ {...radixStateProps}
125
+ disabled={effectiveDisabled}
126
+ onCheckedChange={handleCheckedChange}
127
+ onBlur={handleBlur}
128
+ ref={registration?.ref as React.Ref<HTMLButtonElement> | undefined}
129
+ className={cn(v.control(), slotProps?.control?.className)}
130
+ >
131
+ <RadixSwitch.Thumb className={cn(v.thumb(), slotProps?.thumb?.className)} />
132
+ </RadixSwitch.Root>
133
+
134
+ <div className="flex flex-col">
135
+ {label && (
136
+ <label
137
+ htmlFor={controlId}
138
+ className={cn(v.label(), slotProps?.label?.className)}
139
+ >
140
+ {label}
141
+ </label>
142
+ )}
143
+ {resolvedHelperText && (
144
+ <p
145
+ className={cn(
146
+ resolvedError ? v.errorText() : v.helperText(),
147
+ resolvedError
148
+ ? slotProps?.errorText?.className
149
+ : slotProps?.helperText?.className
150
+ )}
151
+ >
152
+ {resolvedHelperText}
153
+ </p>
154
+ )}
155
+ </div>
156
+ </div>
157
+ );
158
+ }
@@ -0,0 +1,36 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { AccessRequirement } from '@dashforge/rbac';
3
+ import type { Engine } from '@dashforge/ui-core';
4
+ import type { SwitchVariants } from './switch.variants.js';
5
+
6
+ export interface SwitchSlotProps {
7
+ root?: { className?: string };
8
+ control?: { className?: string };
9
+ thumb?: { className?: string };
10
+ label?: { className?: string };
11
+ helperText?: { className?: string };
12
+ errorText?: { className?: string };
13
+ }
14
+
15
+ /**
16
+ * Props for the Dashforge TW `<Switch>` component.
17
+ *
18
+ * Mirrors the bridge contract of `@dashforge/ui/Switch` (MUI). Same
19
+ * `name` / `rules` / `visibleWhen` / `access` semantics, same
20
+ * validation gating. Renders through Radix `Switch.Root` + `Switch.Thumb`.
21
+ */
22
+ export interface SwitchProps extends Pick<SwitchVariants, 'size'> {
23
+ name: string;
24
+ label?: ReactNode;
25
+ rules?: unknown;
26
+ visibleWhen?: (engine: Engine) => boolean;
27
+ checked?: boolean;
28
+ defaultChecked?: boolean;
29
+ disabled?: boolean;
30
+ helperText?: ReactNode;
31
+ error?: boolean;
32
+ access?: AccessRequirement;
33
+ sx?: string;
34
+ slotProps?: SwitchSlotProps;
35
+ onCheckedChange?: (checked: boolean) => void;
36
+ }
@@ -0,0 +1,65 @@
1
+ import { tv, type VariantProps } from 'tailwind-variants';
2
+
3
+ /**
4
+ * Tailwind-variants recipe for `<Switch>` with slots.
5
+ *
6
+ * Slots:
7
+ * - `root` — outer flex wrapper (control + label)
8
+ * - `control` — the visible track (Radix `Switch.Root`)
9
+ * - `thumb` — the moving knob (Radix `Switch.Thumb`); animated
10
+ * via translate-x on data-state="checked"
11
+ * - `label` — text next to the control
12
+ * - `helperText` / `errorText` — informational lines below
13
+ */
14
+ export const switchVariants = tv({
15
+ slots: {
16
+ root: 'inline-flex items-start gap-3',
17
+ control: [
18
+ 'relative inline-flex shrink-0 cursor-pointer items-center',
19
+ 'rounded-full border-2 border-transparent',
20
+ 'transition-colors',
21
+ 'bg-neutral-300',
22
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary-500',
23
+ 'data-[state=checked]:bg-primary-500',
24
+ 'data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
25
+ ],
26
+ thumb: [
27
+ 'pointer-events-none inline-block rounded-full bg-white shadow ring-0',
28
+ 'transition-transform',
29
+ 'data-[state=unchecked]:translate-x-0',
30
+ ],
31
+ label: 'select-none cursor-pointer text-neutral-900',
32
+ helperText: 'text-sm text-neutral-600 mt-1',
33
+ errorText: 'text-sm text-danger-600 mt-1',
34
+ },
35
+ variants: {
36
+ size: {
37
+ sm: {
38
+ control: 'h-4 w-7',
39
+ thumb: 'h-3 w-3 data-[state=checked]:translate-x-3',
40
+ label: 'text-sm',
41
+ },
42
+ md: {
43
+ control: 'h-6 w-11',
44
+ thumb: 'h-5 w-5 data-[state=checked]:translate-x-5',
45
+ label: 'text-base',
46
+ },
47
+ lg: {
48
+ control: 'h-7 w-14',
49
+ thumb: 'h-6 w-6 data-[state=checked]:translate-x-7',
50
+ label: 'text-lg',
51
+ },
52
+ },
53
+ error: {
54
+ true: {
55
+ control: 'bg-danger-200 data-[state=checked]:bg-danger-500 focus-visible:ring-danger-500',
56
+ },
57
+ },
58
+ },
59
+ defaultVariants: {
60
+ size: 'md',
61
+ error: false,
62
+ },
63
+ });
64
+
65
+ export type SwitchVariants = VariantProps<typeof switchVariants>;
@@ -0,0 +1,194 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ import { render, fireEvent } from '@testing-library/react';
4
+ import { RbacProvider } from '@dashforge/rbac';
5
+ import type { RbacPolicy, Subject } from '@dashforge/rbac';
6
+ import { DashFormProvider } from '@dashforge/forms';
7
+ import { TextField } from './TextField.js';
8
+
9
+ const READ_ONLY: RbacPolicy = {
10
+ roles: [
11
+ {
12
+ name: 'viewer',
13
+ permissions: [{ resource: 'doc', action: 'update', effect: 'deny' }],
14
+ },
15
+ ],
16
+ };
17
+ const viewer: Subject = { id: 'v', roles: ['viewer'] };
18
+
19
+ function withForm(children: React.ReactNode, defaultValues: Record<string, unknown> = {}) {
20
+ return <DashFormProvider defaultValues={defaultValues}>{children}</DashFormProvider>;
21
+ }
22
+
23
+ describe('TextField — standalone', () => {
24
+ it('renders an <input type="text"> by default', () => {
25
+ const { getByRole } = render(<TextField name="email" label="Email" />);
26
+ const input = getByRole('textbox') as HTMLInputElement;
27
+ expect(input.type).toBe('text');
28
+ });
29
+
30
+ it('label is wired via htmlFor to the input id', () => {
31
+ const { getByText, getByRole } = render(<TextField name="x" label="My label" />);
32
+ const label = getByText('My label').closest('label') as HTMLLabelElement;
33
+ expect(label.htmlFor).toBe((getByRole('textbox') as HTMLInputElement).id);
34
+ });
35
+
36
+ it('renders the required asterisk and sets the native required attr', () => {
37
+ const { getByText, getByRole } = render(
38
+ <TextField name="x" label="X" required />
39
+ );
40
+ expect(getByText('*')).toBeTruthy();
41
+ expect((getByRole('textbox') as HTMLInputElement).required).toBe(true);
42
+ });
43
+
44
+ it('forwards type="password" / "email" / etc.', () => {
45
+ const { getByLabelText } = render(<TextField name="p" label="P" type="password" />);
46
+ expect((getByLabelText('P') as HTMLInputElement).type).toBe('password');
47
+ });
48
+
49
+ it('renders helperText below the input', () => {
50
+ const { getByText } = render(<TextField name="x" label="X" helperText="Hint" />);
51
+ expect(getByText('Hint')).toBeTruthy();
52
+ });
53
+
54
+ it('aria-describedby links to the helper text id when present', () => {
55
+ const { getByRole, getByText } = render(
56
+ <TextField name="x" label="X" helperText="Hint" />
57
+ );
58
+ const input = getByRole('textbox') as HTMLInputElement;
59
+ const helperId = input.getAttribute('aria-describedby');
60
+ expect(helperId).toBeTruthy();
61
+ expect(getByText('Hint').id).toBe(helperId);
62
+ });
63
+ });
64
+
65
+ describe('TextField — variants', () => {
66
+ it('size lg uses h-12 + text-lg', () => {
67
+ const { container } = render(<TextField name="x" label="X" size="lg" />);
68
+ const wrap = container.querySelector('div[class*="rounded-md"]');
69
+ expect(wrap?.className).toContain('h-12');
70
+ expect(container.querySelector('input')?.className).toContain('text-lg');
71
+ });
72
+
73
+ it('error=true applies danger ring + uses errorText slot for helperText', () => {
74
+ const { container, getByText } = render(
75
+ <TextField name="x" label="X" error helperText="Bad" />
76
+ );
77
+ expect(container.querySelector('div[class*="rounded-md"]')?.className).toContain(
78
+ 'border-danger-500'
79
+ );
80
+ expect(getByText('Bad').className).toContain('text-danger-600');
81
+ });
82
+
83
+ it('aria-invalid is set when error=true', () => {
84
+ const { getByRole } = render(<TextField name="x" label="X" error />);
85
+ expect(getByRole('textbox').getAttribute('aria-invalid')).toBe('true');
86
+ });
87
+
88
+ it('fullWidth adds w-full to root + wrapper', () => {
89
+ const { container } = render(<TextField name="x" label="X" fullWidth />);
90
+ expect((container.firstChild as HTMLElement).className).toContain('w-full');
91
+ });
92
+
93
+ it('layout=inline puts label and input side by side', () => {
94
+ const { container } = render(<TextField name="x" label="X" layout="inline" />);
95
+ expect((container.firstChild as HTMLElement).className).toContain('flex-row');
96
+ });
97
+ });
98
+
99
+ describe('TextField — override (sx + slotProps)', () => {
100
+ it('sx is applied to root', () => {
101
+ const { container } = render(<TextField name="x" label="X" sx="md:w-1/2" />);
102
+ expect((container.firstChild as HTMLElement).className).toContain('md:w-1/2');
103
+ });
104
+
105
+ it('slotProps.input applies to the <input>', () => {
106
+ const { getByRole } = render(
107
+ <TextField name="x" label="X" slotProps={{ input: { className: 'font-mono' } }} />
108
+ );
109
+ expect(getByRole('textbox').className).toContain('font-mono');
110
+ });
111
+
112
+ it('slotProps.label applies to the <label>', () => {
113
+ const { getByText } = render(
114
+ <TextField name="x" label="My" slotProps={{ label: { className: 'uppercase' } }} />
115
+ );
116
+ const label = getByText('My').closest('label');
117
+ expect(label?.className).toContain('uppercase');
118
+ });
119
+ });
120
+
121
+ describe('TextField — RBAC', () => {
122
+ it('hides with onUnauthorized=hide', () => {
123
+ const { queryByRole } = render(
124
+ <RbacProvider policy={READ_ONLY} subject={viewer}>
125
+ <TextField
126
+ name="x"
127
+ label="X"
128
+ access={{ resource: 'doc', action: 'update', onUnauthorized: 'hide' }}
129
+ />
130
+ </RbacProvider>
131
+ );
132
+ expect(queryByRole('textbox')).toBeNull();
133
+ });
134
+
135
+ it('disables with onUnauthorized=disable', () => {
136
+ const { getByRole } = render(
137
+ <RbacProvider policy={READ_ONLY} subject={viewer}>
138
+ <TextField
139
+ name="x"
140
+ label="X"
141
+ access={{ resource: 'doc', action: 'update', onUnauthorized: 'disable' }}
142
+ />
143
+ </RbacProvider>
144
+ );
145
+ expect((getByRole('textbox') as HTMLInputElement).disabled).toBe(true);
146
+ });
147
+
148
+ it('becomes readonly with onUnauthorized=readonly', () => {
149
+ const { getByRole } = render(
150
+ <RbacProvider policy={READ_ONLY} subject={viewer}>
151
+ <TextField
152
+ name="x"
153
+ label="X"
154
+ access={{ resource: 'doc', action: 'update', onUnauthorized: 'readonly' }}
155
+ />
156
+ </RbacProvider>
157
+ );
158
+ expect((getByRole('textbox') as HTMLInputElement).readOnly).toBe(true);
159
+ });
160
+ });
161
+
162
+ describe('TextField — DashFormProvider integration', () => {
163
+ it('renders inside DashFormProvider', () => {
164
+ const { getByRole } = render(withForm(<TextField name="email" label="Email" />));
165
+ expect(getByRole('textbox')).toBeTruthy();
166
+ });
167
+
168
+ it('updates the input value as the user types', () => {
169
+ const { getByRole } = render(withForm(<TextField name="email" label="Email" />));
170
+ const input = getByRole('textbox') as HTMLInputElement;
171
+ fireEvent.change(input, { target: { value: 'hi@example.com' } });
172
+ expect(input.value).toBe('hi@example.com');
173
+ });
174
+
175
+ it('respects explicit `value` (controlled mode wins over bridge)', () => {
176
+ const onChange = vi.fn();
177
+ const { getByRole } = render(
178
+ withForm(
179
+ <TextField name="email" label="Email" value="forced" onChange={onChange} />,
180
+ { email: '' }
181
+ )
182
+ );
183
+ expect((getByRole('textbox') as HTMLInputElement).value).toBe('forced');
184
+ });
185
+
186
+ it('fires user onChange in addition to the bridge update', () => {
187
+ const onChange = vi.fn();
188
+ const { getByRole } = render(
189
+ withForm(<TextField name="email" label="Email" onChange={onChange} />)
190
+ );
191
+ fireEvent.change(getByRole('textbox'), { target: { value: 'x' } });
192
+ expect(onChange).toHaveBeenCalled();
193
+ });
194
+ });