@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,198 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { Engine } from '@dashforge/ui-core';
3
+ import type { AccessRequirement } from '@dashforge/rbac';
4
+ import type { AutocompleteVariants } from './autocomplete.variants.js';
5
+
6
+ /**
7
+ * Default `{ value, label, disabled? }` option shape.
8
+ *
9
+ * The simplest shape and the one used by the component when no
10
+ * `getOptionValue` / `getOptionLabel` props are provided. For richer
11
+ * option shapes (e.g., `{ id, name, country, ... }`) make the component
12
+ * generic by passing `<TOption>` and providing the accessors:
13
+ *
14
+ * ```tsx
15
+ * type User = { id: string; name: string; admin?: boolean };
16
+ *
17
+ * <Autocomplete<User>
18
+ * name="owner"
19
+ * options={users}
20
+ * getOptionValue={(u) => u.id}
21
+ * getOptionLabel={(u) => u.name}
22
+ * getOptionDisabled={(u) => !u.admin}
23
+ * />
24
+ * ```
25
+ */
26
+ export interface AutocompleteOption {
27
+ /** Value persisted on the form. */
28
+ value: string;
29
+ /** Display label (string or any React node). */
30
+ label: ReactNode;
31
+ /** Per-option explicit disable. */
32
+ disabled?: boolean;
33
+ }
34
+
35
+ export interface AutocompleteSlotProps {
36
+ root?: { className?: string };
37
+ label?: { className?: string };
38
+ requiredMark?: { className?: string };
39
+ inputWrapper?: { className?: string };
40
+ input?: { className?: string };
41
+ trigger?: { className?: string };
42
+ clearButton?: { className?: string };
43
+ popover?: { className?: string };
44
+ listBox?: { className?: string };
45
+ listItem?: { className?: string };
46
+ emptyState?: { className?: string };
47
+ helperText?: { className?: string };
48
+ errorText?: { className?: string };
49
+ /** Wrapper around the chip list (multi-select only). */
50
+ chipsList?: { className?: string };
51
+ /** Individual chip displayed in multi-select. */
52
+ chip?: { className?: string };
53
+ /** `×` button on a chip. */
54
+ chipRemove?: { className?: string };
55
+ }
56
+
57
+ /**
58
+ * Union of the value shapes `<Autocomplete>` accepts.
59
+ *
60
+ * - **Single-select (default)**: `string | null`
61
+ * - **Multi-select** (`multiple` prop): `string[] | null`
62
+ *
63
+ * `null` means "no selection" in both modes — a multi-select with no
64
+ * picks emits an empty array on first commit, then keeps emitting arrays.
65
+ */
66
+ export type AutocompleteValue = string | string[] | null;
67
+
68
+ /**
69
+ * Props for `<Autocomplete>`.
70
+ *
71
+ * Bridge-integrated single-select combobox with full ARIA combobox
72
+ * a11y, keyboard navigation, and Form Closure v1 error gating.
73
+ *
74
+ * Mirrors the MUI-side `@dashforge/ui/Autocomplete` at the bridge
75
+ * level — same `name` / `rules` / `visibleWhen` / `access` semantics,
76
+ * same StrictMode-safe unregister-on-unmount.
77
+ *
78
+ * @typeParam TOption Shape of an option. Defaults to
79
+ * `AutocompleteOption` (`{ value, label, disabled? }`).
80
+ * Pass `<TOption>` + `getOptionValue` / `getOptionLabel`
81
+ * for arbitrary domain types.
82
+ */
83
+ export interface AutocompleteProps<TOption = AutocompleteOption>
84
+ extends AutocompleteVariants {
85
+ name: string;
86
+ rules?: unknown;
87
+ options: TOption[];
88
+ label?: ReactNode;
89
+ helperText?: ReactNode;
90
+ required?: boolean;
91
+ error?: boolean;
92
+ disabled?: boolean;
93
+ /** Placeholder shown when the input is empty. */
94
+ placeholder?: string;
95
+ /** Engine predicate — field not rendered when it returns `false`. */
96
+ visibleWhen?: (engine: Engine) => boolean;
97
+ /** RBAC access requirement. */
98
+ access?: AccessRequirement;
99
+ /** Root className shortcut. */
100
+ sx?: string;
101
+ /** Per-slot className overrides. */
102
+ slotProps?: AutocompleteSlotProps;
103
+ /** Controlled value (form mode reads from the bridge if omitted). */
104
+ value?: AutocompleteValue;
105
+ /** Default value for uncontrolled mode (no-op in form mode). */
106
+ defaultValue?: AutocompleteValue;
107
+ /** Fires when the user picks an option (after bridge update in form mode). */
108
+ onValueChange?: (value: AutocompleteValue) => void;
109
+ /** Fallback text when no option matches the typed filter. */
110
+ emptyMessage?: string;
111
+
112
+ // ───── F5-A-bis: multi-select ─────
113
+
114
+ /**
115
+ * Multi-select mode. When `true`:
116
+ *
117
+ * - `value` / `defaultValue` accept `string[]` (or `null`).
118
+ * - Picks are toggled rather than replaced; the popover stays open.
119
+ * - Selected items render as chips inside the input wrapper.
120
+ * - Backspace at the start of an empty input removes the last chip.
121
+ * - The clear button (`×`) clears the entire selection.
122
+ */
123
+ multiple?: boolean;
124
+
125
+ // ───── F5-A-bis: free-solo ─────
126
+
127
+ /**
128
+ * Allow committing arbitrary strings (not just option values).
129
+ *
130
+ * When `true`:
131
+ * - **Enter** commits the typed text as the value when no option
132
+ * row is highlighted (or no rows match the filter).
133
+ * - **Blur** commits the typed text the same way, unless the text
134
+ * exactly matches an existing option's label (in which case the
135
+ * option's value snaps in).
136
+ * - In multi mode, each free-solo commit adds a new chip whose key
137
+ * and label are the typed string.
138
+ *
139
+ * Free-solo is independent of `multiple` — works in both modes.
140
+ */
141
+ freeSolo?: boolean;
142
+
143
+ // ───── F5-A-bis: async runtime options ─────
144
+
145
+ /**
146
+ * Async option loader. Called (debounced by `loadDebounceMs`) every
147
+ * time the user types in the input. Results replace the static
148
+ * `options` prop while the user is actively filtering.
149
+ *
150
+ * Return a promise that resolves with the new option list. Reject /
151
+ * throw to surface a generic empty state.
152
+ *
153
+ * @example
154
+ * ```tsx
155
+ * <Autocomplete
156
+ * name="user"
157
+ * options={[]}
158
+ * loadOptions={async (q) => {
159
+ * const res = await fetch(`/api/users?q=${q}`);
160
+ * return res.ok ? res.json() : [];
161
+ * }}
162
+ * />
163
+ * ```
164
+ */
165
+ loadOptions?: (query: string) => Promise<TOption[]>;
166
+ /**
167
+ * Debounce window (ms) for `loadOptions`. The loader fires after the
168
+ * user stops typing for this long.
169
+ * @default 250
170
+ */
171
+ loadDebounceMs?: number;
172
+ /** Text displayed in the popover while a fetch is in flight. */
173
+ loadingMessage?: ReactNode;
174
+
175
+ // ───── Generic option accessors (F5-A-bis) ─────
176
+
177
+ /**
178
+ * Extract the persistable value (string) from an option.
179
+ * @default `(o) => (o as AutocompleteOption).value`
180
+ */
181
+ getOptionValue?: (option: TOption) => string;
182
+ /**
183
+ * Extract the display label from an option.
184
+ * @default `(o) => (o as AutocompleteOption).label`
185
+ */
186
+ getOptionLabel?: (option: TOption) => ReactNode;
187
+ /**
188
+ * Per-option disabled flag.
189
+ * @default `(o) => Boolean((o as AutocompleteOption).disabled)`
190
+ */
191
+ getOptionDisabled?: (option: TOption) => boolean;
192
+ /**
193
+ * Unique React key for an option. Defaults to `getOptionValue`.
194
+ * Override if your `value` strings can collide across distinct
195
+ * option records (rare).
196
+ */
197
+ getOptionKey?: (option: TOption) => string;
198
+ }
@@ -0,0 +1,135 @@
1
+ import { tv, type VariantProps } from 'tailwind-variants';
2
+
3
+ /**
4
+ * Tailwind-variants recipe for `<Autocomplete>` with slots.
5
+ *
6
+ * Slots:
7
+ * - `root` — outer wrapper (label + combobox + helper/error)
8
+ * - `label` — text label above the input
9
+ * - `requiredMark` — red `*` for required fields
10
+ * - `inputWrapper` — surface holding the input + clear/trigger buttons
11
+ * - `input` — the typeable text input
12
+ * - `trigger` — caret / chevron button that opens the popover
13
+ * - `clearButton` — `×` button to clear the current selection
14
+ * - `popover` — floating panel containing the option list
15
+ * - `listBox` — list element wrapping the options
16
+ * - `listItem` — single option row
17
+ * - `emptyState` — fallback when no options match the filter
18
+ * - `helperText` — descriptive line below the input
19
+ * - `errorText` — semantic counterpart for error mode
20
+ * - `chipsList` — wrapper around selected chips (multi-select only)
21
+ * - `chip` — individual selected chip
22
+ * - `chipRemove` — `×` button on a chip
23
+ */
24
+ export const autocompleteVariants = tv({
25
+ slots: {
26
+ root: 'flex',
27
+ label: 'block text-sm font-medium text-neutral-900 mb-1',
28
+ requiredMark: 'text-danger-500 ml-0.5',
29
+ inputWrapper: [
30
+ 'relative flex items-center',
31
+ 'rounded-md border bg-neutral-50',
32
+ 'border-neutral-300',
33
+ 'transition-colors',
34
+ 'focus-within:border-primary-500 focus-within:ring-2 focus-within:ring-primary-500/30',
35
+ ],
36
+ input: [
37
+ 'block w-full bg-transparent outline-none',
38
+ 'text-neutral-900 placeholder:text-neutral-400',
39
+ 'disabled:cursor-not-allowed disabled:opacity-60',
40
+ 'read-only:cursor-default',
41
+ ],
42
+ trigger: [
43
+ 'flex items-center justify-center shrink-0 px-2',
44
+ 'text-neutral-600 hover:text-neutral-900',
45
+ 'disabled:cursor-not-allowed disabled:opacity-40',
46
+ 'transition-colors',
47
+ ],
48
+ clearButton: [
49
+ 'flex items-center justify-center shrink-0 px-2',
50
+ 'text-neutral-500 hover:text-neutral-900',
51
+ 'transition-colors',
52
+ ],
53
+ popover: [
54
+ 'z-50 mt-1 max-h-64 overflow-auto',
55
+ 'rounded-md border bg-neutral-50 border-neutral-300 shadow-lg',
56
+ 'min-w-[var(--trigger-width)]',
57
+ ],
58
+ listBox: 'outline-none py-1',
59
+ listItem: [
60
+ 'cursor-pointer select-none px-3 py-2 text-sm text-neutral-900',
61
+ 'outline-none',
62
+ 'data-[focused]:bg-primary-50 data-[focused]:text-primary-900',
63
+ 'data-[selected]:bg-primary-100 data-[selected]:text-primary-900 data-[selected]:font-medium',
64
+ 'data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed',
65
+ ],
66
+ emptyState: 'px-3 py-2 text-sm text-neutral-500',
67
+ helperText: 'mt-1 text-sm text-neutral-600',
68
+ errorText: 'mt-1 text-sm text-danger-600',
69
+ chipsList: 'flex flex-wrap items-center gap-1 px-1 py-1',
70
+ chip: [
71
+ 'inline-flex items-center gap-1 shrink-0',
72
+ 'rounded-md bg-primary-100 text-primary-900',
73
+ 'px-2 py-0.5 text-xs font-medium',
74
+ 'border border-primary-200',
75
+ ],
76
+ chipRemove: [
77
+ 'inline-flex items-center justify-center',
78
+ 'rounded-sm w-4 h-4 text-primary-700',
79
+ 'hover:bg-primary-200 hover:text-primary-900',
80
+ 'transition-colors',
81
+ ],
82
+ },
83
+ variants: {
84
+ size: {
85
+ sm: {
86
+ inputWrapper: 'h-8',
87
+ input: 'text-sm px-2',
88
+ trigger: 'h-8 text-xs',
89
+ clearButton: 'h-8',
90
+ },
91
+ md: {
92
+ inputWrapper: 'h-10',
93
+ input: 'text-base px-3',
94
+ trigger: 'h-10 text-sm',
95
+ clearButton: 'h-10',
96
+ },
97
+ lg: {
98
+ inputWrapper: 'h-12',
99
+ input: 'text-lg px-4',
100
+ trigger: 'h-12 text-base',
101
+ clearButton: 'h-12',
102
+ },
103
+ },
104
+ layout: {
105
+ stacked: { root: 'flex-col' },
106
+ inline: { root: 'flex-row items-start gap-3', label: 'mb-0 pt-2 whitespace-nowrap shrink-0' },
107
+ },
108
+ error: {
109
+ true: {
110
+ inputWrapper:
111
+ 'border-danger-500 focus-within:border-danger-500 focus-within:ring-danger-500/30',
112
+ },
113
+ },
114
+ fullWidth: {
115
+ true: {
116
+ root: 'w-full',
117
+ inputWrapper: 'w-full',
118
+ },
119
+ },
120
+ disabled: {
121
+ true: {
122
+ inputWrapper: 'opacity-60 cursor-not-allowed bg-neutral-200',
123
+ },
124
+ },
125
+ },
126
+ defaultVariants: {
127
+ size: 'md',
128
+ layout: 'stacked',
129
+ error: false,
130
+ fullWidth: false,
131
+ disabled: false,
132
+ },
133
+ });
134
+
135
+ export type AutocompleteVariants = VariantProps<typeof autocompleteVariants>;
@@ -0,0 +1,125 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Performance + re-render budget tests for `<Breadcrumbs>`.
4
+ *
5
+ * Layout components are typically near the top of the tree, so a
6
+ * regression here ripples through everything below. Guardrails:
7
+ *
8
+ * 1. Mount cost stays bounded even with very long trails.
9
+ * 2. Re-rendering the parent with the SAME items reference does not
10
+ * re-do the truncation memo work (verifies `useMemo` deps).
11
+ * 3. Truncation does not allocate per-render arrays that would defeat
12
+ * the React keyed-reuse heuristics.
13
+ */
14
+ import * as React from 'react';
15
+ import { describe, it, expect, afterEach } from 'vitest';
16
+ import { render, cleanup } from '@testing-library/react';
17
+ import { Breadcrumbs } from './Breadcrumbs.js';
18
+ import type { BreadcrumbItem } from './breadcrumbs.types.js';
19
+
20
+ void React;
21
+ afterEach(() => cleanup());
22
+
23
+ function makeTrail(n: number): BreadcrumbItem[] {
24
+ return Array.from({ length: n }, (_, i) => ({
25
+ id: `c${i}`,
26
+ label: `Crumb ${i}`,
27
+ href: i === n - 1 ? undefined : `/c${i}`,
28
+ }));
29
+ }
30
+
31
+ function withRenderCounter<P>(
32
+ Component: React.ComponentType<P>,
33
+ counterRef: { count: number }
34
+ ): React.FC<P> {
35
+ return function Counted(props: P) {
36
+ counterRef.count++;
37
+ return <Component {...(props as P & object)} />;
38
+ };
39
+ }
40
+
41
+ describe('<Breadcrumbs> performance', () => {
42
+ it('mounts a 50-crumb trail under 50ms', () => {
43
+ const trail = makeTrail(50);
44
+ const t0 = performance.now();
45
+ render(<Breadcrumbs items={trail} />);
46
+ const t1 = performance.now();
47
+ expect(t1 - t0).toBeLessThan(50);
48
+ });
49
+
50
+ it('mounts a 1000-crumb (collapsed) trail under 100ms', () => {
51
+ const trail = makeTrail(1000);
52
+ const t0 = performance.now();
53
+ render(<Breadcrumbs items={trail} maxItems={5} />);
54
+ const t1 = performance.now();
55
+ expect(t1 - t0).toBeLessThan(100);
56
+ });
57
+ });
58
+
59
+ describe('<Breadcrumbs> re-render budget', () => {
60
+ it('renders at most 2 times on mount', () => {
61
+ const counter = { count: 0 };
62
+ const Counted = withRenderCounter(Breadcrumbs, counter);
63
+ render(<Counted items={makeTrail(5)} />);
64
+ expect(counter.count).toBeLessThanOrEqual(2);
65
+ });
66
+
67
+ it('re-renders exactly once per prop change to items', () => {
68
+ const counter = { count: 0 };
69
+ const Counted = withRenderCounter(Breadcrumbs, counter);
70
+ const a = makeTrail(3);
71
+ const b = makeTrail(4);
72
+ const c = makeTrail(5);
73
+ const { rerender } = render(<Counted items={a} />);
74
+ const baseline = counter.count;
75
+ rerender(<Counted items={b} />);
76
+ rerender(<Counted items={c} />);
77
+ // Two prop changes → up to two renders (plus tolerance).
78
+ expect(counter.count - baseline).toBeLessThanOrEqual(3);
79
+ });
80
+
81
+ it('does NOT re-render siblings when only Breadcrumbs items change', () => {
82
+ // Truncation memoization shouldn't leak through React.memo'd
83
+ // siblings, but at minimum it must not trigger sibling renders
84
+ // when the parent re-renders for an unrelated reason.
85
+ const sibCount = { count: 0 };
86
+ const Sibling = () => {
87
+ sibCount.count++;
88
+ return <div data-testid="sib" />;
89
+ };
90
+ const Tree = ({ items }: { items: BreadcrumbItem[] }) => (
91
+ <div>
92
+ <Breadcrumbs items={items} />
93
+ <Sibling />
94
+ </div>
95
+ );
96
+ const trail = makeTrail(5);
97
+ const { rerender } = render(<Tree items={trail} />);
98
+ const baseline = sibCount.count;
99
+ // Re-render the parent with the SAME items reference — Sibling
100
+ // should still re-render (parent re-rendered) but only ONCE per
101
+ // parent re-render, no extra renders triggered by Breadcrumbs.
102
+ rerender(<Tree items={trail} />);
103
+ rerender(<Tree items={trail} />);
104
+ expect(sibCount.count - baseline).toBeLessThanOrEqual(2);
105
+ });
106
+
107
+ it('truncation logic is stable: same items + same maxItems => same DOM ids', () => {
108
+ // If `useMemo` is well-keyed, the rendered <li> elements (with
109
+ // stable keys from item.id) should be identical DOM nodes across
110
+ // unrelated re-renders. Detects an accidental "always new array"
111
+ // refactor that would invalidate keys.
112
+ const items = makeTrail(10);
113
+ const { rerender, container } = render(
114
+ <Breadcrumbs items={items} maxItems={4} />
115
+ );
116
+ const firstSnapshot = Array.from(container.querySelectorAll('li')).map(
117
+ (li) => li.outerHTML
118
+ );
119
+ rerender(<Breadcrumbs items={items} maxItems={4} />);
120
+ const secondSnapshot = Array.from(container.querySelectorAll('li')).map(
121
+ (li) => li.outerHTML
122
+ );
123
+ expect(secondSnapshot).toEqual(firstSnapshot);
124
+ });
125
+ });
@@ -0,0 +1,231 @@
1
+ // @vitest-environment jsdom
2
+ import * as React from 'react';
3
+ import { describe, it, expect, vi, afterEach } from 'vitest';
4
+ import {
5
+ render,
6
+ screen,
7
+ fireEvent,
8
+ cleanup,
9
+ } from '@testing-library/react';
10
+ import { Breadcrumbs } from './Breadcrumbs.js';
11
+ import type { BreadcrumbItem } from './breadcrumbs.types.js';
12
+
13
+ // Keep the React import alive for the classic JSX transform — when this
14
+ // file is run in environments where the bundler tree-shakes
15
+ // `import * as React`, the JSX call sites would lose the global.
16
+ void React;
17
+
18
+ // Auto-cleanup is not reliably registered when test-library is loaded
19
+ // before vitest globals are wired up — register the unmount step
20
+ // explicitly so back-to-back `render()` calls don't accumulate trees.
21
+ afterEach(() => {
22
+ cleanup();
23
+ });
24
+
25
+ const trail: BreadcrumbItem[] = [
26
+ { id: 'home', label: 'Home', href: '/' },
27
+ { id: 'settings', label: 'Settings', href: '/settings' },
28
+ { id: 'profile', label: 'Profile' },
29
+ ];
30
+
31
+ describe('<Breadcrumbs>', () => {
32
+ describe('basic rendering', () => {
33
+ it('renders a nav with role + accessible name', () => {
34
+ render(<Breadcrumbs items={trail} />);
35
+ const nav = screen.getByRole('navigation', { name: 'Breadcrumb' });
36
+ expect(nav).toBeTruthy();
37
+ });
38
+
39
+ it('renders an <ol> with one <li> per item', () => {
40
+ const { container } = render(<Breadcrumbs items={trail} />);
41
+ expect(container.querySelectorAll('li')).toHaveLength(3);
42
+ });
43
+
44
+ it('returns null for an empty items array', () => {
45
+ const { container } = render(<Breadcrumbs items={[]} />);
46
+ expect(container.firstChild).toBeNull();
47
+ });
48
+
49
+ it('marks the last crumb as aria-current="page" by default', () => {
50
+ render(<Breadcrumbs items={trail} />);
51
+ const current = screen.getByText('Profile').closest('span');
52
+ expect(current?.getAttribute('aria-current')).toBe('page');
53
+ });
54
+
55
+ it('honours an explicit `current: true` flag', () => {
56
+ const custom: BreadcrumbItem[] = [
57
+ { id: 'a', label: 'A', href: '/a' },
58
+ { id: 'b', label: 'B', current: true },
59
+ { id: 'c', label: 'C', href: '/c' },
60
+ ];
61
+ render(<Breadcrumbs items={custom} />);
62
+ const bElement = screen.getByText('B').closest('span');
63
+ expect(bElement?.getAttribute('aria-current')).toBe('page');
64
+ });
65
+ });
66
+
67
+ describe('separator', () => {
68
+ it('inserts the default `/` between crumbs', () => {
69
+ const { container } = render(<Breadcrumbs items={trail} />);
70
+ const separators = container.querySelectorAll('[aria-hidden="true"]');
71
+ // 2 separators between 3 crumbs.
72
+ expect(separators).toHaveLength(2);
73
+ expect(separators[0].textContent).toBe('/');
74
+ });
75
+
76
+ it('accepts a custom separator (string)', () => {
77
+ const { container } = render(
78
+ <Breadcrumbs items={trail} separator=">" />
79
+ );
80
+ const separators = container.querySelectorAll('[aria-hidden="true"]');
81
+ expect(separators[0].textContent).toBe('>');
82
+ });
83
+
84
+ it('accepts a custom separator (ReactNode)', () => {
85
+ render(
86
+ <Breadcrumbs
87
+ items={trail}
88
+ separator={<span data-testid="sep">»</span>}
89
+ />
90
+ );
91
+ // Two separators ⇒ two custom nodes.
92
+ expect(screen.getAllByTestId('sep')).toHaveLength(2);
93
+ });
94
+ });
95
+
96
+ describe('navigation links', () => {
97
+ it('renders a non-current crumb with href as a real link', () => {
98
+ render(<Breadcrumbs items={trail} />);
99
+ const link = screen.getByRole('link', { name: 'Home' });
100
+ expect(link.getAttribute('href')).toBe('/');
101
+ });
102
+
103
+ it('renders a crumb without href as a non-link span', () => {
104
+ const noHref: BreadcrumbItem[] = [
105
+ { id: 'a', label: 'A' },
106
+ { id: 'b', label: 'B' },
107
+ ];
108
+ render(<Breadcrumbs items={noHref} />);
109
+ expect(screen.queryByRole('link')).toBeNull();
110
+ });
111
+
112
+ it('fires onClick on link crumbs', () => {
113
+ const onClick = vi.fn();
114
+ const itemsWithClick: BreadcrumbItem[] = [
115
+ { id: 'a', label: 'A', href: '/a', onClick },
116
+ { id: 'b', label: 'B' },
117
+ ];
118
+ render(<Breadcrumbs items={itemsWithClick} />);
119
+ fireEvent.click(screen.getByText('A'));
120
+ expect(onClick).toHaveBeenCalled();
121
+ });
122
+
123
+ it('renders a disabled crumb as a non-interactive span', () => {
124
+ const dis: BreadcrumbItem[] = [
125
+ { id: 'a', label: 'A', href: '/a', disabled: true },
126
+ { id: 'b', label: 'B' },
127
+ ];
128
+ render(<Breadcrumbs items={dis} />);
129
+ // 'A' must not be a link (disabled overrides interactivity).
130
+ expect(screen.queryByRole('link', { name: 'A' })).toBeNull();
131
+ const span = screen.getByText('A').closest('span');
132
+ expect(span?.getAttribute('aria-disabled')).toBe('true');
133
+ });
134
+ });
135
+
136
+ describe('truncation', () => {
137
+ const long: BreadcrumbItem[] = Array.from({ length: 6 }, (_, i) => ({
138
+ id: `c${i}`,
139
+ label: `Crumb ${i}`,
140
+ href: i === 5 ? undefined : `/c${i}`,
141
+ }));
142
+
143
+ it('collapses the middle when items > maxItems', () => {
144
+ const { container } = render(
145
+ <Breadcrumbs items={long} maxItems={3} />
146
+ );
147
+ // 1 (head) + 1 ellipsis + 1 (tail) = 3 <li>s.
148
+ expect(container.querySelectorAll('li')).toHaveLength(3);
149
+ expect(
150
+ container.querySelector('[aria-label="More breadcrumbs"]')
151
+ ).toBeTruthy();
152
+ });
153
+
154
+ it('respects itemsBeforeCollapse / itemsAfterCollapse', () => {
155
+ const { container } = render(
156
+ <Breadcrumbs
157
+ items={long}
158
+ maxItems={4}
159
+ itemsBeforeCollapse={2}
160
+ itemsAfterCollapse={2}
161
+ />
162
+ );
163
+ // 2 head + 1 ellipsis + 2 tail = 5 <li>s.
164
+ // (maxItems is the visible-budget hint; once collapsed the
165
+ // before/after counts drive the final visible count.)
166
+ expect(container.querySelectorAll('li')).toHaveLength(5);
167
+ });
168
+
169
+ it('does NOT collapse when items.length <= maxItems', () => {
170
+ const { container } = render(
171
+ <Breadcrumbs items={trail} maxItems={10} />
172
+ );
173
+ expect(container.querySelectorAll('li')).toHaveLength(3);
174
+ expect(
175
+ container.querySelector('[aria-label="More breadcrumbs"]')
176
+ ).toBeNull();
177
+ });
178
+
179
+ it('does NOT collapse when maxItems is 0 (default)', () => {
180
+ const { container } = render(<Breadcrumbs items={long} />);
181
+ expect(container.querySelectorAll('li')).toHaveLength(6);
182
+ });
183
+
184
+ it('preserves the LAST crumb (current page) when collapsing', () => {
185
+ render(<Breadcrumbs items={long} maxItems={3} />);
186
+ expect(screen.getByText('Crumb 0')).toBeTruthy(); // first
187
+ expect(screen.getByText('Crumb 5')).toBeTruthy(); // last/current
188
+ expect(screen.queryByText('Crumb 2')).toBeNull(); // middle, collapsed
189
+ });
190
+ });
191
+
192
+ describe('polymorphic link component', () => {
193
+ it('uses the provided linkComponent instead of <a>', () => {
194
+ const MyLink = vi.fn(
195
+ ({ children, ...rest }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
196
+ <a data-testid="custom-link" {...rest}>
197
+ {children}
198
+ </a>
199
+ )
200
+ );
201
+ render(<Breadcrumbs items={trail} linkComponent={MyLink} />);
202
+ // Two interactive crumbs ⇒ MyLink called twice.
203
+ expect(MyLink).toHaveBeenCalledTimes(2);
204
+ expect(screen.getAllByTestId('custom-link')).toHaveLength(2);
205
+ });
206
+ });
207
+
208
+ describe('size variants', () => {
209
+ it('renders sm / md / lg without throwing', () => {
210
+ const sizes = ['sm', 'md', 'lg'] as const;
211
+ sizes.forEach((s) => {
212
+ const { unmount } = render(
213
+ <Breadcrumbs items={trail} size={s} />
214
+ );
215
+ expect(
216
+ screen.getByRole('navigation', { name: 'Breadcrumb' })
217
+ ).toBeTruthy();
218
+ unmount();
219
+ });
220
+ });
221
+ });
222
+
223
+ describe('aria-label override', () => {
224
+ it('applies a custom aria-label to the nav landmark', () => {
225
+ render(<Breadcrumbs items={trail} ariaLabel="Sentiero corrente" />);
226
+ expect(
227
+ screen.getByRole('navigation', { name: 'Sentiero corrente' })
228
+ ).toBeTruthy();
229
+ });
230
+ });
231
+ });