@justanarthur/modo-ui 0.5.0 → 0.6.0-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (461) hide show
  1. package/DESIGN.md +608 -0
  2. package/README.md +61 -39
  3. package/blocks/sidebar-app/examples.mdx +33 -0
  4. package/blocks/sidebar-app/index.tsx +218 -0
  5. package/blocks/sidebar-app/inset-topbar.tsx +25 -0
  6. package/blocks/sidebar-app/menu-grid.ts +26 -0
  7. package/blocks/sidebar-app/nav-data.ts +36 -0
  8. package/blocks/sidebar-app/search-field.tsx +48 -0
  9. package/blocks/sidebar-app/user-footer.tsx +67 -0
  10. package/blocks/sidebar-app/workspace-header.tsx +129 -0
  11. package/components/AI/input-message/examples.mdx +124 -0
  12. package/components/AI/input-message/file-thumbnail.tsx +173 -0
  13. package/components/AI/input-message/index.tsx +1383 -0
  14. package/components/AI/thinking-indicator/examples.mdx +27 -0
  15. package/components/AI/thinking-indicator/index.tsx +210 -0
  16. package/components/accordion/examples.mdx +148 -0
  17. package/components/accordion/index.tsx +1034 -0
  18. package/components/avatar/examples.mdx +95 -0
  19. package/components/avatar/index.tsx +129 -0
  20. package/components/badge/change-demo.tsx +29 -0
  21. package/components/badge/examples.mdx +120 -0
  22. package/components/badge/index.tsx +225 -0
  23. package/components/badge/variants.ts +73 -0
  24. package/components/button/examples.mdx +164 -0
  25. package/components/button/index.tsx +416 -0
  26. package/components/button/loading-demo.tsx +25 -0
  27. package/components/button/variants.ts +166 -0
  28. package/components/card/examples.mdx +212 -0
  29. package/components/card/index.tsx +1037 -0
  30. package/components/copy/examples.mdx +87 -0
  31. package/components/copy/index.tsx +482 -0
  32. package/components/inputs/checkbox/examples.mdx +83 -0
  33. package/components/inputs/checkbox/index.tsx +611 -0
  34. package/components/inputs/checkbox/select-all-demo.tsx +48 -0
  35. package/components/inputs/color-picker/examples.mdx +72 -0
  36. package/components/inputs/color-picker/index.tsx +1955 -0
  37. package/components/inputs/color-picker/variants.ts +401 -0
  38. package/components/inputs/combobox/examples.mdx +273 -0
  39. package/components/inputs/combobox/index.tsx +1436 -0
  40. package/components/inputs/combobox/remote-demo.tsx +101 -0
  41. package/components/inputs/date-time/calendar/examples.mdx +34 -0
  42. package/components/inputs/date-time/calendar/index.tsx +351 -0
  43. package/components/inputs/date-time/calendar/use-month-window.ts +163 -0
  44. package/components/inputs/date-time/date-picker/examples.mdx +166 -0
  45. package/components/inputs/date-time/date-picker/index.tsx +385 -0
  46. package/components/inputs/date-time/date-picker/masked-date-input.tsx +336 -0
  47. package/components/inputs/date-time/date-range-picker/examples.mdx +29 -0
  48. package/components/inputs/date-time/date-range-picker/index.tsx +217 -0
  49. package/components/inputs/date-time/duration-picker/examples.mdx +16 -0
  50. package/components/inputs/date-time/duration-picker/index.tsx +189 -0
  51. package/components/inputs/date-time/month-picker/examples.mdx +16 -0
  52. package/components/inputs/date-time/month-picker/index.tsx +190 -0
  53. package/components/inputs/date-time/picker-shell.tsx +395 -0
  54. package/components/inputs/date-time/time-columns.tsx +91 -0
  55. package/components/inputs/date-time/time-picker/examples.mdx +26 -0
  56. package/components/inputs/date-time/time-picker/index.tsx +166 -0
  57. package/components/inputs/field/examples.mdx +113 -0
  58. package/components/inputs/field/index.tsx +267 -0
  59. package/components/inputs/file-dropzone/examples.mdx +73 -0
  60. package/components/inputs/file-dropzone/file-row.tsx +201 -0
  61. package/components/inputs/file-dropzone/index.tsx +296 -0
  62. package/components/inputs/file-dropzone/upload-demo.tsx +67 -0
  63. package/components/inputs/file-dropzone/variants.ts +94 -0
  64. package/components/inputs/input/examples.mdx +135 -0
  65. package/components/inputs/input/index.tsx +494 -0
  66. package/components/inputs/input/password-demo.tsx +30 -0
  67. package/components/inputs/label/examples.mdx +67 -0
  68. package/components/inputs/label/index.tsx +85 -0
  69. package/components/inputs/number-input/examples.mdx +56 -0
  70. package/components/inputs/number-input/index.tsx +214 -0
  71. package/components/inputs/radio/examples.mdx +77 -0
  72. package/components/inputs/radio/index.tsx +636 -0
  73. package/components/inputs/search/examples.mdx +74 -0
  74. package/components/inputs/search/index.tsx +333 -0
  75. package/components/inputs/select/examples.mdx +218 -0
  76. package/components/inputs/select/index.tsx +862 -0
  77. package/components/inputs/select/variants.ts +32 -0
  78. package/components/inputs/slider/examples.mdx +127 -0
  79. package/components/inputs/slider/index.tsx +1725 -0
  80. package/components/inputs/switch/examples.mdx +94 -0
  81. package/components/inputs/switch/index.tsx +504 -0
  82. package/components/inputs/textarea/examples.mdx +88 -0
  83. package/components/inputs/textarea/index.tsx +161 -0
  84. package/components/navigation/navigation-menu/examples.mdx +109 -0
  85. package/components/navigation/navigation-menu/index.tsx +613 -0
  86. package/components/navigation/pagination/examples.mdx +64 -0
  87. package/components/navigation/pagination/index.tsx +395 -0
  88. package/components/navigation/sidebar/examples.mdx +319 -0
  89. package/components/navigation/sidebar/index.tsx +453 -0
  90. package/components/navigation/sidebar/sidebar-core.tsx +1408 -0
  91. package/components/navigation/sidebar/sidebar-menu.tsx +1495 -0
  92. package/components/navigation/sidebar/sidebar.css +74 -0
  93. package/components/navigation/sidebar/variants.ts +55 -0
  94. package/components/navigation/tabs/examples.mdx +89 -0
  95. package/components/navigation/tabs/index.tsx +494 -0
  96. package/components/navigation/tabs-subtle/examples.mdx +49 -0
  97. package/components/navigation/tabs-subtle/index.tsx +512 -0
  98. package/components/overlays/alert-dialog/examples.mdx +123 -0
  99. package/components/overlays/alert-dialog/index.tsx +258 -0
  100. package/components/overlays/alert-dialog/pending-demo.tsx +36 -0
  101. package/components/overlays/command/examples.mdx +113 -0
  102. package/components/overlays/command/index.tsx +416 -0
  103. package/components/overlays/context-menu/examples.mdx +56 -0
  104. package/components/overlays/context-menu/index.tsx +279 -0
  105. package/components/overlays/dialog/examples.mdx +144 -0
  106. package/components/overlays/dialog/index.tsx +366 -0
  107. package/components/overlays/dropdown/dropdown-search.tsx +395 -0
  108. package/components/overlays/dropdown/examples.mdx +294 -0
  109. package/components/overlays/dropdown/index.tsx +1168 -0
  110. package/components/overlays/dropdown/menu-item.tsx +464 -0
  111. package/components/overlays/dropdown/menu-parts.tsx +660 -0
  112. package/components/overlays/popover/anchored-demo.tsx +59 -0
  113. package/components/overlays/popover/examples.mdx +180 -0
  114. package/components/overlays/popover/index.tsx +315 -0
  115. package/components/overlays/preview-card/examples.mdx +61 -0
  116. package/components/overlays/preview-card/index.tsx +204 -0
  117. package/components/overlays/sheet/examples.mdx +89 -0
  118. package/components/overlays/sheet/index.tsx +247 -0
  119. package/components/overlays/toast/examples.mdx +68 -0
  120. package/components/overlays/toast/global-demo.tsx +72 -0
  121. package/components/overlays/toast/index.tsx +474 -0
  122. package/components/overlays/toast/manager.ts +193 -0
  123. package/components/overlays/tooltip/examples.mdx +106 -0
  124. package/components/overlays/tooltip/index.tsx +519 -0
  125. package/components/progress/examples.mdx +59 -0
  126. package/components/progress/index.tsx +174 -0
  127. package/components/progress/progress.css +49 -0
  128. package/components/progress/step-demo.tsx +27 -0
  129. package/components/table/examples.mdx +184 -0
  130. package/components/table/index.tsx +510 -0
  131. package/components/table/sort-demo.tsx +61 -0
  132. package/dist/base.css +100 -0
  133. package/dist/blocks/sidebar-app/index.js +39 -26
  134. package/dist/components/AI/input-message/index.js +22 -16
  135. package/dist/components/AI/thinking-indicator/index.js +9 -6
  136. package/dist/components/accordion/index.js +16 -13
  137. package/dist/components/avatar/index.d.ts +46 -0
  138. package/dist/components/avatar/index.js +18 -0
  139. package/dist/components/badge/index.d.ts +18 -11
  140. package/dist/components/badge/index.js +11 -7
  141. package/dist/components/badge/variants.d.ts +33 -0
  142. package/dist/components/badge/variants.js +10 -0
  143. package/dist/components/button/index.d.ts +53 -17
  144. package/dist/components/button/index.js +10 -6
  145. package/dist/components/button/variants.d.ts +39 -0
  146. package/dist/components/button/variants.js +8 -0
  147. package/dist/components/card/index.d.ts +16 -3
  148. package/dist/components/card/index.js +16 -11
  149. package/dist/components/copy/index.js +13 -9
  150. package/dist/components/inputs/checkbox/index.d.ts +28 -9
  151. package/dist/components/inputs/checkbox/index.js +17 -13
  152. package/dist/components/inputs/color-picker/index.d.ts +13 -13
  153. package/dist/components/inputs/color-picker/index.js +25 -18
  154. package/dist/components/inputs/color-picker/variants.d.ts +61 -0
  155. package/dist/components/inputs/color-picker/variants.js +28 -0
  156. package/dist/components/inputs/combobox/index.d.ts +60 -17
  157. package/dist/components/inputs/combobox/index.js +26 -18
  158. package/dist/components/inputs/date-time/calendar/index.js +14 -11
  159. package/dist/components/inputs/date-time/date-picker/index.d.ts +53 -11
  160. package/dist/components/inputs/date-time/date-picker/index.js +33 -25
  161. package/dist/components/inputs/date-time/date-range-picker/index.d.ts +3 -3
  162. package/dist/components/inputs/date-time/date-range-picker/index.js +32 -24
  163. package/dist/components/inputs/date-time/duration-picker/index.d.ts +3 -3
  164. package/dist/components/inputs/date-time/duration-picker/index.js +32 -24
  165. package/dist/components/inputs/date-time/month-picker/index.d.ts +3 -3
  166. package/dist/components/inputs/date-time/month-picker/index.js +31 -23
  167. package/dist/components/inputs/date-time/time-picker/index.d.ts +3 -3
  168. package/dist/components/inputs/date-time/time-picker/index.js +32 -24
  169. package/dist/components/inputs/field/index.d.ts +116 -0
  170. package/dist/components/inputs/field/index.js +28 -0
  171. package/dist/components/inputs/file-dropzone/index.d.ts +165 -0
  172. package/dist/components/inputs/file-dropzone/index.js +32 -0
  173. package/dist/components/inputs/file-dropzone/variants.d.ts +35 -0
  174. package/dist/components/inputs/file-dropzone/variants.js +12 -0
  175. package/dist/components/inputs/input/index.d.ts +95 -13
  176. package/dist/components/inputs/input/index.js +17 -9
  177. package/dist/components/inputs/label/index.d.ts +32 -0
  178. package/dist/components/inputs/label/index.js +11 -0
  179. package/dist/components/inputs/number-input/index.d.ts +84 -0
  180. package/dist/components/inputs/number-input/index.js +20 -0
  181. package/dist/components/inputs/radio/index.d.ts +20 -5
  182. package/dist/components/inputs/radio/index.js +15 -12
  183. package/dist/components/inputs/search/index.js +25 -18
  184. package/dist/components/inputs/select/index.d.ts +41 -11
  185. package/dist/components/inputs/select/index.js +27 -14
  186. package/dist/components/inputs/select/variants.d.ts +5 -0
  187. package/dist/components/inputs/select/variants.js +6 -0
  188. package/dist/components/inputs/slider/index.js +7 -5
  189. package/dist/components/inputs/switch/index.d.ts +8 -2
  190. package/dist/components/inputs/switch/index.js +14 -12
  191. package/dist/components/inputs/textarea/index.d.ts +4 -0
  192. package/dist/components/inputs/textarea/index.js +8 -6
  193. package/dist/components/navigation/navigation-menu/index.d.ts +114 -0
  194. package/dist/components/navigation/navigation-menu/index.js +40 -0
  195. package/dist/components/navigation/pagination/index.d.ts +73 -0
  196. package/dist/components/navigation/pagination/index.js +39 -0
  197. package/dist/components/navigation/sidebar/index.d.ts +44 -21
  198. package/dist/components/navigation/sidebar/index.js +41 -31
  199. package/dist/components/navigation/sidebar/variants.d.ts +25 -0
  200. package/dist/components/navigation/sidebar/variants.js +28 -0
  201. package/dist/components/navigation/tabs/index.d.ts +8 -2
  202. package/dist/components/navigation/tabs/index.js +14 -10
  203. package/dist/components/navigation/tabs-subtle/index.js +12 -10
  204. package/dist/components/overlays/alert-dialog/index.d.ts +121 -15
  205. package/dist/components/overlays/alert-dialog/index.js +22 -13
  206. package/dist/components/overlays/command/index.js +24 -17
  207. package/dist/components/overlays/context-menu/index.d.ts +33 -17
  208. package/dist/components/overlays/context-menu/index.js +27 -18
  209. package/dist/components/overlays/dialog/index.js +17 -12
  210. package/dist/components/overlays/dropdown/index.d.ts +257 -31
  211. package/dist/components/overlays/dropdown/index.js +42 -17
  212. package/dist/components/overlays/popover/index.d.ts +31 -9
  213. package/dist/components/overlays/popover/index.js +12 -9
  214. package/dist/components/overlays/preview-card/index.js +11 -8
  215. package/dist/components/overlays/sheet/index.js +18 -13
  216. package/dist/components/overlays/toast/index.d.ts +123 -47
  217. package/dist/components/overlays/toast/index.js +18 -10
  218. package/dist/components/overlays/tooltip/index.d.ts +113 -21
  219. package/dist/components/overlays/tooltip/index.js +15 -6
  220. package/dist/components/progress/index.d.ts +56 -0
  221. package/dist/components/progress/index.js +15 -0
  222. package/dist/components/table/index.d.ts +63 -8
  223. package/dist/components/table/index.js +19 -9
  224. package/dist/index.d.ts +2201 -736
  225. package/dist/index.js +223 -82
  226. package/dist/lib/date-locale.d.ts +14 -0
  227. package/dist/lib/date-locale.js +16 -0
  228. package/dist/lib/field-classes.d.ts +144 -0
  229. package/dist/lib/field-classes.js +40 -0
  230. package/dist/lib/field-variants.d.ts +47 -0
  231. package/dist/lib/field-variants.js +26 -0
  232. package/dist/lib/fluid-hover-highlight.d.ts +112 -0
  233. package/dist/lib/fluid-hover-highlight.js +17 -0
  234. package/dist/lib/focus-ring.d.ts +18 -0
  235. package/dist/lib/focus-ring.js +10 -0
  236. package/dist/lib/goo-indicator.d.ts +46 -0
  237. package/dist/lib/goo-indicator.js +14 -0
  238. package/dist/lib/icon-context.d.ts +30 -0
  239. package/dist/lib/icon-context.js +12 -0
  240. package/dist/lib/inert.d.ts +9 -0
  241. package/dist/lib/inert.js +8 -0
  242. package/dist/lib/morph-layers.d.ts +93 -0
  243. package/dist/lib/morph-layers.js +13 -0
  244. package/dist/lib/morph-part.d.ts +21 -0
  245. package/dist/lib/morph-part.js +12 -0
  246. package/dist/lib/popup.d.ts +20 -0
  247. package/dist/lib/popup.js +12 -0
  248. package/dist/lib/reduced-motion.d.ts +9 -0
  249. package/dist/lib/reduced-motion.js +6 -0
  250. package/dist/lib/row-index.d.ts +10 -0
  251. package/dist/lib/row-index.js +8 -0
  252. package/dist/lib/row-order.d.ts +28 -0
  253. package/dist/lib/row-order.js +10 -0
  254. package/dist/lib/scroll-column.d.ts +27 -0
  255. package/dist/lib/scroll-column.js +18 -0
  256. package/dist/lib/shape-context.d.ts +30 -0
  257. package/dist/lib/shape-context.js +12 -0
  258. package/dist/lib/size-context.d.ts +94 -0
  259. package/dist/lib/size-context.js +20 -0
  260. package/dist/lib/size-scale.d.ts +79 -0
  261. package/dist/lib/size-scale.js +8 -0
  262. package/dist/lib/slot.d.ts +12 -0
  263. package/dist/lib/slot.js +6 -0
  264. package/dist/lib/springs.d.ts +44 -0
  265. package/dist/lib/springs.js +8 -0
  266. package/dist/lib/surface-classes.d.ts +19 -0
  267. package/dist/lib/surface-classes.js +10 -0
  268. package/dist/lib/surface-context.d.ts +9 -0
  269. package/dist/lib/surface-context.js +8 -0
  270. package/dist/lib/use-controllable-state.d.ts +2 -0
  271. package/dist/lib/use-controllable-state.js +6 -0
  272. package/dist/lib/use-fluid-hover.d.ts +138 -0
  273. package/dist/lib/use-fluid-hover.js +14 -0
  274. package/dist/lib/use-is-mobile.d.ts +2 -0
  275. package/dist/lib/use-is-mobile.js +6 -0
  276. package/dist/lib/use-keyboard-nav-gate.d.ts +20 -0
  277. package/dist/lib/use-keyboard-nav-gate.js +7 -0
  278. package/dist/lib/use-merge-split.d.ts +47 -0
  279. package/dist/lib/use-merge-split.js +15 -0
  280. package/dist/lib/use-morph.d.ts +101 -0
  281. package/dist/lib/use-morph.js +20 -0
  282. package/dist/lib/use-touch-primary.d.ts +2 -0
  283. package/dist/lib/use-touch-primary.js +6 -0
  284. package/dist/lib/utils.d.ts +3 -0
  285. package/dist/lib/utils.js +6 -0
  286. package/dist/preset.d.ts +17 -0
  287. package/dist/preset.js +22 -2
  288. package/dist/primitives/code/index.js +7 -5
  289. package/dist/primitives/craft/index.js +9 -6
  290. package/dist/primitives/fluid-hover/index.js +9 -8
  291. package/dist/primitives/morph/index.js +15 -10
  292. package/dist/primitives/motion/index.js +10 -8
  293. package/dist/primitives/scroll-area/index.js +4 -3
  294. package/dist/primitives/separator/index.d.ts +25 -0
  295. package/dist/primitives/separator/index.js +9 -0
  296. package/dist/primitives/sizes/index.js +6 -4
  297. package/dist/primitives/skeleton/index.d.ts +35 -0
  298. package/dist/primitives/skeleton/index.js +12 -0
  299. package/dist/primitives/surface/index.js +6 -4
  300. package/dist/shared/{components-3mkepka5.js → components-0jmqhx2k.js} +28 -25
  301. package/dist/shared/components-124epb7f.js +53 -0
  302. package/dist/shared/{components-gz8zf3ec.js → components-1k0wv53d.js} +15 -43
  303. package/dist/shared/components-26snnbs9.js +322 -0
  304. package/dist/shared/{components-2dxkv6zm.js → components-2c0qcfx4.js} +8 -7
  305. package/dist/shared/{components-473g814w.js → components-2mt9te6d.js} +243 -137
  306. package/dist/shared/{components-hawvy2qz.js → components-2z48g4fp.js} +9 -30
  307. package/dist/shared/components-34txk9rj.js +35 -0
  308. package/dist/shared/components-3h92d94m.js +23 -0
  309. package/dist/shared/{components-zfnmn5d4.js → components-3sp623ae.js} +36 -31
  310. package/dist/shared/{components-jwst3vtj.js → components-3wxkjgqn.js} +186 -76
  311. package/dist/shared/{components-s2bz1xpk.js → components-49bsynbw.js} +8 -5
  312. package/dist/shared/components-4bwsd46w.js +71 -0
  313. package/dist/shared/components-4n4f5m3e.js +297 -0
  314. package/dist/shared/{components-0q0sqfh4.js → components-4y816hwp.js} +27 -49
  315. package/dist/shared/components-52ngsgca.js +399 -0
  316. package/dist/shared/{components-y9y0g8er.js → components-52r5763g.js} +22 -19
  317. package/dist/shared/components-559tf7yb.js +28 -0
  318. package/dist/shared/components-5mkga9hj.js +440 -0
  319. package/dist/shared/{components-kwa6mrf9.js → components-5s0nfqvw.js} +14 -9
  320. package/dist/shared/{components-dk0gjr5a.js → components-60atskhp.js} +19 -18
  321. package/dist/shared/{components-rr7a6ja9.js → components-658b26nb.js} +21 -18
  322. package/dist/shared/{components-xxvdw7k1.js → components-65vwfjs6.js} +37 -34
  323. package/dist/shared/{components-frw34b65.js → components-6j2fh74k.js} +114 -123
  324. package/dist/shared/components-6zg1yft7.js +167 -0
  325. package/dist/shared/components-6zng858q.js +53 -0
  326. package/dist/shared/components-72w4ge68.js +64 -0
  327. package/dist/shared/{components-cv16mryv.js → components-78x40gee.js} +13 -7
  328. package/dist/shared/{components-b2wyq2cq.js → components-7d4r7hh1.js} +39 -57
  329. package/dist/shared/components-7ntvvegr.js +316 -0
  330. package/dist/shared/components-8nwsqjn8.js +34 -0
  331. package/dist/shared/{components-5xc2j8jz.js → components-8tt3xbb5.js} +19 -18
  332. package/dist/shared/{components-vc7vq5nz.js → components-9as3rf68.js} +6 -3
  333. package/dist/shared/components-9mys9jna.js +255 -0
  334. package/dist/shared/components-adkarj8n.js +35 -0
  335. package/dist/shared/{components-b3c5et5b.js → components-ae9fncz4.js} +32 -31
  336. package/dist/shared/{components-hqeq3w9q.js → components-awra5sff.js} +24 -16
  337. package/dist/shared/components-b1e239g3.js +28 -0
  338. package/dist/shared/{components-q5xj5c16.js → components-b1f2bjf1.js} +9 -8
  339. package/dist/shared/{components-qmmjx1qw.js → components-bn42vq6d.js} +71 -72
  340. package/dist/shared/components-c96g82th.js +1444 -0
  341. package/dist/shared/components-c9yr4ed1.js +44 -0
  342. package/dist/shared/components-d3shbwqq.js +95 -0
  343. package/dist/shared/{components-jjw69c4c.js → components-dcc02hs2.js} +7 -4
  344. package/dist/shared/{components-men63rwj.js → components-de99ykt7.js} +3 -2
  345. package/dist/shared/components-dhgk6mbt.js +16 -0
  346. package/dist/shared/components-drxrtnp2.js +25 -0
  347. package/dist/shared/{components-6p8tfcd6.js → components-ds9fqat9.js} +21 -18
  348. package/dist/shared/{components-mjthm1kf.js → components-egy4tyjt.js} +11 -8
  349. package/dist/shared/components-f0xfpkhh.js +82 -0
  350. package/dist/shared/components-f7h57m6t.js +59 -0
  351. package/dist/shared/{components-d6xtjht3.js → components-fjkpbg6n.js} +33 -30
  352. package/dist/shared/{components-gc5vtq2f.js → components-gh0qeey3.js} +63 -29
  353. package/dist/shared/{components-39jq9va1.js → components-gvbapfen.js} +29 -24
  354. package/dist/shared/{components-5vgpnxgy.js → components-gzesg7hy.js} +19 -16
  355. package/dist/shared/components-h6p86be3.js +19 -0
  356. package/dist/shared/components-h83az0bz.js +33 -0
  357. package/dist/shared/components-jjrj6ymn.js +104 -0
  358. package/dist/shared/{components-wvp1m52g.js → components-jp5v0m0f.js} +15 -14
  359. package/dist/shared/{components-zrkzcayz.js → components-jqpefjsn.js} +54 -6
  360. package/dist/shared/{components-1z6tdadk.js → components-k74j4hwh.js} +20 -17
  361. package/dist/shared/components-kswxnm3h.js +24 -0
  362. package/dist/shared/{components-jbbmdr74.js → components-mkkbbgb7.js} +36 -17
  363. package/dist/shared/{components-pbmqctfa.js → components-mpw667rb.js} +169 -65
  364. package/dist/shared/{components-s5ngg32c.js → components-paa08427.js} +121 -47
  365. package/dist/shared/{components-vfbhxf5r.js → components-pd42emw3.js} +6 -5
  366. package/dist/shared/{components-hw9yk32z.js → components-pmfszv7a.js} +35 -23
  367. package/dist/shared/{components-chacnk61.js → components-pw06rbxq.js} +19 -19
  368. package/dist/shared/components-q03cktx8.js +246 -0
  369. package/dist/shared/{components-kvr7f9hy.js → components-qdm3yh26.js} +19 -10
  370. package/dist/shared/{components-27nnvrwe.js → components-qs4rkqsj.js} +15 -14
  371. package/dist/shared/{components-s083f38z.js → components-r6gbmkew.js} +22 -21
  372. package/dist/shared/{components-ta7shw28.js → components-r8eqs7gz.js} +4 -4
  373. package/dist/shared/{components-sn1rxx1d.js → components-rm02h9nt.js} +12 -11
  374. package/dist/shared/{components-c6s6b43g.js → components-rrbpmfx4.js} +3 -2
  375. package/dist/shared/{components-n7mjr357.js → components-tz1fv35x.js} +3 -2
  376. package/dist/shared/components-tz4gaf1v.js +23 -0
  377. package/dist/shared/{components-zx6b6yby.js → components-vf45j12c.js} +4 -18
  378. package/dist/shared/{components-3ky44908.js → components-vfjc9jzz.js} +2 -3
  379. package/dist/shared/{components-39vmfq9s.js → components-vsa625vk.js} +21 -20
  380. package/dist/shared/components-wawnmkph.js +500 -0
  381. package/dist/shared/components-wktrrb4m.js +38 -0
  382. package/dist/shared/{components-8snj490h.js → components-wwd363me.js} +78 -32
  383. package/dist/shared/{components-5pnw8183.js → components-xcwvgcrd.js} +23 -22
  384. package/dist/shared/components-xdqmt6ed.js +20 -0
  385. package/dist/shared/{components-h2ajmhwn.js → components-y1w3a6wx.js} +107 -35
  386. package/dist/shared/{components-ar66gnt0.js → components-y6578b8h.js} +37 -34
  387. package/dist/shared/{components-59efxjd9.js → components-yhgq0qxx.js} +13 -12
  388. package/dist/shared/{components-jthe1pwb.js → components-yz84gpkq.js} +101 -404
  389. package/dist/shared/components-z3v0n0de.js +11 -0
  390. package/dist/shared/{components-7r3etddy.js → components-z9n19fyk.js} +19 -54
  391. package/dist/shared/components-zbvjzh1p.js +141 -0
  392. package/dist/styles.css +241 -16
  393. package/dist/tailwind.css +13 -0
  394. package/dist/tokens.css +34 -0
  395. package/index.ts +60 -0
  396. package/lib/date-locale.ts +50 -0
  397. package/lib/field-classes.ts +115 -0
  398. package/lib/field-variants.ts +117 -0
  399. package/lib/fluid-hover-highlight.tsx +139 -0
  400. package/lib/focus-ring.tsx +46 -0
  401. package/lib/goo-indicator.tsx +261 -0
  402. package/lib/icon-context.tsx +310 -0
  403. package/lib/inert.ts +22 -0
  404. package/lib/morph-layers.tsx +102 -0
  405. package/lib/morph-part.tsx +49 -0
  406. package/lib/popup.ts +52 -0
  407. package/lib/reduced-motion.ts +19 -0
  408. package/lib/row-index.tsx +29 -0
  409. package/lib/row-order.tsx +115 -0
  410. package/lib/scroll-column.tsx +244 -0
  411. package/lib/shape-context.tsx +124 -0
  412. package/lib/size-context.tsx +66 -0
  413. package/lib/size-scale.ts +115 -0
  414. package/lib/slot.ts +25 -0
  415. package/lib/springs.ts +62 -0
  416. package/lib/surface-classes.ts +58 -0
  417. package/lib/surface-context.tsx +21 -0
  418. package/lib/use-controllable-state.ts +44 -0
  419. package/lib/use-fluid-hover.ts +541 -0
  420. package/lib/use-is-mobile.ts +25 -0
  421. package/lib/use-keyboard-nav-gate.ts +45 -0
  422. package/lib/use-merge-split.tsx +209 -0
  423. package/lib/use-morph.ts +557 -0
  424. package/lib/use-touch-primary.tsx +43 -0
  425. package/lib/utils.ts +37 -0
  426. package/package.json +177 -4
  427. package/preset.ts +196 -0
  428. package/primitives/code/examples.mdx +27 -0
  429. package/primitives/code/index.tsx +80 -0
  430. package/primitives/craft/demos.tsx +173 -0
  431. package/primitives/craft/examples.mdx +138 -0
  432. package/primitives/craft/index.tsx +76 -0
  433. package/primitives/fluid-hover/examples.mdx +84 -0
  434. package/primitives/fluid-hover/index.tsx +316 -0
  435. package/primitives/morph/examples.mdx +135 -0
  436. package/primitives/morph/index.tsx +192 -0
  437. package/primitives/motion/examples.mdx +100 -0
  438. package/primitives/motion/index.tsx +160 -0
  439. package/primitives/scroll-area/examples.mdx +58 -0
  440. package/primitives/scroll-area/index.tsx +200 -0
  441. package/primitives/separator/examples.mdx +30 -0
  442. package/primitives/separator/index.tsx +53 -0
  443. package/primitives/sizes/examples.mdx +167 -0
  444. package/primitives/sizes/index.tsx +89 -0
  445. package/primitives/skeleton/examples.mdx +51 -0
  446. package/primitives/skeleton/index.tsx +72 -0
  447. package/primitives/skeleton/skeleton.css +15 -0
  448. package/primitives/surface/examples.mdx +173 -0
  449. package/primitives/surface/index.tsx +109 -0
  450. package/primitives/surface/surface.css +176 -0
  451. package/dist/shared/components-0sn7ypjw.js +0 -61
  452. package/dist/shared/components-1434p1mh.js +0 -123
  453. package/dist/shared/components-a86dytt0.js +0 -986
  454. package/dist/shared/components-gf5s6ap9.js +0 -42
  455. package/dist/shared/components-hyq23phv.js +0 -310
  456. package/dist/shared/components-ng72k97e.js +0 -128
  457. package/dist/shared/components-raknhppb.js +0 -64
  458. package/dist/shared/components-vynjtrk6.js +0 -19
  459. package/dist/shared/components-wy9jpqny.js +0 -146
  460. /package/dist/shared/{components-1v7rxytx.js → components-3ey0hzpb.js} +0 -0
  461. /package/dist/shared/{components-ezxhrmer.js → components-jcnjay5g.js} +0 -0
@@ -0,0 +1,1436 @@
1
+ // biome-ignore-all lint: vendored upstream code keeps its own patterns (see the header)
2
+ /*
3
+ * Ported from Fluid Functionalism — github.com/mickadesign/fluid-functionalism
4
+ * `registry/base/combobox.tsx` @ b3587bdbd83fc66c2a6aae3817ffb856cb09260b
5
+ * MIT License © 2026 Micka Touillaud — fluidfunctionalism.com (notice: LICENSE.fluid-functionalism)
6
+ * Local modifications:
7
+ * - `"use client"` dropped (no RSC here).
8
+ * - Imports rewritten: `@/lib/*` → `../../../lib/*` (`SizeProvider` →
9
+ * `../../../primitives/sizes`), `@/hooks/*` → `../../../lib/*`,
10
+ * `@/lib/elevated` → `../../../primitives/surface`, `@/components/ui/scroll-area` →
11
+ * `../../../primitives/scroll-area`, `@/components/ui/fluid-hover-highlight` →
12
+ * `../../../lib/fluid-hover-highlight`, `framer-motion` → `motion/react`.
13
+ * - `React.*` type references replaced with named type imports (no React namespace import).
14
+ * - The root's generics (`<T extends ComboboxItemData, Multiple extends boolean>`) dropped:
15
+ * modo's parser needs a plain (non-generic) function declaration for the root plus a
16
+ * same-file `interface ComboboxProps`. Item objects gained an index signature so rows can still
17
+ * carry extra fields (`icon`, …), `value`/`defaultValue` are `string | string[]`, and
18
+ * `onValueChange` is a union of the two handler shapes so `setState` still assigns.
19
+ * `ComboboxValue<Multiple>` is still exported for callers that spell the value out.
20
+ * - Uncontrolled create mode added: `creatable` offers the create row with no callback and
21
+ * the component keeps the items it makes (merged into `items`, deduped by value), so a
22
+ * combobox can grow its own list. `onCreate` is unchanged and still wins when it returns
23
+ * an item.
24
+ * - `noUncheckedIndexedAccess`: the single-mode checked rect guards `checkedIndices[0]`.
25
+ * - modo item: TSDoc (FF docs page + API tables), statics `Combobox.Input` / `.Chips` /
26
+ * `.Content` / `.List` / `.Item` / `.Empty` assigned on the root, upstream named exports
27
+ * kept, `export default Combobox`.
28
+ * - Styling reads DS tokens (AGENTS.md styling): `text-[Npx]` →
29
+ * `text-<role>[-compact]`; the hex focus-ring fallback →
30
+ * `ring-focus-ring` / `border-focus-ring`; `duration-80|120|160` and
31
+ * tier-length JS durations → `duration-<tier>` / `spring.*`.
32
+ * - The popup morphs out of the field through the shared morph layer
33
+ * (`lib/use-morph.ts` + `MorphSurface`, goo by default on
34
+ * `spring.goo`; `Combobox.Content` takes `from` / `effect` /
35
+ * `hideSource` / `tier`). Whatever opened it (typing, a press on the input
36
+ * or the chevron), the source is the field the list anchors to. The
37
+ * `scaleY` motion wrapper and the `actionsRef` deferred unmount (with its
38
+ * fallback timer) are gone: the morph holds Base UI's unmount itself. The
39
+ * popup paints its level through the morph's surface layers instead of
40
+ * `render={<Elevated/>}`, re-providing it with `SurfaceProvider`.
41
+ * - `effect="morph"` with `hideSource` opens the list in place over its
42
+ * trigger: the positioner's `sideOffset` becomes the engine's `inPlaceOffset`.
43
+ * - The fields' ladder is the shared `lib/field-classes.ts` recipe (the local
44
+ * `fieldVariants` cva moved there, still exported here as
45
+ * `comboboxFieldVariants`; its default is now `borderless`, the fields pass
46
+ * `bordered`): `error` stamps `data-invalid` instead of overriding the ring
47
+ * per state, so an invalid field also tints its lit fill; the leading icon
48
+ * darkens and thickens on hover too, not only on focus. Both fields build
49
+ * their box with the shared `useFieldBox` (ladder in the variant, state
50
+ * attributes, radius, inset), as every text field does; `variant` is typed
51
+ * `FieldVariant` (no `null`).
52
+ * - Hooks for remote data (local): `loading` (the chevron turns into a
53
+ * spinner, a status row under the list, the empty message held back),
54
+ * a controlled query (`inputValue` / `defaultInputValue` /
55
+ * `onInputValueChange`), `filter={null}` to keep every item the caller
56
+ * hands in, `readOnly`, a controlled popup (`open` / `defaultOpen` /
57
+ * `onOpenChange`), a `footer` under the list (`Combobox.Content`), and
58
+ * object items whose `label` is any node with a `textValue` to filter and
59
+ * fill the field by.
60
+ */
61
+
62
+ import { Combobox as ComboboxPrimitive } from '@base-ui/react/combobox'
63
+ import { AnimatePresence, animate, motion, useMotionValue } from 'motion/react'
64
+ import {
65
+ createContext,
66
+ forwardRef,
67
+ type HTMLAttributes,
68
+ type InputHTMLAttributes,
69
+ type MutableRefObject,
70
+ type ReactNode,
71
+ type RefObject,
72
+ useCallback,
73
+ useContext,
74
+ useEffect,
75
+ useImperativeHandle,
76
+ useMemo,
77
+ useRef,
78
+ useState,
79
+ } from 'react'
80
+ import { type FieldVariant, fieldIconClasses, fieldVariants, useFieldBox } from '../../../lib/field-classes'
81
+ import { FluidHoverHighlight } from '../../../lib/fluid-hover-highlight'
82
+ import { type IconComponent, useIcon } from '../../../lib/icon-context'
83
+ import { MorphSurface } from '../../../lib/morph-layers'
84
+ import { isDisabledRow, popupScrollAreaClass, popupViewportClass } from '../../../lib/popup'
85
+ import { useReduceMotion } from '../../../lib/reduced-motion'
86
+ import { shapeMap, useShape } from '../../../lib/shape-context'
87
+ import { type SizeVariant, useSize } from '../../../lib/size-context'
88
+ import { spring } from '../../../lib/springs'
89
+ import { SURFACE_BG, SURFACE_SHADOW } from '../../../lib/surface-classes'
90
+ import { SurfaceProvider, useSurface } from '../../../lib/surface-context'
91
+ import { useControllableState } from '../../../lib/use-controllable-state'
92
+ import { useFluidHover, useRegisterFluidHoverItem } from '../../../lib/use-fluid-hover'
93
+ import { SelectionBackgrounds, useMergeSplitBlocks, useSelectionRuns } from '../../../lib/use-merge-split'
94
+ import { inPlaceOffset, type MorphOrigin, opensInPlace, useMorph, useMorphOrigin } from '../../../lib/use-morph'
95
+ import { cn } from '../../../lib/utils'
96
+ import { ScrollArea } from '../../../primitives/scroll-area'
97
+ import { SizeProvider } from '../../../primitives/sizes'
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Combobox
101
+ //
102
+ // A text field that filters a list as you type. Built on Base UI's Combobox
103
+ // primitive, which owns the filtering, the combobox/listbox ARIA wiring
104
+ // (aria-activedescendant — the input keeps focus while arrows move a
105
+ // highlight through the rows), positioning, dismissal, and the hidden form
106
+ // input. This layer keeps the fluid-hover overlays, the morph open/close
107
+ // (lib/use-morph.ts), and the animated checkmark — the same visuals as
108
+ // Select.
109
+ //
110
+ // Items are data: pass `items` to the root and render rows from the
111
+ // ComboboxList function child. String items are their own value and label;
112
+ // object items carry `{ value, label }` plus anything else you need.
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** An object item: `value` and `label`, plus whatever the rows need. */
116
+ interface ComboboxItemObject {
117
+ value: string
118
+ /** What the row and the chip show: text, or any node with a `textValue`. */
119
+ label: ReactNode
120
+ /** The item as text: what the query filters and the field shows. Defaults to `label` when it is a string, else `value`. */
121
+ textValue?: string
122
+ [key: string]: unknown
123
+ }
124
+
125
+ type ComboboxItemData = string | ComboboxItemObject
126
+
127
+ function itemValue(item: ComboboxItemData): string {
128
+ return typeof item === 'string' ? item : item.value
129
+ }
130
+
131
+ /** The item as text: what filters, fills the field and names a chip. */
132
+ function itemLabel(item: ComboboxItemData): string {
133
+ if (typeof item === 'string') return item
134
+ return item.textValue ?? (typeof item.label === 'string' ? item.label : item.value)
135
+ }
136
+
137
+ /** The item as shown: its label node. */
138
+ function itemNode(item: ComboboxItemData): ReactNode {
139
+ return typeof item === 'string' ? item : item.label
140
+ }
141
+
142
+ /** What `Combobox.Content`'s footer gets when it is a function. */
143
+ interface ComboboxFooterContext {
144
+ /** Closes the popup. */
145
+ close: () => void
146
+ /** The trimmed query. */
147
+ query: string
148
+ }
149
+
150
+ /** A node, or a function of the popup's state, rendered under the list. */
151
+ type ComboboxFooter = ReactNode | ((context: ComboboxFooterContext) => ReactNode)
152
+
153
+ // The create row is one more item in the list the primitive filters and
154
+ // highlights, so Enter and the arrows reach it like any row. Its value can't
155
+ // collide with a consumer's; its label is the query it would create.
156
+ const CREATE_VALUE = '\0create'
157
+
158
+ function isCreateItem(item: ComboboxItemData): boolean {
159
+ return itemValue(item) === CREATE_VALUE
160
+ }
161
+
162
+ function defaultCreateLabel(query: string): ReactNode {
163
+ return `Create “${query}”`
164
+ }
165
+
166
+ type ComboboxValue<Multiple extends boolean> = Multiple extends true ? string[] : string
167
+
168
+ interface ComboboxContextValue {
169
+ /** Selected values — one entry in single mode, any number in multiple. */
170
+ values: string[]
171
+ multiple: boolean
172
+ inputValue: string
173
+ open: boolean
174
+ /** What opened the popup; its trigger is always the field (`anchorRef`). */
175
+ origin: RefObject<MorphOrigin>
176
+ /** The field (input group or chips container) the popup anchors to.
177
+ * `RefObject<HTMLDivElement>` (not `… | null`) so the ref prop on Base UI's
178
+ * InputGroup/Chips accepts it under @types/react 18. */
179
+ anchorRef: RefObject<HTMLDivElement>
180
+ disabled: boolean
181
+ itemsByValue: Map<string, ComboboxItemData>
182
+ /** The create row's label for the current query; null while no row is
183
+ * offered. */
184
+ createRow: ReactNode | null
185
+ /** `hideSelected` has emptied the list with nothing typed: every item is
186
+ * a chip already. */
187
+ allSelected: boolean
188
+ /** Local: items are on their way; the empty message waits. */
189
+ loading: boolean
190
+ /** Local: the loading row's text. */
191
+ loadingLabel: ReactNode
192
+ /** Local: shows the picks but takes no input. */
193
+ readOnly: boolean
194
+ /** Local: opens or closes the popup (the footer's `close`). */
195
+ setOpen: (open: boolean) => void
196
+ }
197
+
198
+ const ComboboxContext = createContext<ComboboxContextValue | null>(null)
199
+
200
+ /** Highlighted row (the primitive's active index over the rendered rows)
201
+ * and whether a keyboard/auto highlight put it there — pointer highlights
202
+ * are left to fluid hover. Its own context so a per-pointer highlight
203
+ * re-renders the list, not the field and every row. */
204
+ interface Highlight {
205
+ index: number
206
+ keyboard: boolean
207
+ }
208
+ const ComboboxHighlightContext = createContext<Highlight | null>(null)
209
+
210
+ function useComboboxContext() {
211
+ const ctx = useContext(ComboboxContext)
212
+ if (!ctx) throw new Error('Combobox compound components must be inside <Combobox>')
213
+ return ctx
214
+ }
215
+
216
+ // Content context for fluid hover
217
+ interface ComboboxContentContextValue {
218
+ registerItem: (index: number, element: HTMLElement | null) => void
219
+ activeIndex: number | null
220
+ }
221
+
222
+ const ComboboxContentContext = createContext<ComboboxContentContextValue | null>(null)
223
+
224
+ // Each rendered row learns its index from the list, so consumers never
225
+ // number rows by hand.
226
+ const ComboboxItemIndexContext = createContext<number>(0)
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Combobox (root)
230
+ // ---------------------------------------------------------------------------
231
+
232
+ interface ComboboxProps {
233
+ /** `Combobox.Input` or `Combobox.Chips`, plus `Combobox.Content`. */
234
+ children: ReactNode
235
+ /** The options. A string is its own value and label; an object carries `value`, `label`, and whatever your rows need. */
236
+ items: readonly ComboboxItemData[]
237
+ /** Any number of picks. Values become `string[]`; use `Combobox.Chips` for the field. Defaults to `false`. */
238
+ multiple?: boolean
239
+ /** Selected value. `""` means none; an array when `multiple`. Controlled — pair it with `onValueChange`. */
240
+ value?: string | string[]
241
+ /** Selection on first render when uncontrolled. Defaults to none. */
242
+ defaultValue?: string | string[]
243
+ /** Called with the pick, `""` when cleared, or the array when `multiple`. */
244
+ onValueChange?: ((value: string) => void) | ((value: string[]) => void)
245
+ /** Match an item against the query, or `null` to show every item as given (filtered elsewhere, e.g. on a server). Defaults to a case-insensitive "contains" on the item's text. */
246
+ filter?: ((item: ComboboxItemData, query: string) => boolean) | null
247
+ /** Controlled query, the field's text. Pair it with `onInputValueChange`. */
248
+ inputValue?: string
249
+ /** Initial query, for an uncontrolled field. Defaults to the picked item's text in single mode, else `""`. */
250
+ defaultInputValue?: string
251
+ /** Called with the field's text on every keystroke, and when a pick or a close rewrites it. */
252
+ onInputValueChange?: (inputValue: string) => void
253
+ /** Items are loading: the chevron turns into a spinner, a status row sits under the list and the empty message waits. Defaults to `false`. */
254
+ loading?: boolean
255
+ /** The loading row's text. Defaults to `"Loading…"`. */
256
+ loadingLabel?: ReactNode
257
+ /** Shows the picks but takes no input: the popup stays shut and the clear ✕ and chip ✕ go. Defaults to `false`. */
258
+ readOnly?: boolean
259
+ /** Controlled open state of the popup. Pair it with `onOpenChange`. */
260
+ open?: boolean
261
+ /** Initial open state, for an uncontrolled popup. Defaults to `false`. */
262
+ defaultOpen?: boolean
263
+ /** Called when the popup opens or closes. */
264
+ onOpenChange?: (open: boolean) => void
265
+ /** Adds a last row that creates the trimmed query whenever no label matches it exactly. Add the item to `items` and return it to select it. */
266
+ onCreate?: (query: string) => ComboboxItemData | void
267
+ /** The create row's label. Defaults to ``(query) => `Create “${query}”` ``. */
268
+ createLabel?: (query: string) => ReactNode
269
+ /** Local addition: offer the create row with no callback — the component keeps the items it makes, so nothing outside has to hold the list. Defaults to `false`. */
270
+ creatable?: boolean
271
+ /** Multiple only. Picked items leave the list, so it reads as what is left to add. Deselect from the chips. Defaults to `false`. */
272
+ hideSelected?: boolean
273
+ /** Disables the field and the popup. Defaults to `false`. */
274
+ disabled?: boolean
275
+ /** Form field name; a hidden input carries the value. */
276
+ name?: string
277
+ /** Marks the hidden form input required. */
278
+ required?: boolean
279
+ /** Pins field and popup to one step of the size ladder (default 36px, compact 28px — see the Sizes primitive). Omitted, both follow the surrounding SizeProvider. */
280
+ size?: SizeVariant
281
+ }
282
+
283
+ function toValues(v: string | readonly string[] | undefined): string[] {
284
+ if (v === undefined) return []
285
+ if (Array.isArray(v)) return v as string[]
286
+ return v === '' ? [] : [v as string]
287
+ }
288
+
289
+ /**
290
+ * Type to filter a list. 1 pick, or chips.
291
+ *
292
+ * Items are data on the root: strings, which are their own value and label,
293
+ * or `{ value, label, … }` objects. `Combobox.List` takes a function child and
294
+ * renders one `Combobox.Item` per match, so the filtering, the ARIA wiring
295
+ * (the input keeps focus while the arrows move a highlight through the rows),
296
+ * the positioning and the hidden form input stay with Base UI. On top of that
297
+ * sit the fluid-hover highlight, the merged selection background of
298
+ * `Checkbox.Group`, and a list that oozes out of the field and back through the
299
+ * shared morph (see Morph), goo by default. `multiple` turns the picks into a
300
+ * `string[]` and pairs with `Combobox.Chips`: one chip per pick, Backspace in
301
+ * an empty field drops the last one. Uncontrolled with `defaultValue`, or
302
+ * controlled with `value` plus `onValueChange`.
303
+ *
304
+ * For items that come from elsewhere (a server, a paged API), own the query
305
+ * with `inputValue` + `onInputValueChange`, pass `filter={null}` so the list
306
+ * shows exactly the items you hand it, and set `loading` while they are on
307
+ * their way: the chevron blurs into a spinner and a status row sits under
308
+ * the list instead of the empty message. `Combobox.Content`'s `footer` pins
309
+ * an action under the list ("Load more", "Add new…"). An object item's
310
+ * `label` may be any node; its `textValue` is what filters, fills the field
311
+ * and names its chip. `readOnly` shows the picks and takes no input.
312
+ *
313
+ * Statics:
314
+ * - `Combobox.Input` — the single-pick field: icon, text input, clear ✕, chevron.
315
+ * - `Combobox.Chips` — the multiple field: one chip per pick ahead of the input.
316
+ * - `Combobox.Content` — the popup surface, holding `Combobox.Empty` and `Combobox.List`:
317
+ * `side`, `align`, `sideOffset`, a `footer` under the list, and the morph options
318
+ * `from`, `effect`, `hideSource`, `tier`.
319
+ * - `Combobox.List` — a row per match, from a `(item, index) => ReactNode` child.
320
+ * - `Combobox.Item` — one row: `value`, optional `icon` and `disabled`.
321
+ * - `Combobox.Empty` — what shows when nothing matches.
322
+ *
323
+ * @example {@include ./examples.mdx}
324
+ */
325
+ function Combobox({
326
+ children,
327
+ items,
328
+ multiple,
329
+ value,
330
+ defaultValue,
331
+ onValueChange,
332
+ filter,
333
+ onCreate,
334
+ createLabel = defaultCreateLabel,
335
+ creatable = false,
336
+ hideSelected = false,
337
+ disabled = false,
338
+ name,
339
+ required,
340
+ inputValue: inputValueProp,
341
+ defaultInputValue,
342
+ onInputValueChange,
343
+ loading = false,
344
+ loadingLabel = 'Loading…',
345
+ readOnly = false,
346
+ open: openProp,
347
+ defaultOpen = false,
348
+ onOpenChange,
349
+ size,
350
+ }: ComboboxProps) {
351
+ const isMultiple = !!multiple
352
+ const [internalValues, setInternalValues] = useState<string[]>(() => toValues(defaultValue))
353
+ const [inputValue, setInputValue] = useControllableState(inputValueProp, defaultInputValue ?? '', onInputValueChange)
354
+ const [open, setOpen] = useControllableState(openProp, defaultOpen, onOpenChange)
355
+ const [highlight, setHighlight] = useState<Highlight | null>(null)
356
+ // Items the create row made while no consumer owns the list.
357
+ const [createdItems, setCreatedItems] = useState<ComboboxItemData[]>([])
358
+ const { origin, capture } = useMorphOrigin()
359
+ const anchorRef = useRef<HTMLDivElement | null>(null)
360
+ // Memoized on the prop identity: a string value is stable by nature and a
361
+ // consumer's array is state, so the derived array (and everything keyed on
362
+ // it) only changes when the selection does.
363
+ const controlledValues = useMemo(() => toValues(value), [value])
364
+ const values = value !== undefined ? controlledValues : internalValues
365
+
366
+ // The consumer's items plus anything `creatable` made here. A consumer that
367
+ // adds a created item to `items` itself wins — the internal copy is deduped
368
+ // away, so the two modes never double a row.
369
+ const allItems = useMemo<readonly ComboboxItemData[]>(() => {
370
+ const own = createdItems.filter(made => !items.some(item => itemValue(item) === itemValue(made)))
371
+ return own.length ? [...items, ...own] : items
372
+ }, [items, createdItems])
373
+
374
+ // The query the primitive filters on: the trimmed input. In single mode
375
+ // the field shows the selected label on open, which is an exact match, so
376
+ // no create row appears until the user types past it.
377
+ const query = inputValue.trim()
378
+ const canCreate = !!onCreate || creatable
379
+ const createItem = useMemo<ComboboxItemData | null>(() => {
380
+ if (!canCreate || query === '') return null
381
+ const lower = query.toLocaleLowerCase()
382
+ const exists = allItems.some(item => itemLabel(item).toLocaleLowerCase() === lower)
383
+ return exists ? null : { value: CREATE_VALUE, label: query }
384
+ }, [canCreate, query, allItems])
385
+
386
+ const itemsByValue = useMemo(() => {
387
+ const map = new Map<string, ComboboxItemData>()
388
+ for (const item of allItems) map.set(itemValue(item), item)
389
+ // Rows hand the primitive the item they were rendered from; the create
390
+ // row's carries the query it stands for.
391
+ if (createItem) map.set(CREATE_VALUE, createItem)
392
+ return map
393
+ }, [allItems, createItem])
394
+ const selectedItems = useMemo(
395
+ () => values.map(v => itemsByValue.get(v)).filter(Boolean) as ComboboxItemData[],
396
+ [values, itemsByValue],
397
+ )
398
+
399
+ // What the list shows: the items less the chips when `hideSelected`, plus
400
+ // the create row last, so Enter picks a real match while one exists and
401
+ // only creates once nothing matches.
402
+ const hideChecked = hideSelected && isMultiple
403
+ const listItems = useMemo<readonly ComboboxItemData[]>(() => {
404
+ const visible = hideChecked ? allItems.filter(item => !values.includes(itemValue(item))) : allItems
405
+ return createItem ? [...visible, createItem] : visible
406
+ }, [allItems, hideChecked, values, createItem])
407
+ const allSelected = hideChecked && query === '' && allItems.length > 0 && listItems.length === 0
408
+
409
+ // Base UI hands back the item(s); the public API speaks in values. A pick
410
+ // on the create row is not a selection: it asks the consumer for the item,
411
+ // and selects whatever comes back.
412
+ const onCreateRef = useRef(onCreate)
413
+ onCreateRef.current = onCreate
414
+ const handleValueChange = useCallback(
415
+ (next: ComboboxItemData[] | ComboboxItemData | null) => {
416
+ const picked = Array.isArray(next) ? next : next == null ? [] : [next]
417
+ const created = picked.find(isCreateItem)
418
+ let nextValues = picked.filter(item => !isCreateItem(item)).map(itemValue)
419
+ if (created) {
420
+ const madeQuery = itemLabel(created)
421
+ const made = onCreateRef.current?.(madeQuery) ?? undefined
422
+ if (made !== undefined) {
423
+ nextValues = [...nextValues, itemValue(made)]
424
+ } else if (creatable) {
425
+ // No consumer owns the list, so the item is made and kept here: the
426
+ // row it adds is indistinguishable from a real one.
427
+ const own: ComboboxItemData = { value: madeQuery, label: madeQuery }
428
+ setCreatedItems(prev => (prev.some(item => itemValue(item) === madeQuery) ? prev : [...prev, own]))
429
+ nextValues = [...nextValues, madeQuery]
430
+ } else if (!isMultiple) {
431
+ // Nothing to select: a single field keeps its pick.
432
+ return
433
+ }
434
+ }
435
+ if (value === undefined) setInternalValues(nextValues)
436
+ ;(onValueChange as ((value: string | string[]) => void) | undefined)?.(
437
+ isMultiple ? nextValues : (nextValues[0] ?? ''),
438
+ )
439
+ },
440
+ [value, onValueChange, isMultiple, creatable],
441
+ )
442
+
443
+ // The create row must survive the filter that hides every other miss.
444
+ // Without a consumer filter the primitive's own collator match is kept.
445
+ const { contains } = ComboboxPrimitive.useFilter()
446
+ const filterFn = useMemo(() => {
447
+ // `null`: the caller filters; the list shows what it is handed.
448
+ if (filter === null) return null
449
+ if (!filter && !createItem) return undefined
450
+ const match = filter
451
+ ? (item: ComboboxItemData, q: string) => filter(item, q)
452
+ : (item: ComboboxItemData, q: string) => contains(item, q, itemLabel)
453
+ return (item: ComboboxItemData, q: string) => isCreateItem(item) || match(item, q)
454
+ }, [filter, createItem, contains])
455
+
456
+ const createRow = createItem ? createLabel(query) : null
457
+ const ctx = useMemo(
458
+ () => ({
459
+ values,
460
+ multiple: isMultiple,
461
+ inputValue,
462
+ open,
463
+ origin,
464
+ anchorRef,
465
+ disabled,
466
+ itemsByValue,
467
+ createRow,
468
+ allSelected,
469
+ loading,
470
+ loadingLabel,
471
+ readOnly,
472
+ setOpen,
473
+ }),
474
+ [
475
+ values,
476
+ isMultiple,
477
+ inputValue,
478
+ open,
479
+ origin,
480
+ disabled,
481
+ itemsByValue,
482
+ createRow,
483
+ allSelected,
484
+ loading,
485
+ loadingLabel,
486
+ readOnly,
487
+ setOpen,
488
+ ],
489
+ )
490
+
491
+ // A size prop pins the whole compound (field + portalled popup — React
492
+ // context crosses portals) to one step of the ladder.
493
+ const root = (
494
+ <ComboboxContext.Provider value={ctx}>
495
+ <ComboboxHighlightContext.Provider value={highlight}>
496
+ <ComboboxPrimitive.Root
497
+ items={listItems}
498
+ multiple={isMultiple}
499
+ // Always controlled; "" (no selection) maps to Base UI's null. The
500
+ // value shape follows `multiple`, which the primitive's generics
501
+ // can't express from a boolean prop — hence the cast.
502
+ value={(isMultiple ? selectedItems : (selectedItems[0] ?? null)) as never}
503
+ onValueChange={handleValueChange as never}
504
+ isItemEqualToValue={(a: ComboboxItemData, b: ComboboxItemData) => itemValue(a) === itemValue(b)}
505
+ itemToStringLabel={itemLabel}
506
+ itemToStringValue={itemValue}
507
+ filter={filterFn}
508
+ open={open}
509
+ onOpenChange={(next, details) => {
510
+ // The list grows out of the field, whichever part opened it.
511
+ if (next) capture({ event: details.event, trigger: anchorRef.current ?? undefined })
512
+ setOpen(next)
513
+ }}
514
+ // Controlled only when the caller owns the query; otherwise Base UI
515
+ // keeps it and this mirrors it.
516
+ inputValue={inputValueProp}
517
+ defaultInputValue={defaultInputValue}
518
+ onInputValueChange={next => setInputValue(next)}
519
+ autoHighlight={ALWAYS_HIGHLIGHT}
520
+ onItemHighlighted={(item, details) =>
521
+ setHighlight(item === undefined ? null : { index: details.index, keyboard: details.reason !== 'pointer' })
522
+ }
523
+ disabled={disabled}
524
+ readOnly={readOnly}
525
+ name={name}
526
+ required={required}
527
+ // Non-modal: the page keeps scrolling and the Positioner tracks the
528
+ // anchor, so the popup follows its field instead of detaching.
529
+ modal={false}
530
+ >
531
+ {children}
532
+ </ComboboxPrimitive.Root>
533
+ </ComboboxHighlightContext.Provider>
534
+ </ComboboxContext.Provider>
535
+ )
536
+
537
+ return size ? <SizeProvider size={size}>{root}</SizeProvider> : root
538
+ }
539
+
540
+ Combobox.displayName = 'Combobox'
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // ComboboxInput — the field: leading icon, text input, clear ✕, chevron.
544
+ // ---------------------------------------------------------------------------
545
+
546
+ // The field follows the global pill/rounded shape; the popup does not. Like
547
+ // Dropdown, the list keeps the smaller "rounded" radii whatever the rest of
548
+ // the UI is shaped: pill corners on a popover distort its padding and break
549
+ // the concentric fit of the rows' hover and selection backgrounds inside it.
550
+ const popupShape = shapeMap.rounded
551
+
552
+ // The first row is highlighted the moment the list opens and follows the
553
+ // query as it filters, so Enter always has a target. AriaCombobox accepts
554
+ // "always" for this; ComboboxRoot's prop type still says boolean.
555
+ const ALWAYS_HIGHLIGHT = 'always' as unknown as boolean
556
+
557
+ // The clear and chevron buttons share one quiet style; they sit inside the
558
+ // field's ring so they need no frame of their own.
559
+ const fieldButtonClass =
560
+ 'flex shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors duration-fast hover:text-foreground focus-visible:ring-1 focus-visible:ring-focus-ring data-[disabled]:pointer-events-none'
561
+ // The clear ✕ is a real icon button: the hover fill says "press me", where
562
+ // the chevron beside it only decorates the field it belongs to.
563
+ const clearButtonClass = cn(fieldButtonClass, 'hover:bg-hover active:bg-active transition-[color,background-color]')
564
+ // A chip's ✕ sits on the chip's own hover-tinted fill, so it steps up a notch.
565
+ const chipRemoveClass = cn(fieldButtonClass, 'rounded hover:bg-active')
566
+
567
+ interface ComboboxFieldProps
568
+ extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'value' | 'defaultValue' | 'onChange'> {
569
+ /** Framed at rest, or invisible until hovered or focused. Defaults to `"bordered"`. */
570
+ variant?: FieldVariant
571
+ /** Leading icon inside the field. */
572
+ icon?: IconComponent
573
+ /** Shown while nothing is typed or selected. Defaults to `"Search…"`. */
574
+ placeholder?: string
575
+ /** Error message under the field; tints the ring and sets `aria-invalid`. */
576
+ error?: string
577
+ /** Render a ✕ that clears the selection and query. Its slot is always
578
+ * reserved so the field never changes width. Defaults to `false`. */
579
+ clearable?: boolean
580
+ /** Size override for the field alone. Prefer the `size` prop on <Combobox>
581
+ * (or a surrounding SizeProvider) so the popup matches. */
582
+ size?: SizeVariant
583
+ }
584
+
585
+ type ComboboxInputProps = ComboboxFieldProps
586
+
587
+ // Icon swap (motion.md): the chevron and the spinner share one cell.
588
+ const SHOWN = { opacity: 1, scale: 1, filter: 'blur(0px)' }
589
+ const HIDDEN = { opacity: 0, scale: 0.6, filter: 'blur(4px)' }
590
+ const swapEnter = { type: 'tween' as const, duration: spring.fast.duration, ease: 'easeOut' as const }
591
+ const swapLeave = { type: 'tween' as const, ...spring.fast.exit, ease: 'easeIn' as const }
592
+
593
+ /** The trailing clear ✕ and chevron, shared by both fields. */
594
+ function FieldControls({ clearable, compact, iconSize }: { clearable: boolean; compact: boolean; iconSize: number }) {
595
+ const XIcon = useIcon('x')
596
+ const Loader = useIcon('loader-circle')
597
+ const pill = useShape().variant === 'pill'
598
+ const { loading, readOnly } = useComboboxContext()
599
+ const reduced = useReduceMotion()
600
+ const swap = (shown: boolean) => (reduced ? { opacity: shown ? 1 : 0 } : shown ? SHOWN : HIDDEN)
601
+ return (
602
+ <>
603
+ {clearable && !readOnly && (
604
+ // The primitive hides the button while there is nothing to clear; a
605
+ // fixed slot keeps the field's width steady either way.
606
+ <span className={cn('flex shrink-0 items-center justify-center', compact ? 'size-5' : 'size-6')}>
607
+ <ComboboxPrimitive.Clear
608
+ aria-label="Clear"
609
+ className={cn(clearButtonClass, pill && 'rounded-full', compact ? 'size-5' : 'size-6')}
610
+ >
611
+ <XIcon size={iconSize} strokeWidth={1.5} />
612
+ </ComboboxPrimitive.Clear>
613
+ </span>
614
+ )}
615
+ {/* Not a tab stop: the field itself opens on ArrowDown or typing. */}
616
+ <ComboboxPrimitive.Trigger
617
+ aria-label="Open"
618
+ tabIndex={-1}
619
+ className={cn(fieldButtonClass, compact ? 'size-5' : 'size-6')}
620
+ >
621
+ {/* While loading, the chevron blurs into a spinner in the same cell. */}
622
+ <span className="grid" style={{ width: iconSize, height: iconSize }}>
623
+ <motion.span
624
+ className="col-start-1 row-start-1 flex"
625
+ initial={false}
626
+ animate={swap(!loading)}
627
+ transition={loading ? swapLeave : swapEnter}
628
+ >
629
+ <svg
630
+ width={iconSize}
631
+ height={iconSize}
632
+ viewBox="0 0 24 24"
633
+ fill="none"
634
+ stroke="currentColor"
635
+ strokeWidth={2}
636
+ strokeLinecap="round"
637
+ strokeLinejoin="round"
638
+ className={cn('transition-colors duration-fast', readOnly && 'invisible')}
639
+ >
640
+ <path d="M6 9l6 6 6-6" />
641
+ </svg>
642
+ </motion.span>
643
+ <motion.span
644
+ aria-hidden
645
+ className="col-start-1 row-start-1 flex"
646
+ initial={false}
647
+ animate={swap(loading)}
648
+ transition={loading ? swapEnter : swapLeave}
649
+ >
650
+ <Loader size={iconSize} strokeWidth={1.5} className="animate-spin motion-reduce:animate-none" />
651
+ </motion.span>
652
+ </span>
653
+ </ComboboxPrimitive.Trigger>
654
+ </>
655
+ )
656
+ }
657
+
658
+ const ComboboxInput = forwardRef<HTMLInputElement, ComboboxInputProps>(
659
+ (
660
+ { className, variant = 'bordered', icon: Icon, placeholder = 'Search…', error, clearable = false, size, ...props },
661
+ ref,
662
+ ) => {
663
+ const { anchorRef, readOnly } = useComboboxContext()
664
+ const { size: sizeClasses, props: boxProps } = useFieldBox({ variant, invalid: !!error, readOnly, size })
665
+ const compact = sizeClasses.variant === 'compact'
666
+
667
+ return (
668
+ <div className="flex flex-col gap-1">
669
+ {/* The group is the popup's anchor, so the list spans the whole
670
+ field (icon to chevron), not just the text input. */}
671
+ <ComboboxPrimitive.InputGroup
672
+ ref={anchorRef}
673
+ {...boxProps}
674
+ className={cn(
675
+ boxProps.className,
676
+ 'flex items-center',
677
+ sizeClasses.control,
678
+ sizeClasses.gap,
679
+ compact ? 'min-w-[128px]' : 'min-w-[160px]',
680
+ className,
681
+ )}
682
+ >
683
+ {Icon && <Icon size={sizeClasses.icon} strokeWidth={1.5} className={fieldIconClasses} />}
684
+ <ComboboxPrimitive.Input
685
+ ref={ref}
686
+ placeholder={placeholder}
687
+ aria-invalid={!!error || undefined}
688
+ className={cn(
689
+ 'min-w-0 flex-1 rounded-none bg-transparent text-foreground placeholder:text-muted-foreground outline-none font-[inherit]',
690
+ sizeClasses.text,
691
+ // The caret is as tall as the line box (Chrome, Safari); the
692
+ // ladder's leading keeps it in proportion to the field.
693
+ compact ? 'leading-5' : 'leading-6',
694
+ )}
695
+ {...props}
696
+ />
697
+ <FieldControls clearable={clearable} compact={compact} iconSize={sizeClasses.icon} />
698
+ </ComboboxPrimitive.InputGroup>
699
+ {error && <span className="text-caption text-destructive pl-3">{error}</span>}
700
+ </div>
701
+ )
702
+ },
703
+ )
704
+
705
+ ComboboxInput.displayName = 'ComboboxInput'
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // ComboboxChips — the multiple-selection field: one chip per selected item
709
+ // ahead of the text input, wrapping onto new lines as they accumulate.
710
+ // Backspace in an empty input removes the last chip; ArrowLeft walks into
711
+ // the chips, where Backspace/Delete removes the focused one.
712
+ // ---------------------------------------------------------------------------
713
+
714
+ type ComboboxChipsProps = ComboboxFieldProps
715
+
716
+ /** Springs the chips field's height to the measured chip row stack (plus
717
+ * `padY`, the field's vertical padding) as chips wrap and unwrap. The
718
+ * height lives in a MotionValue driving an inline style, and for a reason:
719
+ * the field must stay pinned at its old height through the reflow that
720
+ * wraps a chip, or its auto height lands on the new row before any spring
721
+ * can start and the growth snaps. First measure sets the pin; every later
722
+ * one springs it. Measured (offsetHeight + ResizeObserver), never
723
+ * `height: "auto"`: framer resolves "auto" from the element's *visual*
724
+ * (transformed) size, so under a scaled ancestor (the /demo card) the
725
+ * spring would overshoot. Layout metrics are transform-immune. */
726
+ function useChipRowHeight(padY: number) {
727
+ const height = useMotionValue<number | 'auto'>('auto')
728
+ const roRef = useRef<ResizeObserver | null>(null)
729
+ const padRef = useRef(padY)
730
+ padRef.current = padY
731
+ const ref = useCallback(
732
+ (el: HTMLDivElement | null) => {
733
+ roRef.current?.disconnect()
734
+ roRef.current = null
735
+ if (!el) return
736
+ const measure = () => {
737
+ if (el.offsetHeight <= 0) return
738
+ const target = el.offsetHeight + padRef.current
739
+ if (height.get() === 'auto') height.set(target)
740
+ else if (height.get() !== target) animate(height, target, spring.fast)
741
+ }
742
+ measure()
743
+ const ro = new ResizeObserver(measure)
744
+ ro.observe(el)
745
+ roRef.current = ro
746
+ },
747
+ [height],
748
+ )
749
+ return { ref, height }
750
+ }
751
+
752
+ const ComboboxChips = forwardRef<HTMLInputElement, ComboboxChipsProps>(
753
+ (
754
+ { className, variant = 'bordered', icon: Icon, placeholder = 'Search…', error, clearable = false, size, ...props },
755
+ ref,
756
+ ) => {
757
+ const XIcon = useIcon('x')
758
+ const shape = useShape()
759
+ const { anchorRef, open, disabled, inputValue, values, readOnly } = useComboboxContext()
760
+ const { size: sizeClasses, props: boxProps } = useFieldBox({ variant, invalid: !!error, disabled, readOnly, size })
761
+ const compact = sizeClasses.variant === 'compact'
762
+ const rowHeight = useChipRowHeight(compact ? 8 : 12)
763
+
764
+ return (
765
+ <div className="flex flex-col gap-1">
766
+ <ComboboxPrimitive.Chips
767
+ ref={anchorRef}
768
+ // The field springs to the chip rows' measured height as they
769
+ // wrap and unwrap, instead of snapping a row taller or shorter.
770
+ render={<motion.div style={{ height: rowHeight.height }} />}
771
+ // Chips stamps no state attributes of its own (InputGroup does);
772
+ // the field ladder (`lib/field-classes.ts`) and the chevron read these.
773
+ {...boxProps}
774
+ data-popup-open={open || undefined}
775
+ className={cn(
776
+ boxProps.className,
777
+ 'flex items-center',
778
+ // The spring above owns the height; leaving it in transition-all
779
+ // would have the 80ms CSS ease drag behind every spring frame.
780
+ 'transition-[color,background-color,border-radius,box-shadow,opacity]',
781
+ // Grows with its chips. Rows top-align (`items-start`) so the
782
+ // icon and controls hold the first line while chips wrap; the
783
+ // vertical padding is exactly what centers one 24px (20px
784
+ // compact) row inside the ladder height.
785
+ '!items-start',
786
+ compact ? 'min-h-7 py-1' : 'min-h-9 py-1.5',
787
+ sizeClasses.gap,
788
+ compact ? 'min-w-[128px]' : 'min-w-[160px]',
789
+ className,
790
+ )}
791
+ >
792
+ {Icon && (
793
+ // A row-height box keeps the icon centered on the first line.
794
+ <span className={cn('flex shrink-0 items-center', compact ? 'h-5' : 'h-6')}>
795
+ <Icon size={sizeClasses.icon} strokeWidth={1.5} className={fieldIconClasses} />
796
+ </span>
797
+ )}
798
+ <div
799
+ ref={rowHeight.ref}
800
+ className={cn(
801
+ 'relative flex min-w-0 flex-1 flex-wrap items-center gap-1',
802
+ // A chip leads the field: the row pulls left so the chip sits
803
+ // an even distance from every edge. The offset lives here, not
804
+ // on the container's padding — the container's transition-all
805
+ // would ease a padding change against the chip's spring and
806
+ // drag the caret with it; the row snaps instead, in the same
807
+ // frame as the chip. An icon keeps the full inset — it leads,
808
+ // not the chip.
809
+ !Icon && values.length > 0 && '-ml-1',
810
+ )}
811
+ >
812
+ <ComboboxPrimitive.Value>
813
+ {(selected: ComboboxItemData[] | null) => (
814
+ <>
815
+ {/* Chips pop in and out on the fast tier and slide into
816
+ their new slots; an exiting chip is inert while it
817
+ fades, since the primitive has already dropped it.
818
+ popLayout lifts the exiting chip out of the flow at
819
+ once, so the field reflows immediately rather than
820
+ after the fade. */}
821
+ <AnimatePresence initial={false} mode="popLayout">
822
+ {(selected ?? []).map(item => {
823
+ const label = itemLabel(item)
824
+ return (
825
+ <motion.span
826
+ key={itemValue(item)}
827
+ layout
828
+ initial={{ opacity: 0, scale: 0.9 }}
829
+ animate={{ opacity: 1, scale: 1 }}
830
+ exit={{
831
+ opacity: 0,
832
+ scale: 0.9,
833
+ pointerEvents: 'none',
834
+ transition: spring.fast.exit,
835
+ }}
836
+ transition={spring.fast}
837
+ // Grow from the left edge, where the typed text
838
+ // just was — not from the middle of the chip.
839
+ style={{ originX: 0 }}
840
+ className="inline-flex max-w-full shrink-0"
841
+ >
842
+ <ComboboxPrimitive.Chip
843
+ aria-label={label}
844
+ className={cn(
845
+ 'inline-flex max-w-full shrink-0 items-center gap-0.5 bg-hover pl-2 pr-0.5 text-foreground outline-none',
846
+ shape.variant === 'pill' ? 'rounded-full' : 'rounded-md',
847
+ 'focus-visible:ring-1 focus-visible:ring-focus-ring',
848
+ compact ? 'h-5 text-caption-compact' : 'h-6 text-caption',
849
+ )}
850
+ >
851
+ <span
852
+ className={cn(
853
+ typeof itemNode(item) === 'string' ? 'truncate' : 'flex min-w-0 items-center',
854
+ readOnly && 'pr-1.5',
855
+ )}
856
+ >
857
+ {itemNode(item)}
858
+ </span>
859
+ {!readOnly && (
860
+ <ComboboxPrimitive.ChipRemove
861
+ aria-label={`Remove ${label}`}
862
+ className={cn(
863
+ chipRemoveClass,
864
+ shape.variant === 'pill' && 'rounded-full',
865
+ compact ? 'size-4' : 'size-5',
866
+ )}
867
+ >
868
+ <XIcon size={compact ? 10 : 12} strokeWidth={2} />
869
+ </ComboboxPrimitive.ChipRemove>
870
+ )}
871
+ </ComboboxPrimitive.Chip>
872
+ </motion.span>
873
+ )
874
+ })}
875
+ </AnimatePresence>
876
+ {/* The field sizes to what is typed (`size` is the
877
+ intrinsic width; flex-auto grows it across the rest
878
+ of its row) so it stays beside the chips while it
879
+ fits and wraps only once the text no longer does.
880
+ It never animates: chips slide, the field snaps.
881
+ Animating it read as the placeholder sliding in from
882
+ the right when the last chip went. */}
883
+ <span className="flex min-w-6 flex-auto">
884
+ <ComboboxPrimitive.Input
885
+ ref={ref}
886
+ size={Math.max(1, inputValue.length + 1)}
887
+ placeholder={selected?.length ? undefined : placeholder}
888
+ aria-invalid={!!error || undefined}
889
+ className={cn(
890
+ 'w-full min-w-0 rounded-none bg-transparent text-foreground placeholder:text-muted-foreground outline-none font-[inherit]',
891
+ // Line box = row height, so the caret spans the chip row.
892
+ compact ? 'h-5 leading-5' : 'h-6 leading-6',
893
+ sizeClasses.text,
894
+ )}
895
+ {...props}
896
+ />
897
+ </span>
898
+ </>
899
+ )}
900
+ </ComboboxPrimitive.Value>
901
+ </div>
902
+ <FieldControls clearable={clearable} compact={compact} iconSize={sizeClasses.icon} />
903
+ </ComboboxPrimitive.Chips>
904
+ {error && <span className="text-caption text-destructive pl-3">{error}</span>}
905
+ </div>
906
+ )
907
+ },
908
+ )
909
+
910
+ ComboboxChips.displayName = 'ComboboxChips'
911
+
912
+ // ---------------------------------------------------------------------------
913
+ // ComboboxContent — the popup surface. Holds ComboboxEmpty and ComboboxList.
914
+ // ---------------------------------------------------------------------------
915
+
916
+ interface ComboboxContentProps {
917
+ /** Classes for the popup surface — merged after the radius and max-height. */
918
+ className?: string
919
+ /** `Combobox.Empty` and `Combobox.List`. */
920
+ children: ReactNode
921
+ /** Side of the field the popup prefers. Defaults to `"bottom"`. */
922
+ side?: 'top' | 'bottom' | 'left' | 'right'
923
+ /** Alignment against the field. Defaults to `"start"`. */
924
+ align?: 'start' | 'center' | 'end'
925
+ /** Gap to the field, in px. Ignored when the list opens in place over the field (`effect="morph"` with `hideSource`). Defaults to `6`. */
926
+ sideOffset?: number
927
+ /** Where the list grows from (see Morph): the field, the press point, its own center, a viewport edge, or a ref to any element. Defaults to `'trigger'` (the field). */
928
+ from?: 'trigger' | 'pointer' | 'center' | 'top' | 'right' | 'bottom' | 'left' | RefObject<HTMLElement | null>
929
+ /** How it grows (see Morph): with the liquid goo neck, a plain morph, a slide or a fade. Defaults to `'goo'`. */
930
+ effect?: 'goo' | 'morph' | 'slide' | 'fade'
931
+ /** Hide the field while open, so it reads as turning into the list. Defaults to `false`. */
932
+ hideSource?: boolean
933
+ /** Spring tier of a plain morph, slide or fade; goo runs on its own `spring.goo`. Defaults to `'moderate'`. */
934
+ tier?: 'moderate' | 'slow'
935
+ /** Pinned under the list, past a hairline: a "load more" or "add new" action. A function gets `{ close, query }`. */
936
+ footer?: ComboboxFooter
937
+ }
938
+
939
+ const ComboboxContent = forwardRef<HTMLDivElement, ComboboxContentProps>(
940
+ (
941
+ {
942
+ className,
943
+ children,
944
+ side = 'bottom',
945
+ align = 'start',
946
+ sideOffset = 6,
947
+ from,
948
+ effect,
949
+ hideSource,
950
+ tier = 'moderate',
951
+ footer,
952
+ },
953
+ ref,
954
+ ) => {
955
+ const { open, origin, anchorRef, setOpen, inputValue } = useComboboxContext()
956
+ const morph = useMorph(open, origin, { from, effect, hideSource, tier })
957
+ useImperativeHandle(ref, () => morph.popup as HTMLDivElement, [morph.popup])
958
+ // Lifts 2 levels off its substrate with a fixed shadow (see Elevated).
959
+ const level = Math.min(useSurface() + 2, 8)
960
+ const shape = popupShape
961
+
962
+ return (
963
+ <ComboboxPrimitive.Portal>
964
+ <ComboboxPrimitive.Positioner
965
+ // The whole field (input group or chips container) is the anchor,
966
+ // so the list spans it edge to edge.
967
+ anchor={anchorRef}
968
+ side={side}
969
+ align={align}
970
+ sideOffset={opensInPlace({ effect, hideSource }) ? inPlaceOffset : sideOffset}
971
+ className="z-50 outline-none"
972
+ >
973
+ <ComboboxPrimitive.Popup ref={morph.popupRef} className="relative select-none outline-none">
974
+ <SurfaceProvider value={level}>
975
+ <MorphSurface
976
+ morph={morph}
977
+ bg={SURFACE_BG[level]}
978
+ shadow={SURFACE_SHADOW[3]}
979
+ radius={shape.container}
980
+ className={cn(
981
+ // min-w tracks the field via the Positioner's --anchor-width
982
+ // var; the list inside owns padding and scrolling.
983
+ `flex flex-col min-w-[var(--anchor-width)] max-h-[min(300px,var(--available-height))] overflow-hidden ${shape.container}`,
984
+ className,
985
+ )}
986
+ >
987
+ {children}
988
+ {footer !== undefined && (
989
+ // Outside the scrolling list, so it stays put while the rows scroll.
990
+ <div className="shrink-0 border-t border-border p-1">
991
+ {typeof footer === 'function'
992
+ ? footer({ close: () => setOpen(false), query: inputValue.trim() })
993
+ : footer}
994
+ </div>
995
+ )}
996
+ </MorphSurface>
997
+ </SurfaceProvider>
998
+ </ComboboxPrimitive.Popup>
999
+ </ComboboxPrimitive.Positioner>
1000
+ </ComboboxPrimitive.Portal>
1001
+ )
1002
+ },
1003
+ )
1004
+
1005
+ ComboboxContent.displayName = 'ComboboxContent'
1006
+
1007
+ // ---------------------------------------------------------------------------
1008
+ // ComboboxList — renders a row per matching item, carrying the fluid hover
1009
+ // hover overlays and the animated selected background / focus ring.
1010
+ // ---------------------------------------------------------------------------
1011
+
1012
+ interface ComboboxListProps {
1013
+ /** Classes for the rows' container. */
1014
+ className?: string
1015
+ /** Render one row per matching item: `(item, index) => <Combobox.Item …/>`. Rows get their index from the list. */
1016
+ children: (item: ComboboxItemData, index: number) => ReactNode
1017
+ }
1018
+
1019
+ const ComboboxList = forwardRef<HTMLDivElement, ComboboxListProps>(({ className, children }, ref) => {
1020
+ const { open, values, multiple, inputValue, createRow, loading, loadingLabel } = useComboboxContext()
1021
+ const Loader = useIcon('loader-circle')
1022
+ const sizeClasses = useSize()
1023
+ const highlight = useContext(ComboboxHighlightContext)
1024
+ const PlusIcon = useIcon('plus')
1025
+ const shape = popupShape
1026
+ const containerRef = useRef<HTMLDivElement>(null)
1027
+
1028
+ const hover = useFluidHover(containerRef, { isItemDisabled: isDisabledRow })
1029
+ const { activeIndex, setActiveIndex, itemRects, isMeasured, handlers, registerItem, remeasure } = hover
1030
+
1031
+ // Checked rows by index — one in single mode, any number in multiple.
1032
+ const [checkedIndices, setCheckedIndices] = useState<number[]>([])
1033
+
1034
+ // Fresh rects once per open — the popup keeps its rows registered while
1035
+ // it sits hidden between opens, so registration alone never triggers a
1036
+ // pass on reopen. Filtering re-registers rows, which the hook coalesces
1037
+ // into its own measurement.
1038
+ useEffect(() => {
1039
+ if (!open) return
1040
+ remeasure()
1041
+ }, [open, remeasure])
1042
+
1043
+ // Typing re-filters the rows underneath the overlays, and those frames
1044
+ // must snap, not glide — an overlay springing to wherever its row LANDED
1045
+ // reads as the list smearing on every keystroke. The primitive filters
1046
+ // internally, so the query is the reflow signal (picks don't change it,
1047
+ // keeping multiple mode's merge/split glide). The flag arms on the
1048
+ // query's render and holds until the rects measured from the new row
1049
+ // set land — measurement coalesces on a rAF, one render behind.
1050
+ const reflowRef = useRef(false)
1051
+ const prevQueryRef = useRef(inputValue)
1052
+ const armedRectsRef = useRef(itemRects)
1053
+ if (prevQueryRef.current !== inputValue) {
1054
+ prevQueryRef.current = inputValue
1055
+ armedRectsRef.current = itemRects
1056
+ reflowRef.current = true
1057
+ }
1058
+ const reflowSnap = reflowRef.current
1059
+ useEffect(() => {
1060
+ if (reflowRef.current && itemRects !== armedRectsRef.current) reflowRef.current = false
1061
+ })
1062
+
1063
+ // Detect the checked rows. Their indices shift as the query filters the
1064
+ // list, so the typed value is a dependency too.
1065
+ useEffect(() => {
1066
+ if (!open) return
1067
+ // Double rAF: first waits for React commit, second for layout
1068
+ let inner: number
1069
+ const outer = requestAnimationFrame(() => {
1070
+ inner = requestAnimationFrame(() => {
1071
+ const container = containerRef.current
1072
+ if (container) {
1073
+ const rows = Array.from(container.querySelectorAll('[data-fluid-hover-index]')) as HTMLElement[]
1074
+ const next: number[] = []
1075
+ rows.forEach((el, i) => {
1076
+ if (values.includes(el.getAttribute('data-value') ?? '')) next.push(i)
1077
+ })
1078
+ setCheckedIndices(next)
1079
+ }
1080
+ })
1081
+ })
1082
+ return () => {
1083
+ cancelAnimationFrame(outer)
1084
+ cancelAnimationFrame(inner)
1085
+ }
1086
+ }, [open, values, inputValue])
1087
+
1088
+ // A keyboard (or auto) highlight drives the hover background — the only
1089
+ // indicator: the row Enter would pick reads like the row the pointer
1090
+ // would pick, with no ring.
1091
+ useEffect(() => {
1092
+ // A dropped highlight (the input is the stop again) takes the hover
1093
+ // background with it — otherwise it would linger on the last row.
1094
+ if (!highlight) setActiveIndex(null)
1095
+ else if (highlight.keyboard) setActiveIndex(highlight.index)
1096
+ }, [highlight, setActiveIndex])
1097
+
1098
+ // Reset every overlay index as the close begins, so the reopen doesn't
1099
+ // spring an overlay from a stale row.
1100
+ useEffect(() => {
1101
+ if (open) return
1102
+ setCheckedIndices([])
1103
+ setActiveIndex(null)
1104
+ }, [open, setActiveIndex])
1105
+
1106
+ // Overlays read rects only once the hook reports the row set fully
1107
+ // measured — positioning one from an incomplete pass mounts it at the
1108
+ // wrong row, and the correcting pass then springs it across the list.
1109
+ // Single mode glides ONE marker between rows (a value change springs it
1110
+ // to the picked row). Multiple mode paints one block per contiguous run
1111
+ // of checked rows — merging and splitting like Checkbox.Group as picks
1112
+ // bridge or break a run.
1113
+ const firstChecked = checkedIndices[0]
1114
+ const checkedRect = isMeasured && !multiple && firstChecked !== undefined ? (itemRects[firstChecked] ?? null) : null
1115
+ const runs = useSelectionRuns(multiple ? checkedIndices : [])
1116
+ const blocks = useMergeSplitBlocks(runs, isMeasured && open ? itemRects : [], shape.bgRadius)
1117
+
1118
+ const contentCtx = useMemo(() => ({ registerItem, activeIndex }), [registerItem, activeIndex])
1119
+
1120
+ return (
1121
+ <ComboboxContentContext.Provider value={contentCtx}>
1122
+ <ScrollArea className={popupScrollAreaClass} viewportClassName={cn(popupViewportClass, 'scroll-fade')}>
1123
+ <ComboboxPrimitive.List
1124
+ ref={(node: HTMLDivElement | null) => {
1125
+ ;(containerRef as MutableRefObject<HTMLDivElement | null>).current = node
1126
+ if (typeof ref === 'function') ref(node)
1127
+ else if (ref) (ref as MutableRefObject<HTMLDivElement | null>).current = node
1128
+ }}
1129
+ onMouseEnter={handlers.onMouseEnter}
1130
+ onMouseMove={handlers.onMouseMove}
1131
+ onMouseLeave={handlers.onMouseLeave}
1132
+ onClick={handlers.onClick}
1133
+ className={cn(
1134
+ // The list is the overlays' offsetParent, so rows and overlays
1135
+ // scroll together inside the ScrollArea. Padding collapses when
1136
+ // the list is empty (ComboboxEmpty takes over).
1137
+ 'relative flex flex-col p-1 outline-none data-[empty]:p-0',
1138
+ className,
1139
+ )}
1140
+ >
1141
+ {/* The three overlays are torn down as the close begins rather
1142
+ than exit-animated: an overlay still mounted when the popup
1143
+ reopens is one AnimatePresence re-adopts under its old key and
1144
+ animates from the row it had before. */}
1145
+ {/* Selected background */}
1146
+ {open && multiple && (
1147
+ <SelectionBackgrounds blocks={reflowSnap ? blocks.map(b => ({ ...b, instant: true })) : blocks} />
1148
+ )}
1149
+ {open && !multiple && (
1150
+ <AnimatePresence>
1151
+ {checkedRect && (
1152
+ <motion.div
1153
+ key="checked"
1154
+ aria-hidden
1155
+ className={`absolute ${shape.bg} bg-active pointer-events-none`}
1156
+ initial={false}
1157
+ animate={{
1158
+ top: checkedRect.top,
1159
+ left: checkedRect.left,
1160
+ width: checkedRect.width,
1161
+ height: checkedRect.height,
1162
+ opacity: 1,
1163
+ }}
1164
+ exit={{ opacity: 0, transition: spring.moderate.exit }}
1165
+ transition={
1166
+ reflowSnap ? { duration: 0 } : { ...spring.moderate, opacity: { duration: spring.fast.duration } }
1167
+ }
1168
+ />
1169
+ )}
1170
+ </AnimatePresence>
1171
+ )}
1172
+
1173
+ {/* Hover background. Snaps (no travel) while a filter reflow is
1174
+ moving the rows underneath. */}
1175
+ {open && (
1176
+ <FluidHoverHighlight hover={hover} className={shape.bg} transition={reflowSnap ? false : undefined} />
1177
+ )}
1178
+
1179
+ {/* The overlays above are plain children, so the rows come from
1180
+ an explicit Collection (a function child on List would have
1181
+ to be its only child). */}
1182
+ <ComboboxPrimitive.Collection>
1183
+ {(item: ComboboxItemData, index: number) => (
1184
+ <ComboboxItemIndexContext.Provider key={itemValue(item)} value={index}>
1185
+ {/* The create row is the list's, not the consumer's: it
1186
+ reads the label from the root and picks like any row. */}
1187
+ {isCreateItem(item) ? (
1188
+ <ComboboxItem value={CREATE_VALUE} icon={PlusIcon}>
1189
+ {createRow}
1190
+ </ComboboxItem>
1191
+ ) : (
1192
+ children(item, index)
1193
+ )}
1194
+ </ComboboxItemIndexContext.Provider>
1195
+ )}
1196
+ </ComboboxPrimitive.Collection>
1197
+ </ComboboxPrimitive.List>
1198
+ {loading && (
1199
+ // Not a row: no hover, no pick. Under the rows, so items already
1200
+ // here stay where they are while more arrive.
1201
+ <div
1202
+ role="status"
1203
+ className={cn(
1204
+ 'flex shrink-0 items-center justify-center text-muted-foreground',
1205
+ sizeClasses.control,
1206
+ sizeClasses.itemPx,
1207
+ sizeClasses.gap,
1208
+ sizeClasses.text,
1209
+ )}
1210
+ >
1211
+ <Loader
1212
+ size={sizeClasses.icon}
1213
+ strokeWidth={1.5}
1214
+ className="shrink-0 animate-spin motion-reduce:animate-none"
1215
+ />
1216
+ <span className="truncate [text-box:trim-both_cap_alphabetic]">{loadingLabel}</span>
1217
+ </div>
1218
+ )}
1219
+ </ScrollArea>
1220
+ </ComboboxContentContext.Provider>
1221
+ )
1222
+ })
1223
+
1224
+ ComboboxList.displayName = 'ComboboxList'
1225
+
1226
+ // ---------------------------------------------------------------------------
1227
+ // ComboboxItem
1228
+ // ---------------------------------------------------------------------------
1229
+
1230
+ interface ComboboxItemProps extends HTMLAttributes<HTMLDivElement> {
1231
+ /** Leading icon in the row. */
1232
+ icon?: IconComponent
1233
+ /** The item's value — a string item itself, or an object item's `value`. */
1234
+ value: string
1235
+ /** Disables the row. Defaults to `false`. */
1236
+ disabled?: boolean
1237
+ /** The row label. */
1238
+ children?: ReactNode
1239
+ }
1240
+
1241
+ const ComboboxItem = forwardRef<HTMLDivElement, ComboboxItemProps>(
1242
+ ({ className, children, icon: Icon, value, disabled = false, ...props }, ref) => {
1243
+ const comboboxCtx = useComboboxContext()
1244
+ const contentCtx = useContext(ComboboxContentContext)
1245
+ const index = useContext(ComboboxItemIndexContext)
1246
+ const internalRef = useRef<HTMLDivElement>(null)
1247
+ const shape = popupShape
1248
+ const sizeClasses = useSize()
1249
+ const compact = sizeClasses.variant === 'compact'
1250
+ const hasMounted = useRef(false)
1251
+
1252
+ useEffect(() => {
1253
+ hasMounted.current = true
1254
+ }, [])
1255
+
1256
+ // Register with fluid hover. Depends on the (stable) registerItem
1257
+ // rather than the content context, which is rebuilt on every activeIndex
1258
+ // change.
1259
+ const registerItem = contentCtx?.registerItem
1260
+ useRegisterFluidHoverItem(registerItem, index, internalRef)
1261
+
1262
+ const isActive = contentCtx?.activeIndex === index
1263
+ const isChecked = comboboxCtx.values.includes(value)
1264
+ const skipAnimation = !hasMounted.current
1265
+ // Base UI matches rows to `items` by value, so the row hands back the
1266
+ // item it was rendered from.
1267
+ const item = comboboxCtx.itemsByValue.get(value) ?? value
1268
+
1269
+ return (
1270
+ <ComboboxPrimitive.Item
1271
+ value={item}
1272
+ index={index}
1273
+ disabled={disabled}
1274
+ render={
1275
+ <div
1276
+ ref={(node: HTMLDivElement | null) => {
1277
+ ;(internalRef as MutableRefObject<HTMLDivElement | null>).current = node
1278
+ if (typeof ref === 'function') ref(node)
1279
+ else if (ref) (ref as MutableRefObject<HTMLDivElement | null>).current = node
1280
+ }}
1281
+ data-fluid-hover-index={index}
1282
+ data-value={value}
1283
+ className={cn(
1284
+ // Fixed height so the text-box trim on the label doesn't
1285
+ // shrink the row; shrink-0 because the list is a max-height
1286
+ // flex column.
1287
+ `relative z-10 flex ${sizeClasses.control} shrink-0 items-center ${sizeClasses.gap} ${shape.item} ${sizeClasses.itemPx} ${sizeClasses.text} cursor-pointer outline-none select-none`,
1288
+ 'transition-[color] duration-fast',
1289
+ isActive || isChecked ? 'text-foreground' : 'text-muted-foreground',
1290
+ disabled && 'opacity-50 pointer-events-none',
1291
+ className,
1292
+ )}
1293
+ {...props}
1294
+ />
1295
+ }
1296
+ >
1297
+ {Icon && (
1298
+ <Icon
1299
+ size={sizeClasses.icon}
1300
+ strokeWidth={isActive || isChecked ? 2 : 1.5}
1301
+ className="shrink-0 transition-[color,stroke-width] duration-fast"
1302
+ />
1303
+ )}
1304
+
1305
+ {/* py-1/-my-1 keeps truncate's overflow:hidden from clipping
1306
+ ascenders/descenders outside the trimmed box. */}
1307
+ {/* Nodes keep their own box; only text is trimmed and truncated. */}
1308
+ <span
1309
+ className={cn(
1310
+ 'flex-1 min-w-0',
1311
+ typeof children === 'string' || typeof children === 'number'
1312
+ ? 'truncate [text-box:trim-both_cap_alphabetic] py-1 -my-1'
1313
+ : 'flex items-center',
1314
+ )}
1315
+ >
1316
+ {children}
1317
+ </span>
1318
+
1319
+ {/* Always-rendered fixed slot so the check appearing/disappearing
1320
+ never changes the row's intrinsic width. */}
1321
+ <span aria-hidden className={cn('shrink-0', compact ? 'w-3.5 h-3.5' : 'w-4 h-4')}>
1322
+ <AnimatePresence>
1323
+ {isChecked && (
1324
+ <motion.svg
1325
+ key="check"
1326
+ width={sizeClasses.icon}
1327
+ height={sizeClasses.icon}
1328
+ viewBox="0 0 24 24"
1329
+ fill="none"
1330
+ stroke="currentColor"
1331
+ strokeWidth={2}
1332
+ strokeLinecap="round"
1333
+ strokeLinejoin="round"
1334
+ className="text-foreground"
1335
+ initial={{ opacity: 1 }}
1336
+ animate={{ opacity: 1 }}
1337
+ exit={{ opacity: 1 }}
1338
+ >
1339
+ <motion.path
1340
+ d="M4 12L9 17L20 6"
1341
+ initial={{ pathLength: skipAnimation ? 1 : 0 }}
1342
+ animate={{
1343
+ pathLength: 1,
1344
+ transition: { duration: spring.fast.duration, ease: 'easeOut' },
1345
+ }}
1346
+ exit={{
1347
+ pathLength: 0,
1348
+ transition: { duration: 0.04, ease: 'easeIn' },
1349
+ }}
1350
+ />
1351
+ </motion.svg>
1352
+ )}
1353
+ </AnimatePresence>
1354
+ </span>
1355
+ </ComboboxPrimitive.Item>
1356
+ )
1357
+ },
1358
+ )
1359
+
1360
+ ComboboxItem.displayName = 'ComboboxItem'
1361
+
1362
+ // ---------------------------------------------------------------------------
1363
+ // ComboboxEmpty — shown in place of the list when nothing matches.
1364
+ // ---------------------------------------------------------------------------
1365
+
1366
+ interface ComboboxEmptyProps extends HTMLAttributes<HTMLDivElement> {
1367
+ /** Shown instead of the children when `hideSelected` has emptied the
1368
+ * list with nothing typed: every item is a chip already. */
1369
+ allSelected?: ReactNode
1370
+ /** Shown when nothing matches the query. */
1371
+ children?: ReactNode
1372
+ }
1373
+
1374
+ const ComboboxEmpty = forwardRef<HTMLDivElement, ComboboxEmptyProps>(
1375
+ ({ className, children, allSelected, ...props }, ref) => {
1376
+ const sizeClasses = useSize()
1377
+ const ctx = useComboboxContext()
1378
+ return (
1379
+ // Base UI keeps this element mounted (it is a live region) and only
1380
+ // renders the children while the list is empty — so the padding is
1381
+ // gated on having content.
1382
+ <ComboboxPrimitive.Empty
1383
+ ref={ref}
1384
+ className={cn('px-3 text-center text-muted-foreground [&:not(:empty)]:py-6', sizeClasses.text, className)}
1385
+ {...props}
1386
+ >
1387
+ {ctx.loading ? null : ctx.allSelected && allSelected !== undefined ? allSelected : children}
1388
+ </ComboboxPrimitive.Empty>
1389
+ )
1390
+ },
1391
+ )
1392
+
1393
+ ComboboxEmpty.displayName = 'ComboboxEmpty'
1394
+
1395
+ // ---------------------------------------------------------------------------
1396
+ // Statics — the compound API, typed by assignment so `<Combobox.Item>` and
1397
+ // the named exports stay the same component.
1398
+ // ---------------------------------------------------------------------------
1399
+
1400
+ Combobox.Input = ComboboxInput
1401
+ Combobox.Chips = ComboboxChips
1402
+ Combobox.Content = ComboboxContent
1403
+ Combobox.List = ComboboxList
1404
+ Combobox.Item = ComboboxItem
1405
+ Combobox.Empty = ComboboxEmpty
1406
+
1407
+ // ---------------------------------------------------------------------------
1408
+ // Exports
1409
+ // ---------------------------------------------------------------------------
1410
+
1411
+ export type {
1412
+ ComboboxChipsProps,
1413
+ ComboboxContentProps,
1414
+ ComboboxEmptyProps,
1415
+ ComboboxFooter,
1416
+ ComboboxFooterContext,
1417
+ ComboboxInputProps,
1418
+ ComboboxItemData,
1419
+ ComboboxItemObject,
1420
+ ComboboxItemProps,
1421
+ ComboboxListProps,
1422
+ ComboboxProps,
1423
+ ComboboxValue,
1424
+ }
1425
+ export {
1426
+ Combobox,
1427
+ ComboboxChips,
1428
+ ComboboxContent,
1429
+ ComboboxEmpty,
1430
+ ComboboxInput,
1431
+ ComboboxItem,
1432
+ ComboboxList,
1433
+ fieldVariants as comboboxFieldVariants,
1434
+ }
1435
+
1436
+ export default Combobox