portal-mojo 0.1.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 (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,622 @@
1
+ // CollectionMultiSelect — the server-backed multi-pick: live model data as a
2
+ // checkbox list with search on top, SELECT/DESELECT-all with counts, and
3
+ // shift-click range selection. Ported from web-mojo
4
+ // src/core/forms/inputs/CollectionMultiSelect.js (588 lines: SearchView +
5
+ // ListItemsView + CollectionMultiSelectView) into the one-cache world: data
6
+ // flows through useModelList's query keys ([endpoint, params]), so pickers,
7
+ // tables and model hooks share one cache and one invalidation root — never a
8
+ // parallel fetch path (the #1275 epic contract).
9
+ //
10
+ // PRESENTATION: the DEFAULT is a dropdown — a summary trigger + <Popover>
11
+ // menu sharing MultiSelectDropdown's shell (one trigger voice for both
12
+ // multi-picks; the menu stays open while ticking, Done closes). The source
13
+ // (and its FormBuilder mount) rendered an always-visible panel, which eats
14
+ // half a form column; `variant="panel"` keeps that box for settings-page /
15
+ // always-on contexts. Search defaults ON in the dropdown (a server-backed
16
+ // dropdown without search is a page-one-only picker) and OFF in the panel
17
+ // (source parity).
18
+ //
19
+ // Deliberate departures from the source, all bug-class kills:
20
+ // · Ids are compared NORMALIZED (String()) everywhere — the loose `==`
21
+ // comparisons (and the strict-equals misses elsewhere) meant string vs
22
+ // number ids worked only by luck. The value prop keeps its caller-side
23
+ // types; comparisons all pass through one keyOf().
24
+ // · SELECT adds the visible selectable rows to the value (union). The
25
+ // source REPLACED the value with the visible rows, silently dropping
26
+ // selections made under a previous search — selection is an id set that
27
+ // must survive searches/refetches. DESELECT still clears the whole value
28
+ // (source parity — it is the "empty the value" affordance).
29
+ // · The shift-click anchor resets when the row set changes (new data /
30
+ // ignoreIds change) — the source kept a stale index into a replaced
31
+ // array and could range across rows that no longer existed.
32
+ // · itemTemplate (mustache HTML string) → renderItem(row): ReactNode.
33
+ // Trusted-HTML slots end here (architecture rule 6).
34
+ // · excludeIds is NOT ported: despite its "server-side" comment it was a
35
+ // second client-side filter identical to ignoreIds. One option remains.
36
+ import {
37
+ useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState,
38
+ type KeyboardEvent as ReactKeyboardEvent, type ReactNode,
39
+ } from 'react';
40
+ import { useModelList } from '../client/hooks';
41
+ import type { ModelDef } from '../client/model';
42
+ import type { Params } from '../client/types';
43
+ import { GroupContext } from '../client/group-context';
44
+ import { Popover, type PopoverPlacement } from './Popover';
45
+
46
+ /** Every id comparison in this component goes through here — string and
47
+ * number ids (and mixes of the two) work by construction. */
48
+ const keyOf = (v: string | number): string => String(v);
49
+
50
+ /** MOJOUtils.getNestedValue heritage: dot-notation nested access. */
51
+ function getPath(obj: unknown, path: string): unknown {
52
+ let cur: unknown = obj;
53
+ for (const part of path.split('.')) {
54
+ if (cur == null || typeof cur !== 'object') return undefined;
55
+ cur = (cur as Record<string, unknown>)[part];
56
+ }
57
+ return cur;
58
+ }
59
+
60
+ // One warn per (endpoint, field) — a bad field name is a config bug, not a
61
+ // render event (unknown-value rule: fall back WITH a warn, never to nothing).
62
+ const warnedFields = new Set<string>();
63
+ function warnFieldOnce(endpoint: string, kind: 'labelField' | 'valueField', field: string, note: string): void {
64
+ const key = `${endpoint}:${kind}:${field}`;
65
+ if (warnedFields.has(key)) return;
66
+ warnedFields.add(key);
67
+ console.warn(`CollectionMultiSelect: ${kind} "${field}" ${note} (endpoint ${endpoint})`);
68
+ }
69
+
70
+ interface Item<T> {
71
+ /** Normalized identity — the one string every comparison uses. */
72
+ key: string;
73
+ /** The row's raw id, emitted through onChange when the row is picked. */
74
+ id: string | number;
75
+ label: string;
76
+ disabled: boolean;
77
+ row: T;
78
+ }
79
+
80
+ export interface CollectionMultiSelectProps<T extends { id: number | string }> {
81
+ /** A defineModel definition — supplies the endpoint (and the row type). */
82
+ model?: ModelDef<T>;
83
+ /** Bare endpoint, for lists without a model definition. */
84
+ endpoint?: string;
85
+ /** Selected ids — CONTROLLED. String/number ids compare normalized. */
86
+ value: Array<string | number>;
87
+ /** Fires with the full next id array on every commit (each toggle IS a commit). */
88
+ onChange: (ids: Array<string | number>) => void;
89
+ /**
90
+ * 'dropdown' (default): summary trigger + Popover menu — the form-embedded
91
+ * presentation. 'panel': the always-visible box (the source's shape) for
92
+ * settings-page / always-on contexts.
93
+ */
94
+ variant?: 'dropdown' | 'panel';
95
+ /** Trigger text while nothing is selected (dropdown). Default 'Select...'. */
96
+ placeholder?: string;
97
+ /** Summarize the trigger with row labels when all picks have been seen
98
+ * (else always "N selected"). Default true. */
99
+ showSelectedLabels?: boolean;
100
+ /** Above this many picks the trigger shows "N selected". Default 3. */
101
+ maxLabelsToShow?: number;
102
+ /** Menu placement against the trigger (dropdown). Default 'bottom-start'. */
103
+ placement?: PopoverPlacement;
104
+ /** Row field shown as the label; dot notation reaches nested fields. Default 'name'. */
105
+ labelField?: string;
106
+ /** Row field used as the id; dot notation reaches nested fields. Default 'id'. */
107
+ valueField?: string;
108
+ /** VISIBLE rows before the list scrolls (not the fetch size). Default 8. */
109
+ size?: number;
110
+ /** Explicit list max-height in px; defaults to size × 42 (the row height). */
111
+ maxHeight?: number;
112
+ /** Search input above the list — 400ms debounce into the `search` wire
113
+ * param. Defaults ON in the dropdown variant, OFF in the panel. */
114
+ enableSearch?: boolean;
115
+ searchPlaceholder?: string;
116
+ /**
117
+ * Extra wire params merged into every fetch — a dict, or a callback
118
+ * re-evaluated per render (each fetch uses its freshest result; a changed
119
+ * result is a new query key and refetches). Overrides the built-in
120
+ * `size: 50` page size; `group` (requiresActiveGroup) and `search` win
121
+ * over it.
122
+ */
123
+ defaultParams?: Params | (() => Params | null | undefined);
124
+ /**
125
+ * Fold the active group in as the `group` wire param. Without an active
126
+ * group the fetch is HELD (never an unscoped list where a scoped one was
127
+ * demanded) and the empty state shows.
128
+ */
129
+ requiresActiveGroup?: boolean;
130
+ /** Ids hidden from the list client-side (the "already added" pattern —
131
+ * the documented exception to the no-client-filtering rule). */
132
+ ignoreIds?: Array<string | number>;
133
+ /** Custom row content in place of the label span; the checkbox stays. */
134
+ renderItem?: (row: T) => ReactNode;
135
+ /** Per-row disabled: unclickable, skipped by SELECT and range selection. */
136
+ isRowDisabled?: (row: T) => boolean;
137
+ /** The SELECT (n) / DESELECT (n) header row. Default true. */
138
+ showSelectAll?: boolean;
139
+ label?: string;
140
+ required?: boolean;
141
+ help?: string;
142
+ error?: string;
143
+ /** Disable the whole control: trigger, search, buttons and every row. */
144
+ disabled?: boolean;
145
+ /** id for the trigger button (the label points at it). */
146
+ id?: string;
147
+ }
148
+
149
+ export function CollectionMultiSelect<T extends { id: number | string }>({
150
+ model, endpoint, value, onChange,
151
+ variant = 'dropdown',
152
+ placeholder = 'Select...',
153
+ showSelectedLabels = true, maxLabelsToShow = 3,
154
+ placement = 'bottom-start',
155
+ labelField = 'name', valueField = 'id',
156
+ size = 8, maxHeight,
157
+ enableSearch, searchPlaceholder = 'Search…',
158
+ defaultParams, requiresActiveGroup = false, ignoreIds,
159
+ renderItem, isRowDisabled, showSelectAll = true,
160
+ label, required = false, help, error, disabled = false,
161
+ id,
162
+ }: CollectionMultiSelectProps<T>) {
163
+ const resolvedEndpoint = model?.endpoint ?? endpoint;
164
+ if (!resolvedEndpoint) throw new Error('CollectionMultiSelect: pass `model` or `endpoint`');
165
+
166
+ const searchOn = enableSearch ?? (variant === 'dropdown');
167
+
168
+ // ── Search: live input → 400ms debounce → the `search` wire param ──
169
+ // (the source's SearchView debounce, verbatim 400ms — deliberately NOT
170
+ // ModelTable's 300; a picker races the user's next keystroke less.)
171
+ const [input, setInput] = useState('');
172
+ const [term, setTerm] = useState('');
173
+ useEffect(() => {
174
+ const t = setTimeout(() => setTerm(input.trim()), 400);
175
+ return () => clearTimeout(t);
176
+ }, [input]);
177
+
178
+ // ── Active group (requiresActiveGroup) ──────────────────────────────
179
+ // Read the context nullable-safely: consumers without a GroupProvider
180
+ // only misconfigure when they ASK for group scoping.
181
+ const groupCtx = useContext(GroupContext);
182
+ const activeGroupId = groupCtx?.group?.id ?? null;
183
+ const waitingForGroup = requiresActiveGroup && activeGroupId == null;
184
+ useEffect(() => {
185
+ if (requiresActiveGroup && groupCtx === null) {
186
+ console.warn('CollectionMultiSelect: requiresActiveGroup is set but there is no <GroupProvider> above — the fetch stays held');
187
+ }
188
+ }, [requiresActiveGroup, groupCtx]);
189
+
190
+ // ── The fetch — SHARED cache keys ([endpoint, params], useModelList) ──
191
+ // defaultParams may be a callback: evaluated every render, so each fetch
192
+ // carries its freshest result (the source evaluated it once at setup).
193
+ const extra = typeof defaultParams === 'function' ? (defaultParams() ?? {}) : (defaultParams ?? {});
194
+ const wire: Params = {
195
+ size: 50, // page size on the WIRE (overridable via defaultParams) — not the visible-rows prop
196
+ ...extra,
197
+ ...(requiresActiveGroup && activeGroupId != null ? { group: activeGroupId } : {}),
198
+ ...(searchOn && term ? { search: term } : {}),
199
+ };
200
+ const query = useModelList<T>(resolvedEndpoint, wire, { enabled: !waitingForGroup });
201
+ const rows = query.data?.rows;
202
+
203
+ // ignoreIds by content, not identity — an inline array literal must not
204
+ // reset the shift anchor (or churn anything) on every render.
205
+ const ignoreSig = JSON.stringify((ignoreIds ?? []).map(keyOf));
206
+ const ignoreKeys = useMemo(
207
+ () => new Set(JSON.parse(ignoreSig) as string[]),
208
+ [ignoreSig],
209
+ );
210
+
211
+ const items = useMemo<Item<T>[]>(() => {
212
+ const out: Item<T>[] = [];
213
+ for (const row of rows ?? []) {
214
+ const rawId = getPath(row, valueField);
215
+ if (rawId == null) {
216
+ warnFieldOnce(resolvedEndpoint, 'valueField', valueField, 'is missing on a row — row dropped');
217
+ continue;
218
+ }
219
+ const id: string | number = typeof rawId === 'number' || typeof rawId === 'string' ? rawId : String(rawId);
220
+ const key = keyOf(id);
221
+ if (ignoreKeys.has(key)) continue; // client-side ignoreIds filter
222
+ const rawLabel = getPath(row, labelField);
223
+ if (rawLabel == null) {
224
+ warnFieldOnce(resolvedEndpoint, 'labelField', labelField, 'is missing on a row — showing the id');
225
+ }
226
+ out.push({
227
+ key,
228
+ id,
229
+ label: rawLabel == null ? String(id) : String(rawLabel),
230
+ disabled: disabled || (isRowDisabled?.(row) ?? false),
231
+ row,
232
+ });
233
+ }
234
+ return out;
235
+ }, [rows, ignoreKeys, valueField, labelField, disabled, isRowDisabled, resolvedEndpoint]);
236
+
237
+ // Selection is an ID SET over the controlled value — never row references,
238
+ // so it survives searches and refetches by construction.
239
+ const selectedKeys = useMemo(() => new Set(value.map(keyOf)), [value]);
240
+
241
+ // ── Trigger summary labels ──────────────────────────────────────────
242
+ // Labels accumulate from every row this control has SEEN (across searches
243
+ // and refetches) — a pick made under an old term keeps its label. An id
244
+ // never seen (hydrated initial value) is NOT an error — the summary just
245
+ // says "N selected"; no per-id fetches, no wrong labels, no warn.
246
+ const [labelCache, setLabelCache] = useState<ReadonlyMap<string, string>>(new Map());
247
+ useEffect(() => {
248
+ setLabelCache((prev) => {
249
+ let changed = false;
250
+ const next = new Map(prev);
251
+ for (const it of items) {
252
+ if (next.get(it.key) !== it.label) {
253
+ next.set(it.key, it.label);
254
+ changed = true;
255
+ }
256
+ }
257
+ return changed ? next : prev;
258
+ });
259
+ }, [items]);
260
+
261
+ // ── Shift-click anchor: the last clicked row index ──────────────────
262
+ // Reset when the row set itself changes (new data, ignoreIds change) —
263
+ // indices into the old array are meaningless in the new one. Selection
264
+ // changes deliberately do NOT reset it (click, then shift-click works).
265
+ const anchorRef = useRef(-1);
266
+ useEffect(() => {
267
+ anchorRef.current = -1;
268
+ }, [rows, ignoreSig]);
269
+
270
+ const toggleRow = (index: number, shiftKey: boolean) => {
271
+ const item = items[index];
272
+ if (!item || item.disabled) return;
273
+ const anchor = anchorRef.current;
274
+ if (shiftKey && anchor >= 0 && anchor < items.length && anchor !== index) {
275
+ // Range toggle: the CLICKED row's new state applies across the
276
+ // whole span; disabled rows are skipped.
277
+ const nextSelect = !selectedKeys.has(item.key);
278
+ const start = Math.min(anchor, index);
279
+ const end = Math.max(anchor, index);
280
+ if (nextSelect) {
281
+ const have = new Set(selectedKeys);
282
+ const additions: Array<string | number> = [];
283
+ for (let i = start; i <= end; i++) {
284
+ const it = items[i]!;
285
+ if (it.disabled || have.has(it.key)) continue;
286
+ have.add(it.key);
287
+ additions.push(it.id);
288
+ }
289
+ if (additions.length > 0) onChange([...value, ...additions]);
290
+ } else {
291
+ const drop = new Set<string>();
292
+ for (let i = start; i <= end; i++) {
293
+ const it = items[i]!;
294
+ if (!it.disabled) drop.add(it.key);
295
+ }
296
+ const next = value.filter((v) => !drop.has(keyOf(v)));
297
+ if (next.length !== value.length) onChange(next);
298
+ }
299
+ } else if (selectedKeys.has(item.key)) {
300
+ onChange(value.filter((v) => keyOf(v) !== item.key));
301
+ } else {
302
+ onChange([...value, item.id]);
303
+ }
304
+ anchorRef.current = index;
305
+ };
306
+
307
+ // ── SELECT / DESELECT counts (live, disabled at their limits) ───────
308
+ // SELECT counts what it would add: visible rows that are enabled and not
309
+ // yet selected. DESELECT counts (and clears) the WHOLE value — including
310
+ // ids picked under an earlier search that aren't on screen right now.
311
+ const selectableCount = items.reduce((n, it) => n + (!it.disabled && !selectedKeys.has(it.key) ? 1 : 0), 0);
312
+ const selectedCount = value.length;
313
+
314
+ const selectAllVisible = () => {
315
+ const additions = items.filter((it) => !it.disabled && !selectedKeys.has(it.key)).map((it) => it.id);
316
+ if (additions.length > 0) onChange([...value, ...additions]);
317
+ };
318
+ const deselectAll = () => {
319
+ if (value.length > 0) onChange([]);
320
+ };
321
+
322
+ // ── Dropdown shell state (open / width / focus) ─────────────────────
323
+ const triggerRef = useRef<HTMLButtonElement>(null);
324
+ const bodyRef = useRef<HTMLDivElement>(null);
325
+ const searchRef = useRef<HTMLInputElement>(null);
326
+ const [open, setOpen] = useState(false);
327
+ // Menu width is glued to the trigger (never narrower than 260px — the
328
+ // search + action row needs the room) — set at open time so the popover's
329
+ // FIRST render is already right, then kept in sync while open.
330
+ const [menuWidth, setMenuWidth] = useState<number>();
331
+
332
+ const autoId = useId();
333
+ const menuId = `${autoId}-menu`;
334
+ const labelId = `${autoId}-label`;
335
+ const triggerId = id ?? `${autoId}-trigger`;
336
+
337
+ const close = useCallback((restoreFocus: boolean) => {
338
+ setOpen(false);
339
+ if (restoreFocus) triggerRef.current?.focus();
340
+ }, []);
341
+
342
+ const openMenu = () => {
343
+ const rect = triggerRef.current?.getBoundingClientRect();
344
+ if (rect) setMenuWidth(Math.max(rect.width, 260));
345
+ setOpen(true);
346
+ };
347
+
348
+ useLayoutEffect(() => {
349
+ if (variant !== 'dropdown' || !open) return;
350
+ const el = triggerRef.current;
351
+ if (!el) return;
352
+ const sync = () => setMenuWidth(Math.max(el.getBoundingClientRect().width, 260));
353
+ sync();
354
+ const ro = new ResizeObserver(sync);
355
+ ro.observe(el);
356
+ return () => ro.disconnect();
357
+ }, [variant, open]);
358
+
359
+ const rowEls = () => Array.from(
360
+ bodyRef.current?.querySelectorAll<HTMLElement>('[data-cms-row]:not([aria-disabled="true"])') ?? [],
361
+ );
362
+
363
+ // Opening moves focus INTO the menu (it is portaled to the end of <body>,
364
+ // so Tab from the trigger would not reach it): the search input when
365
+ // search is on, else the first enabled row. Focus returns to the trigger
366
+ // on Escape/Done (see close()).
367
+ useEffect(() => {
368
+ if (variant !== 'dropdown' || !open) return;
369
+ if (searchOn) searchRef.current?.focus();
370
+ else rowEls()[0]?.focus();
371
+ }, [variant, open, searchOn]);
372
+
373
+ const onRowKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>, index: number) => {
374
+ switch (event.key) {
375
+ case ' ':
376
+ case 'Enter':
377
+ // The SAME toggleRow() a click runs — shiftKey included, so
378
+ // Shift+Space range-selects from the keyboard too.
379
+ event.preventDefault();
380
+ toggleRow(index, event.shiftKey);
381
+ return;
382
+ case 'ArrowDown':
383
+ case 'ArrowUp': {
384
+ event.preventDefault();
385
+ const list = rowEls();
386
+ if (list.length === 0) return;
387
+ const at = list.indexOf(event.currentTarget);
388
+ list[(at + (event.key === 'ArrowDown' ? 1 : -1) + list.length) % list.length]?.focus();
389
+ return;
390
+ }
391
+ case 'Home':
392
+ case 'End': {
393
+ event.preventDefault();
394
+ const list = rowEls();
395
+ (event.key === 'Home' ? list[0] : list[list.length - 1])?.focus();
396
+ return;
397
+ }
398
+ default:
399
+ }
400
+ };
401
+
402
+ // Skeleton whenever THIS key's rows aren't on screen yet (cold load or a
403
+ // search/params change still serving the previous key via placeholder) —
404
+ // the ModelTable pattern. Background refetches of the same key keep the
405
+ // live rows.
406
+ const pendingView = !waitingForGroup && (query.isPending || query.isPlaceholderData);
407
+ const listMaxHeight = maxHeight ?? size * 42;
408
+
409
+ const showActions = showSelectAll && !pendingView && items.length > 0;
410
+
411
+ // ── The shared body: search + actions + list/skeleton/empty/error ───
412
+ // Rendered identically inside the panel box and the dropdown menu.
413
+ const body = (
414
+ <div className="collection-multiselect-body" ref={bodyRef}>
415
+ {(searchOn || showActions) && (
416
+ <div className="collection-multiselect-head">
417
+ {/* search-box-fill: the house .search-box input is a 210px
418
+ TOOLBAR control; here it must fill the menu. */}
419
+ {searchOn && (
420
+ <div className="search-box search-box-fill collection-multiselect-search">
421
+ <i className="bi bi-search" />
422
+ <input
423
+ ref={searchRef}
424
+ type="search"
425
+ value={input}
426
+ placeholder={searchPlaceholder}
427
+ disabled={disabled}
428
+ onChange={(e) => setInput(e.target.value)}
429
+ onKeyDown={(e) => {
430
+ if (e.key === 'ArrowDown') {
431
+ e.preventDefault();
432
+ rowEls()[0]?.focus();
433
+ }
434
+ }}
435
+ aria-label={label ? `Search ${label}` : 'Search'}
436
+ />
437
+ </div>
438
+ )}
439
+ {showActions && (
440
+ <div className="collection-multiselect-actions">
441
+ <button
442
+ type="button"
443
+ className="collection-multiselect-action"
444
+ disabled={disabled || selectableCount === 0}
445
+ onClick={selectAllVisible}
446
+ >
447
+ <i className="bi bi-check-square" />
448
+ SELECT{selectableCount > 0 && ` (${selectableCount})`}
449
+ </button>
450
+ <button
451
+ type="button"
452
+ className="collection-multiselect-action"
453
+ disabled={disabled || selectedCount === 0}
454
+ onClick={deselectAll}
455
+ >
456
+ DESELECT{selectedCount > 0 && ` (${selectedCount})`}
457
+ <i className="bi bi-square" />
458
+ </button>
459
+ </div>
460
+ )}
461
+ </div>
462
+ )}
463
+
464
+ {pendingView ? (
465
+ <div className="collection-multiselect-list" style={{ maxHeight: listMaxHeight }} aria-hidden="true">
466
+ {Array.from({ length: Math.min(size, 8) }).map((_, i) => (
467
+ <div key={i} className="collection-multiselect-skel">
468
+ <span className="skel collection-multiselect-skel-check" />
469
+ <span className={`skel ${i % 2 === 0 ? 'skel-w-60' : 'skel-w-40'}`} />
470
+ </div>
471
+ ))}
472
+ </div>
473
+ ) : query.isError ? (
474
+ <div className="collection-multiselect-empty collection-multiselect-error" role="alert">
475
+ <i className="bi bi-exclamation-triangle" />
476
+ <div>{query.error instanceof Error ? query.error.message : 'Request failed'}</div>
477
+ <button type="button" className="btn btn-compact" onClick={() => void query.refetch()}>
478
+ Retry
479
+ </button>
480
+ </div>
481
+ ) : items.length === 0 ? (
482
+ <div className="collection-multiselect-empty">
483
+ <i className="bi bi-inbox" />
484
+ <div>No items available</div>
485
+ </div>
486
+ ) : (
487
+ <div
488
+ className="collection-multiselect-list"
489
+ style={{ maxHeight: listMaxHeight }}
490
+ role="group"
491
+ aria-label={label}
492
+ >
493
+ {items.map((item, index) => {
494
+ const checked = selectedKeys.has(item.key);
495
+ return (
496
+ <div
497
+ key={item.key}
498
+ data-cms-row=""
499
+ role="checkbox"
500
+ aria-checked={checked}
501
+ aria-disabled={item.disabled || undefined}
502
+ tabIndex={item.disabled ? -1 : 0}
503
+ className={[
504
+ 'collection-multiselect-item',
505
+ checked ? 'is-selected' : '',
506
+ item.disabled ? 'is-disabled' : '',
507
+ ].filter(Boolean).join(' ')}
508
+ onClick={(e) => toggleRow(index, e.shiftKey)}
509
+ onKeyDown={(e) => onRowKeyDown(e, index)}
510
+ >
511
+ {/* Purely visual — pointer-events:none in CSS, so every
512
+ interaction (incl. shiftKey) lands on the row; the
513
+ display can never diverge from the controlled state. */}
514
+ <input type="checkbox" className="tbl-check" checked={checked} disabled={item.disabled} tabIndex={-1} aria-hidden="true" readOnly />
515
+ {renderItem
516
+ ? <span className="collection-multiselect-item-body">{renderItem(item.row)}</span>
517
+ : <span className="collection-multiselect-item-label">{item.label}</span>}
518
+ </div>
519
+ );
520
+ })}
521
+ </div>
522
+ )}
523
+ </div>
524
+ );
525
+
526
+ const foot = error ? (
527
+ <div className="field-error">{error}</div>
528
+ ) : help ? (
529
+ <div className="field-help">{help}</div>
530
+ ) : null;
531
+
532
+ if (variant === 'panel') {
533
+ return (
534
+ <div className={`collection-multiselect${disabled ? ' collection-multiselect-disabled' : ''}`}>
535
+ {label && (
536
+ <div className="field-label">
537
+ {label}
538
+ {required && <em> *</em>}
539
+ </div>
540
+ )}
541
+ {body}
542
+ {foot}
543
+ </div>
544
+ );
545
+ }
546
+
547
+ // Trigger summary: placeholder → up to N comma-joined labels (only when
548
+ // EVERY pick's label is known) → "N selected".
549
+ const allLabeled = value.every((v) => labelCache.has(keyOf(v)));
550
+ const triggerText = selectedCount === 0
551
+ ? placeholder
552
+ : showSelectedLabels && selectedCount <= maxLabelsToShow && allLabeled
553
+ ? value.map((v) => labelCache.get(keyOf(v))!).join(', ')
554
+ : `${selectedCount} selected`;
555
+
556
+ return (
557
+ <div className={`collection-multiselect collection-multiselect-dropdown${disabled ? ' collection-multiselect-disabled' : ''}`}>
558
+ {label && (
559
+ <div className="field-label" id={labelId}>
560
+ {label}
561
+ {required && <em> *</em>}
562
+ </div>
563
+ )}
564
+
565
+ <button
566
+ ref={triggerRef}
567
+ type="button"
568
+ id={triggerId}
569
+ className={`multiselect-trigger${open ? ' is-open' : ''}`}
570
+ disabled={disabled}
571
+ aria-haspopup="dialog"
572
+ aria-expanded={open}
573
+ aria-controls={open ? menuId : undefined}
574
+ // BOTH ids: the field label AND the trigger's own summary, so
575
+ // the accessible name reads "Groups, Ops, Design".
576
+ aria-labelledby={label ? `${labelId} ${triggerId}` : undefined}
577
+ onClick={() => (open ? close(true) : openMenu())}
578
+ onKeyDown={(event) => {
579
+ if (!open && event.key === 'ArrowDown') {
580
+ event.preventDefault();
581
+ openMenu();
582
+ }
583
+ }}
584
+ >
585
+ <span className={`multiselect-trigger-text${selectedCount === 0 ? ' is-placeholder' : ''}`}>
586
+ {triggerText}
587
+ </span>
588
+ <i className="bi bi-chevron-down" aria-hidden="true" />
589
+ </button>
590
+
591
+ <Popover
592
+ anchorRef={triggerRef}
593
+ open={open}
594
+ placement={placement}
595
+ // Escape always hands focus back; an outside click only does
596
+ // when focus is still parked inside the menu (never fight the
597
+ // element the user just clicked).
598
+ onClose={(reason) => close(
599
+ reason === 'escape' || bodyRef.current?.contains(document.activeElement) === true,
600
+ )}
601
+ id={menuId}
602
+ aria-label={label ?? 'Options'}
603
+ style={menuWidth === undefined ? undefined : { width: menuWidth }}
604
+ >
605
+ <div className="collection-multiselect-menu">
606
+ {body}
607
+ <div className="multiselect-footer">
608
+ <button
609
+ type="button"
610
+ className="btn btn-primary btn-compact multiselect-done"
611
+ onClick={() => close(true)}
612
+ >
613
+ Done
614
+ </button>
615
+ </div>
616
+ </div>
617
+ </Popover>
618
+
619
+ {foot}
620
+ </div>
621
+ );
622
+ }