@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,330 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { cn } from '../../utils/cn.js';
3
+ import { useAccessState } from '../../hooks/useAccessState.js';
4
+ import { leftNavVariants } from './leftNav.variants.js';
5
+ import type {
6
+ LeftNavGroup,
7
+ LeftNavItem,
8
+ LeftNavNode,
9
+ LeftNavProps,
10
+ } from './leftNav.types.js';
11
+
12
+ /** Type-narrowing guard for `LeftNavGroup`. */
13
+ function isGroup(node: LeftNavNode): node is LeftNavGroup {
14
+ return node.kind === 'group';
15
+ }
16
+
17
+ /**
18
+ * Internal `<NavItemRow>` — renders one clickable row.
19
+ *
20
+ * Extracted as a separate component so each row can call `useAccessState`
21
+ * unconditionally (RBAC is per-row). Without this extraction we'd have
22
+ * a rules-of-hooks violation iterating `items.map(useAccessState)`.
23
+ */
24
+ function NavItemRow({
25
+ item,
26
+ isActive,
27
+ collapsed,
28
+ linkComponent,
29
+ v,
30
+ slotProps,
31
+ }: {
32
+ item: LeftNavItem;
33
+ isActive: boolean;
34
+ collapsed: boolean;
35
+ linkComponent: NonNullable<LeftNavProps['linkComponent']>;
36
+ v: ReturnType<typeof leftNavVariants>;
37
+ slotProps: LeftNavProps['slotProps'];
38
+ }) {
39
+ const accessState = useAccessState(item.access);
40
+ if (!accessState.visible) return null;
41
+
42
+ const isDisabled = Boolean(item.disabled) || accessState.disabled;
43
+ const Link = linkComponent;
44
+
45
+ const linkClasses = cn(
46
+ v.itemLink(),
47
+ isActive && v.itemActive(),
48
+ slotProps?.itemLink?.className,
49
+ isActive && slotProps?.itemActive?.className
50
+ );
51
+
52
+ // Visual content — same in `<a>` / `<button>` branches below.
53
+ const content = (
54
+ <>
55
+ {item.icon && (
56
+ <span
57
+ aria-hidden="true"
58
+ className={cn(v.itemIcon(), slotProps?.itemIcon?.className)}
59
+ >
60
+ {item.icon}
61
+ </span>
62
+ )}
63
+ <span className={cn(v.itemLabel(), slotProps?.itemLabel?.className)}>
64
+ {item.label}
65
+ </span>
66
+ {!collapsed && item.badge != null && (
67
+ <span className={cn(v.itemBadge(), slotProps?.itemBadge?.className)}>
68
+ {item.badge}
69
+ </span>
70
+ )}
71
+ </>
72
+ );
73
+
74
+ return (
75
+ <li className={cn(v.item(), slotProps?.item?.className)}>
76
+ {item.href !== undefined ? (
77
+ <Link
78
+ href={item.href}
79
+ onClick={(event) => {
80
+ if (isDisabled) {
81
+ event.preventDefault();
82
+ return;
83
+ }
84
+ item.onSelect?.();
85
+ }}
86
+ aria-current={isActive ? 'page' : undefined}
87
+ aria-disabled={isDisabled || undefined}
88
+ title={collapsed && typeof item.label === 'string' ? item.label : undefined}
89
+ className={linkClasses}
90
+ >
91
+ {content}
92
+ </Link>
93
+ ) : (
94
+ <button
95
+ type="button"
96
+ onClick={() => {
97
+ if (isDisabled) return;
98
+ item.onSelect?.();
99
+ }}
100
+ aria-current={isActive ? 'page' : undefined}
101
+ aria-disabled={isDisabled || undefined}
102
+ disabled={isDisabled}
103
+ title={collapsed && typeof item.label === 'string' ? item.label : undefined}
104
+ className={linkClasses}
105
+ >
106
+ {content}
107
+ </button>
108
+ )}
109
+ </li>
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Internal `<NavGroupRow>` — renders a collapsible group of items.
115
+ *
116
+ * State strategy: each group owns its own `expanded` state (uncontrolled
117
+ * by default, seeded from `defaultExpanded`). A controlled mode kicks
118
+ * in when `group.expanded` is provided — then the parent owns the state
119
+ * and we never call `setLocalExpanded`.
120
+ */
121
+ function NavGroupRow({
122
+ group,
123
+ activeId,
124
+ collapsed,
125
+ linkComponent,
126
+ onGroupExpandedChange,
127
+ v,
128
+ slotProps,
129
+ }: {
130
+ group: LeftNavGroup;
131
+ activeId: string | undefined;
132
+ collapsed: boolean;
133
+ linkComponent: NonNullable<LeftNavProps['linkComponent']>;
134
+ onGroupExpandedChange?: (groupId: string, expanded: boolean) => void;
135
+ v: ReturnType<typeof leftNavVariants>;
136
+ slotProps: LeftNavProps['slotProps'];
137
+ }) {
138
+ const accessState = useAccessState(group.access);
139
+ const isControlled = group.expanded !== undefined;
140
+ const [localExpanded, setLocalExpanded] = useState<boolean>(
141
+ group.defaultExpanded ?? true
142
+ );
143
+ const expanded = isControlled ? Boolean(group.expanded) : localExpanded;
144
+
145
+ if (!accessState.visible) return null;
146
+ const isDisabled = accessState.disabled;
147
+
148
+ const toggle = () => {
149
+ if (isDisabled) return;
150
+ const next = !expanded;
151
+ if (!isControlled) setLocalExpanded(next);
152
+ onGroupExpandedChange?.(group.id, next);
153
+ };
154
+
155
+ // Group header ids → wires aria-controls / aria-labelledby for the
156
+ // expandable region the screen-reader announces.
157
+ const headerId = `${group.id}-header`;
158
+ const regionId = `${group.id}-region`;
159
+
160
+ return (
161
+ <li className={cn(v.group(), slotProps?.group?.className)}>
162
+ <button
163
+ id={headerId}
164
+ type="button"
165
+ onClick={toggle}
166
+ aria-expanded={expanded}
167
+ aria-controls={regionId}
168
+ aria-disabled={isDisabled || undefined}
169
+ disabled={isDisabled}
170
+ title={collapsed && typeof group.label === 'string' ? group.label : undefined}
171
+ className={cn(v.groupHeader(), slotProps?.groupHeader?.className)}
172
+ >
173
+ {group.icon && (
174
+ <span
175
+ aria-hidden="true"
176
+ className={cn(v.itemIcon(), slotProps?.itemIcon?.className)}
177
+ >
178
+ {group.icon}
179
+ </span>
180
+ )}
181
+ <span className={cn(v.itemLabel(), slotProps?.itemLabel?.className)}>
182
+ {group.label}
183
+ </span>
184
+ {!collapsed && (
185
+ <span aria-hidden="true" className="shrink-0 text-neutral-500 text-xs">
186
+ {expanded ? '▾' : '▸'}
187
+ </span>
188
+ )}
189
+ </button>
190
+ {expanded && (
191
+ <ul
192
+ id={regionId}
193
+ role="region"
194
+ aria-labelledby={headerId}
195
+ className={cn(
196
+ v.groupChildren(),
197
+ slotProps?.groupChildren?.className
198
+ )}
199
+ >
200
+ {group.children.map((child) => (
201
+ <NavItemRow
202
+ key={child.id}
203
+ item={child}
204
+ isActive={activeId === child.id}
205
+ collapsed={collapsed}
206
+ linkComponent={linkComponent}
207
+ v={v}
208
+ slotProps={slotProps}
209
+ />
210
+ ))}
211
+ </ul>
212
+ )}
213
+ </li>
214
+ );
215
+ }
216
+
217
+ /**
218
+ * Dashforge TW LeftNav — router-agnostic sidebar navigation.
219
+ *
220
+ * Pure presentational. The consumer wires `activeId` from the current
221
+ * route. Each row renders as a real `<a>` (with `href`) or a `<button>`
222
+ * (with `onSelect`); `linkComponent` swaps in a router-aware link
223
+ * component for SPA navigation.
224
+ *
225
+ * Modes:
226
+ * - **Expanded** (`collapsed={false}`): full-width with labels +
227
+ * badges + group children visible.
228
+ * - **Rail** (`collapsed={true}`): 56px wide, icons only, labels
229
+ * hidden from sighted users (still announced to screen readers via
230
+ * `sr-only`). Group children are hidden — clicking the group
231
+ * header is a no-op visually in rail mode (consumer is expected
232
+ * to either expand the nav or surface a flyout in a follow-up).
233
+ *
234
+ * **A11y**:
235
+ * - `<nav aria-label="…">` landmark.
236
+ * - `aria-current="page"` on the active row.
237
+ * - Groups expose `aria-expanded` / `aria-controls` and the children
238
+ * wrapper is `role="region" aria-labelledby="…-header"`.
239
+ * - Rail-mode tooltips via the native `title` attribute (preserves
240
+ * hover discoverability when labels are visually hidden).
241
+ *
242
+ * **Re-render contract**:
243
+ * - Rows are extracted into stable sub-components so a single
244
+ * `activeId` change re-renders all rows but each does only its
245
+ * own `useAccessState` lookup; this is the price of per-row RBAC.
246
+ * - Group expand/collapse re-renders ONLY that group (local state).
247
+ */
248
+ export function LeftNav(props: LeftNavProps) {
249
+ const {
250
+ items,
251
+ activeId,
252
+ collapsed = false,
253
+ onCollapseChange,
254
+ onGroupExpandedChange,
255
+ brand,
256
+ footer,
257
+ width,
258
+ linkComponent = 'a' as unknown as NonNullable<LeftNavProps['linkComponent']>,
259
+ ariaLabel = 'Main navigation',
260
+ sx,
261
+ slotProps,
262
+ showCollapseToggle = true,
263
+ } = props;
264
+
265
+ const v = leftNavVariants({ width, collapsed });
266
+
267
+ const handleCollapseToggle = useCallback(() => {
268
+ onCollapseChange?.(!collapsed);
269
+ }, [collapsed, onCollapseChange]);
270
+
271
+ return (
272
+ <nav
273
+ aria-label={ariaLabel}
274
+ className={cn(v.root(), sx, slotProps?.root?.className)}
275
+ >
276
+ {(brand || (showCollapseToggle && onCollapseChange)) && (
277
+ <div className={cn(v.brand(), slotProps?.brand?.className)}>
278
+ {brand && <div className="flex-1 min-w-0 truncate">{brand}</div>}
279
+ {showCollapseToggle && onCollapseChange && (
280
+ <button
281
+ type="button"
282
+ onClick={handleCollapseToggle}
283
+ aria-label={collapsed ? 'Expand navigation' : 'Collapse navigation'}
284
+ aria-pressed={collapsed}
285
+ className={cn(
286
+ v.collapseToggle(),
287
+ slotProps?.collapseToggle?.className
288
+ )}
289
+ >
290
+ {collapsed ? '»' : '«'}
291
+ </button>
292
+ )}
293
+ </div>
294
+ )}
295
+
296
+ <ul className={cn(v.list(), slotProps?.list?.className)}>
297
+ {items.map((node) =>
298
+ isGroup(node) ? (
299
+ <NavGroupRow
300
+ key={node.id}
301
+ group={node}
302
+ activeId={activeId}
303
+ collapsed={collapsed}
304
+ linkComponent={linkComponent}
305
+ onGroupExpandedChange={onGroupExpandedChange}
306
+ v={v}
307
+ slotProps={slotProps}
308
+ />
309
+ ) : (
310
+ <NavItemRow
311
+ key={node.id}
312
+ item={node}
313
+ isActive={activeId === node.id}
314
+ collapsed={collapsed}
315
+ linkComponent={linkComponent}
316
+ v={v}
317
+ slotProps={slotProps}
318
+ />
319
+ )
320
+ )}
321
+ </ul>
322
+
323
+ {footer && (
324
+ <div className={cn(v.footer(), slotProps?.footer?.className)}>
325
+ {footer}
326
+ </div>
327
+ )}
328
+ </nav>
329
+ );
330
+ }
@@ -0,0 +1,120 @@
1
+ import type {
2
+ ReactNode,
3
+ ComponentType,
4
+ AnchorHTMLAttributes,
5
+ Ref,
6
+ } from 'react';
7
+ import type { AccessRequirement } from '@dashforge/rbac';
8
+ import type { LeftNavVariants } from './leftNav.variants.js';
9
+
10
+ /**
11
+ * One row in the LeftNav. May be either a clickable item OR a group
12
+ * (a header that owns nested children, optionally collapsible).
13
+ */
14
+ export type LeftNavNode = LeftNavItem | LeftNavGroup;
15
+
16
+ export interface LeftNavItem {
17
+ kind?: 'item';
18
+ /** Stable id used for `activeId` matching + React keys. */
19
+ id: string;
20
+ label: ReactNode;
21
+ icon?: ReactNode;
22
+ /**
23
+ * Destination for navigation. Omit for a pure-callback item (use
24
+ * `onSelect`).
25
+ */
26
+ href?: string;
27
+ /** Fired when the row is clicked (in addition to navigation). */
28
+ onSelect?: () => void;
29
+ /** Optional pill / counter rendered to the right of the label. */
30
+ badge?: ReactNode;
31
+ /** Per-item disabled flag. */
32
+ disabled?: boolean;
33
+ /** RBAC access requirement — hides / disables the row. */
34
+ access?: AccessRequirement;
35
+ }
36
+
37
+ export interface LeftNavGroup {
38
+ kind: 'group';
39
+ id: string;
40
+ label: ReactNode;
41
+ icon?: ReactNode;
42
+ children: LeftNavItem[];
43
+ /**
44
+ * Initial expanded state when used in uncontrolled mode. Default
45
+ * `true` (groups open by default — most app navs want immediate
46
+ * visibility).
47
+ */
48
+ defaultExpanded?: boolean;
49
+ /** Controlled expanded state. Provide `onExpandedChange` to update. */
50
+ expanded?: boolean;
51
+ /** RBAC access requirement — hides / disables the whole group. */
52
+ access?: AccessRequirement;
53
+ }
54
+
55
+ /**
56
+ * Polymorphic link component prop. Defaults to `<a>`. Pass a
57
+ * router-aware component (react-router `Link`, Next `Link`) for SPA
58
+ * navigation.
59
+ */
60
+ export type LeftNavLinkComponent = ComponentType<
61
+ AnchorHTMLAttributes<HTMLAnchorElement> & {
62
+ ref?: Ref<HTMLAnchorElement>;
63
+ }
64
+ >;
65
+
66
+ export interface LeftNavSlotProps {
67
+ root?: { className?: string };
68
+ brand?: { className?: string };
69
+ list?: { className?: string };
70
+ item?: { className?: string };
71
+ itemLink?: { className?: string };
72
+ itemActive?: { className?: string };
73
+ itemIcon?: { className?: string };
74
+ itemLabel?: { className?: string };
75
+ itemBadge?: { className?: string };
76
+ group?: { className?: string };
77
+ groupHeader?: { className?: string };
78
+ groupChildren?: { className?: string };
79
+ footer?: { className?: string };
80
+ collapseToggle?: { className?: string };
81
+ }
82
+
83
+ /**
84
+ * Props for `<LeftNav>`.
85
+ *
86
+ * Router-agnostic sidebar navigation. The consumer drives the active
87
+ * item via `activeId` (typically derived from the current route).
88
+ *
89
+ * Rail / drawer responsive behaviour is handled by `<AppShell>`; this
90
+ * component renders the actual nav content and exposes a `collapsed`
91
+ * prop for the icon-only "rail" rendering.
92
+ */
93
+ export interface LeftNavProps extends LeftNavVariants {
94
+ items: LeftNavNode[];
95
+ /** Currently-active item id (highlight + `aria-current="page"`). */
96
+ activeId?: string;
97
+ /**
98
+ * Icon-only rail mode. When `true`, labels and group children are
99
+ * visually hidden (kept for screen readers via `sr-only`).
100
+ */
101
+ collapsed?: boolean;
102
+ /** Fired when the user clicks the collapse-toggle button. */
103
+ onCollapseChange?: (collapsed: boolean) => void;
104
+ /** Fired when a group's expanded state changes (controlled or not). */
105
+ onGroupExpandedChange?: (groupId: string, expanded: boolean) => void;
106
+ /** Brand / logo node rendered at the top. */
107
+ brand?: ReactNode;
108
+ /** Footer node (e.g., user badge). */
109
+ footer?: ReactNode;
110
+ /** Override the link component. @default 'a' */
111
+ linkComponent?: LeftNavLinkComponent;
112
+ /** Visible label of the nav landmark. @default 'Main navigation' */
113
+ ariaLabel?: string;
114
+ /** Root className shortcut. */
115
+ sx?: string;
116
+ /** Per-slot className overrides. */
117
+ slotProps?: LeftNavSlotProps;
118
+ /** Show / hide the collapse toggle button. @default true */
119
+ showCollapseToggle?: boolean;
120
+ }
@@ -0,0 +1,96 @@
1
+ import { tv, type VariantProps } from 'tailwind-variants';
2
+
3
+ /**
4
+ * Tailwind-variants recipe for `<LeftNav>`.
5
+ *
6
+ * Slots:
7
+ * - `root` — `<nav>` landmark
8
+ * - `brand` — top brand/logo wrapper
9
+ * - `list` — `<ul>` containing items & groups
10
+ * - `item` — `<li>` wrapper
11
+ * - `itemLink` — clickable row (`<a>` or `<button>`)
12
+ * - `itemActive` — active state on the row
13
+ * - `itemIcon` — icon slot inside a row
14
+ * - `itemLabel` — text label slot
15
+ * - `itemBadge` — right-aligned pill
16
+ * - `group` — wrapper around a group header + children
17
+ * - `groupHeader` — clickable header row (`<button>`)
18
+ * - `groupChildren` — `<ul>` containing the group's items
19
+ * - `footer` — bottom slot (user badge, etc.)
20
+ * - `collapseToggle` — rail-mode toggle button
21
+ */
22
+ export const leftNavVariants = tv({
23
+ slots: {
24
+ root: [
25
+ 'flex flex-col h-full',
26
+ 'bg-neutral-50 border-r border-neutral-200',
27
+ 'transition-[width] duration-200',
28
+ ],
29
+ brand: [
30
+ 'flex items-center gap-2 px-3 h-14 shrink-0',
31
+ 'border-b border-neutral-200 text-neutral-900 font-semibold',
32
+ ],
33
+ list: 'flex flex-col gap-0.5 p-2 flex-1 overflow-y-auto',
34
+ item: 'list-none',
35
+ itemLink: [
36
+ 'flex items-center gap-2 px-2 py-2 rounded-md',
37
+ 'text-sm text-neutral-700 hover:bg-neutral-100',
38
+ 'outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
39
+ 'transition-colors w-full',
40
+ 'aria-disabled:opacity-50 aria-disabled:cursor-not-allowed',
41
+ 'aria-disabled:hover:bg-transparent',
42
+ ],
43
+ itemActive: 'bg-primary-100 text-primary-900 font-medium',
44
+ itemIcon: 'shrink-0 w-5 h-5 flex items-center justify-center',
45
+ itemLabel: 'truncate flex-1 min-w-0 text-left',
46
+ itemBadge: [
47
+ 'shrink-0 inline-flex items-center justify-center',
48
+ 'px-1.5 h-5 rounded-full text-[10px] font-medium',
49
+ 'bg-neutral-200 text-neutral-800',
50
+ ],
51
+ group: 'flex flex-col gap-0.5',
52
+ groupHeader: [
53
+ 'flex items-center gap-2 px-2 py-2 rounded-md',
54
+ 'text-sm font-medium text-neutral-900 hover:bg-neutral-100',
55
+ 'outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
56
+ 'transition-colors w-full',
57
+ ],
58
+ groupChildren: 'flex flex-col gap-0.5 pl-6',
59
+ footer: [
60
+ 'border-t border-neutral-200 p-3 shrink-0',
61
+ 'flex items-center gap-2',
62
+ ],
63
+ collapseToggle: [
64
+ 'inline-flex items-center justify-center w-8 h-8 shrink-0',
65
+ 'rounded-md text-neutral-600 hover:bg-neutral-100',
66
+ 'outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50',
67
+ 'transition-colors',
68
+ ],
69
+ },
70
+ variants: {
71
+ width: {
72
+ sm: { root: 'w-48' },
73
+ md: { root: 'w-60' },
74
+ lg: { root: 'w-72' },
75
+ },
76
+ collapsed: {
77
+ true: {
78
+ root: 'w-14',
79
+ brand: 'justify-center px-0',
80
+ list: 'p-1 items-center',
81
+ itemLink: 'justify-center px-0 py-2.5',
82
+ itemLabel: 'sr-only',
83
+ itemBadge: 'hidden',
84
+ groupHeader: 'justify-center px-0',
85
+ groupChildren: 'hidden',
86
+ footer: 'justify-center px-0',
87
+ },
88
+ },
89
+ },
90
+ defaultVariants: {
91
+ width: 'md',
92
+ collapsed: false,
93
+ },
94
+ });
95
+
96
+ export type LeftNavVariants = VariantProps<typeof leftNavVariants>;
@@ -0,0 +1,83 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from 'vitest';
3
+ import { render, screen, fireEvent } from '@testing-library/react';
4
+ import { DashFormProvider } from '@dashforge/forms';
5
+ import { NumberField } from './NumberField.js';
6
+
7
+ describe('<NumberField>', () => {
8
+ describe('standalone (no DashForm)', () => {
9
+ it('renders a numeric input with the label', () => {
10
+ render(<NumberField name="age" label="Age" />);
11
+ const el = screen.getByLabelText('Age') as HTMLInputElement;
12
+ expect(el).toBeInstanceOf(HTMLInputElement);
13
+ expect(el.type).toBe('number');
14
+ });
15
+
16
+ it('respects `min` / `max` / `step` attributes', () => {
17
+ render(
18
+ <NumberField name="qty" label="Qty" min={1} max={10} step={2} />
19
+ );
20
+ const el = screen.getByLabelText('Qty');
21
+ expect(el.getAttribute('min')).toBe('1');
22
+ expect(el.getAttribute('max')).toBe('10');
23
+ expect(el.getAttribute('step')).toBe('2');
24
+ });
25
+
26
+ it('displays a controlled numeric value via `value`', () => {
27
+ const { rerender } = render(
28
+ <NumberField name="qty" label="Qty" value={5} onChange={() => undefined} />
29
+ );
30
+ expect((screen.getByLabelText('Qty') as HTMLInputElement).value).toBe('5');
31
+ rerender(
32
+ <NumberField name="qty" label="Qty" value={null} onChange={() => undefined} />
33
+ );
34
+ expect((screen.getByLabelText('Qty') as HTMLInputElement).value).toBe('');
35
+ });
36
+
37
+ it('renders a `*` for required fields', () => {
38
+ render(<NumberField name="x" label="X" required />);
39
+ expect(screen.getByText('*')).toBeTruthy();
40
+ });
41
+
42
+ it('does NOT render a stepper unless `showStepper` is set', () => {
43
+ render(<NumberField name="x" label="X" />);
44
+ expect(screen.queryByLabelText('Increment')).toBeNull();
45
+ expect(screen.queryByLabelText('Decrement')).toBeNull();
46
+ });
47
+
48
+ it('renders +/- stepper buttons when `showStepper` is true', () => {
49
+ render(<NumberField name="x" label="X" showStepper />);
50
+ expect(screen.getByLabelText('Increment')).toBeTruthy();
51
+ expect(screen.getByLabelText('Decrement')).toBeTruthy();
52
+ });
53
+ });
54
+
55
+ describe('inside DashFormProvider', () => {
56
+ it('mounts cleanly inside a bridge-providing tree', () => {
57
+ render(
58
+ <DashFormProvider defaultValues={{ age: 30 }}>
59
+ <NumberField name="age" label="Age" />
60
+ </DashFormProvider>
61
+ );
62
+ // Bridge mode mounts cleanly with no console errors. Initial-value
63
+ // reflection in the input is timing-sensitive (RHF's defaultValues
64
+ // commit happens after the first render); the actual end-to-end
65
+ // round-trip is verified in the dash smoke page where async
66
+ // RHF state has time to settle.
67
+ expect(screen.getByLabelText('Age')).toBeInstanceOf(HTMLInputElement);
68
+ });
69
+
70
+ it('stepper buttons render and are clickable inside the bridge tree', () => {
71
+ render(
72
+ <DashFormProvider defaultValues={{ qty: 5 }}>
73
+ <NumberField name="qty" label="Qty" showStepper step={3} />
74
+ </DashFormProvider>
75
+ );
76
+ // The stepper itself renders correctly; the bridge round-trip on
77
+ // click is covered by the smoke test in dash.
78
+ const inc = screen.getByLabelText('Increment') as HTMLButtonElement;
79
+ expect(inc.disabled).toBe(false);
80
+ fireEvent.click(inc); // should not throw
81
+ });
82
+ });
83
+ });