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,336 @@
1
+ // ComboBox — the house autocomplete: a text input + suggestion dropdown.
2
+ // Port of web-mojo's ComboInput.js (the feature spec: descriptions, meta,
3
+ // match highlighting, relevance ranking, ARIA) merged with the one behavior
4
+ // ComboBox.js contributed (open on focus). This is the ONE combobox —
5
+ // web-mojo shipped two and the form pipeline only ever instantiated the
6
+ // poor one, silently dropping the description/meta configs fields passed.
7
+ //
8
+ // Fixed here BY CONSTRUCTION (web-mojo bugs, do not reintroduce):
9
+ // - onChange fires on COMMIT only (selection, Enter-custom, blur-settle) —
10
+ // never per keystroke. The in-progress text is a private draft.
11
+ // - allowCustom:false actually reverts: the committed value lives in the
12
+ // `value` prop, the draft is separate state — closing with unmatched text
13
+ // drops the draft and the display falls back to the committed label.
14
+ // (web-mojo overwrote its committed value per keystroke, so its "revert"
15
+ // restored the text just typed.)
16
+ // - Option identity never round-trips through DOM attributes — selection
17
+ // passes the option object in a closure, so numeric values survive
18
+ // (web-mojo strict-equalled `data-value` strings against numbers).
19
+ // - Popover is the sole outside-click/Escape owner. ComboBox owns only the
20
+ // meaning of each dismissal (settle outside, revert on Escape).
21
+ import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
22
+ import { Popover } from './Popover';
23
+
24
+ export type ComboValue = string | number;
25
+
26
+ export interface ComboOption {
27
+ value: ComboValue;
28
+ label: string;
29
+ /** Muted second line in the dropdown (rendered while `showDescription`). */
30
+ description?: string;
31
+ /** Opaque payload handed back through onSelect — rule-builder hook. */
32
+ meta?: Record<string, unknown>;
33
+ }
34
+
35
+ export interface ComboBoxProps {
36
+ /** Committed value. The input DISPLAYS the matching option's label. */
37
+ value?: ComboValue | null;
38
+ /** Bare strings normalize to { value: s, label: s }. */
39
+ options: ReadonlyArray<ComboOption | string>;
40
+ /** Fires on COMMIT only, and only when the value actually changes. */
41
+ onChange?: (value: ComboValue, option?: ComboOption) => void;
42
+ /** Fires whenever a commit resolves to an option — carries meta. */
43
+ onSelect?: (option: ComboOption) => void;
44
+ placeholder?: string;
45
+ /** Typed text not matching an option commits as-is. Default true. */
46
+ allowCustom?: boolean;
47
+ showDescription?: boolean;
48
+ /** Typing auto-opens the list only at this many chars. Default 0. */
49
+ minChars?: number;
50
+ /** Cap on rendered suggestions — and on keyboard wraparound. Default 10. */
51
+ maxSuggestions?: number;
52
+ disabled?: boolean;
53
+ readOnly?: boolean;
54
+ required?: boolean;
55
+ id?: string;
56
+ className?: string;
57
+ }
58
+
59
+ function normalizeOptions(input: ReadonlyArray<ComboOption | string>): ComboOption[] {
60
+ const out: ComboOption[] = [];
61
+ for (const raw of input) {
62
+ if (typeof raw === 'string') {
63
+ out.push({ value: raw, label: raw });
64
+ continue;
65
+ }
66
+ const opt: ComboOption = { value: raw.value, label: raw.label || String(raw.value) };
67
+ if (raw.description) opt.description = raw.description;
68
+ if (raw.meta) opt.meta = raw.meta;
69
+ out.push(opt);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Case-insensitive substring across label + value + description, ranked
75
+ exact-label, then label-startsWith, then input order (stable sort). */
76
+ function filterAndRank(options: ComboOption[], query: string): ComboOption[] {
77
+ if (!query) return options.slice();
78
+ const matches = options.filter((o) =>
79
+ o.label.toLowerCase().includes(query) ||
80
+ String(o.value).toLowerCase().includes(query) ||
81
+ (o.description !== undefined && o.description.toLowerCase().includes(query)));
82
+ const rank = (o: ComboOption): number => {
83
+ const label = o.label.toLowerCase();
84
+ if (label === query) return 0;
85
+ if (label.startsWith(query)) return 1;
86
+ return 2;
87
+ };
88
+ return matches.sort((a, b) => rank(a) - rank(b));
89
+ }
90
+
91
+ function escapeRegex(str: string): string {
92
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
93
+ }
94
+
95
+ /** Query-match highlighting as split React nodes — never HTML strings. */
96
+ function highlightLabel(label: string, query: string): ReactNode {
97
+ if (!query) return label;
98
+ const parts = label.split(new RegExp(`(${escapeRegex(query)})`, 'ig'));
99
+ if (parts.length === 1) return label;
100
+ return parts.map((part, i) => (i % 2 === 1 ? <mark key={i} className="combo-mark">{part}</mark> : part));
101
+ }
102
+
103
+ export function ComboBox({
104
+ value = null,
105
+ options,
106
+ onChange,
107
+ onSelect,
108
+ placeholder = 'Select or type...',
109
+ allowCustom = true,
110
+ showDescription = true,
111
+ minChars = 0,
112
+ maxSuggestions = 10,
113
+ disabled = false,
114
+ readOnly = false,
115
+ required = false,
116
+ id,
117
+ className,
118
+ }: ComboBoxProps) {
119
+ // `draft` is the uncommitted typed text; null = not editing, display the
120
+ // committed value's label. This split IS the one-pipeline fix.
121
+ const [draft, setDraft] = useState<string | null>(null);
122
+ const [open, setOpen] = useState(false);
123
+ const [highlight, setHighlight] = useState(-1);
124
+ const wrapRef = useRef<HTMLDivElement>(null);
125
+ const inputRef = useRef<HTMLInputElement>(null);
126
+ // Mirror of `draft`, nulled synchronously on commit/revert so the
127
+ // mousedown-outside + blur pair can both call settle() without
128
+ // double-committing (blur fires before React re-renders).
129
+ const draftRef = useRef<string | null>(null);
130
+ const warnedRef = useRef<Set<string>>(new Set());
131
+ const uid = useId();
132
+ const listId = `combo-list-${uid}`;
133
+ const optionId = (i: number) => `combo-opt-${uid}-${i}`;
134
+
135
+ const opts = useMemo(() => normalizeOptions(options), [options]);
136
+ const selected = useMemo(() => opts.find((o) => o.value === value), [opts, value]);
137
+ const query = (draft ?? '').trim().toLowerCase();
138
+ const visible = useMemo(
139
+ () => filterAndRank(opts, query).slice(0, maxSuggestions),
140
+ [opts, query, maxSuggestions],
141
+ );
142
+
143
+ // Unknown committed value under allowCustom:false is a config bug: warn
144
+ // once per (instance, value) but still display it verbatim — never
145
+ // "render nothing" (house rule 4).
146
+ if (!allowCustom && value != null && value !== '' && !selected) {
147
+ const key = String(value);
148
+ if (!warnedRef.current.has(key)) {
149
+ warnedRef.current.add(key);
150
+ console.warn(`ComboBox: value ${JSON.stringify(value)} is not among the options while allowCustom is false — displaying it verbatim`);
151
+ }
152
+ }
153
+
154
+ const canOpen = !disabled && !readOnly;
155
+ const displayValue = draft ?? (selected ? selected.label : value == null ? '' : String(value));
156
+
157
+ const setDraftBoth = (d: string | null) => { draftRef.current = d; setDraft(d); };
158
+ const close = () => { setOpen(false); setHighlight(-1); };
159
+
160
+ // ── the ONE committed pipeline ───────────────────────────────────────
161
+ const commitOption = (opt: ComboOption) => {
162
+ setDraftBoth(null);
163
+ close();
164
+ if (opt.value !== value) onChange?.(opt.value, opt);
165
+ onSelect?.(opt);
166
+ };
167
+ const commitCustom = (text: string) => {
168
+ setDraftBoth(null);
169
+ close();
170
+ if (text !== (value == null ? '' : String(value))) onChange?.(text);
171
+ };
172
+ /** Drop the draft — the display reverts to the committed value. */
173
+ const revert = () => { setDraftBoth(null); close(); };
174
+ /** Typed text equal to an option's label (or stringified value) IS that
175
+ option — the display shows labels, so labels must never commit as
176
+ raw values. */
177
+ const resolveText = (text: string): ComboOption | undefined =>
178
+ opts.find((o) => o.label === text) ?? opts.find((o) => String(o.value) === text);
179
+ /** Close-with-settlement: resolve → commit option; else custom when
180
+ allowed (including '' = cleared); else revert the display. Used by
181
+ outside click, blur, Tab and toggle-close. */
182
+ const settle = () => {
183
+ const text = draftRef.current;
184
+ if (text === null) { close(); return; }
185
+ const match = resolveText(text);
186
+ if (match) { commitOption(match); return; }
187
+ if (allowCustom) { commitCustom(text); return; }
188
+ revert();
189
+ };
190
+ // Keep the keyboard-highlighted option in view.
191
+ useEffect(() => {
192
+ if (highlight < 0) return;
193
+ document.getElementById(`combo-opt-${uid}-${highlight}`)?.scrollIntoView({ block: 'nearest' });
194
+ }, [highlight, uid]);
195
+
196
+ const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
197
+ switch (e.key) {
198
+ case 'ArrowDown':
199
+ if (!canOpen) break;
200
+ e.preventDefault();
201
+ if (!open) setOpen(true);
202
+ else if (visible.length > 0) setHighlight((h) => (h + 1) % visible.length);
203
+ break;
204
+ case 'ArrowUp':
205
+ if (!open) break;
206
+ e.preventDefault();
207
+ if (visible.length > 0) setHighlight((h) => (h <= 0 ? visible.length - 1 : h - 1));
208
+ break;
209
+ case 'Enter':
210
+ if (open && highlight >= 0 && highlight < visible.length) {
211
+ e.preventDefault();
212
+ commitOption(visible[highlight]);
213
+ } else if (allowCustom && draftRef.current) {
214
+ e.preventDefault();
215
+ const match = resolveText(draftRef.current);
216
+ if (match) commitOption(match);
217
+ else commitCustom(draftRef.current);
218
+ } else if (open) {
219
+ // Nothing highlighted, nothing committable — just close;
220
+ // the draft stays for further editing, blur settles it.
221
+ e.preventDefault();
222
+ close();
223
+ }
224
+ break;
225
+ case 'Tab':
226
+ // Close and pass through — no preventDefault, focus moves on.
227
+ if (open || draftRef.current !== null) settle();
228
+ break;
229
+ }
230
+ };
231
+
232
+ const onToggle = () => {
233
+ if (open) { settle(); return; }
234
+ // Open-with-cleared-filter: show ALL options. The committed label
235
+ // stays visible but text-selected, so the next keystroke starts a
236
+ // fresh query (web-mojo blanked the input instead, which left an
237
+ // empty-looking display over a committed value).
238
+ setDraftBoth(null);
239
+ setHighlight(-1);
240
+ setOpen(true);
241
+ inputRef.current?.focus();
242
+ inputRef.current?.select();
243
+ };
244
+
245
+ const emptyText = allowCustom && (draft ?? '').length >= minChars
246
+ ? (draft ? 'No matches found. Press Enter to use custom value.' : 'Start typing to see suggestions...')
247
+ : 'No matching options found.';
248
+ const emptyIcon = allowCustom && (draft ?? '').length >= minChars ? 'bi-info-circle' : 'bi-search';
249
+
250
+ return (
251
+ <div className={`combo${className ? ` ${className}` : ''}`} ref={wrapRef}>
252
+ <div className="combo-input-wrap">
253
+ <input
254
+ ref={inputRef}
255
+ id={id}
256
+ type="text"
257
+ className="input combo-input"
258
+ role="combobox"
259
+ aria-expanded={open}
260
+ aria-autocomplete="list"
261
+ aria-controls={listId}
262
+ aria-activedescendant={open && highlight >= 0 ? optionId(highlight) : undefined}
263
+ autoComplete="off"
264
+ placeholder={placeholder}
265
+ value={displayValue}
266
+ disabled={disabled}
267
+ readOnly={readOnly}
268
+ required={required}
269
+ onFocus={() => { if (canOpen) setOpen(true); }}
270
+ onBlur={(e) => {
271
+ if (wrapRef.current && e.relatedTarget && wrapRef.current.contains(e.relatedTarget as Node)) return;
272
+ settle();
273
+ }}
274
+ onChange={(e) => {
275
+ // Draft only — the committed pipeline never runs here.
276
+ const v = e.target.value;
277
+ setDraftBoth(v);
278
+ setHighlight(-1);
279
+ if (!canOpen) return;
280
+ if (v.length >= minChars) setOpen(true);
281
+ else close();
282
+ }}
283
+ onKeyDown={onKeyDown}
284
+ />
285
+ {canOpen && (
286
+ <button
287
+ type="button"
288
+ className="combo-toggle"
289
+ tabIndex={-1}
290
+ aria-label="Toggle dropdown"
291
+ onMouseDown={(e) => e.preventDefault() /* keep input focus */}
292
+ onClick={onToggle}
293
+ >
294
+ <i className="bi bi-chevron-down" aria-hidden="true" />
295
+ </button>
296
+ )}
297
+ </div>
298
+ <Popover
299
+ anchorRef={wrapRef}
300
+ open={open}
301
+ onClose={(reason) => reason === 'escape' ? revert() : settle()}
302
+ id={listId}
303
+ className="combo-pop"
304
+ role="listbox"
305
+ >
306
+ {visible.length === 0 ? (
307
+ <div className="combo-empty">
308
+ <i className={`bi ${emptyIcon}`} aria-hidden="true" />
309
+ <span>{emptyText}</span>
310
+ </div>
311
+ ) : visible.map((opt, i) => {
312
+ const isSelected = opt.value === value;
313
+ return (
314
+ <div
315
+ key={`${typeof opt.value}:${String(opt.value)}`}
316
+ id={optionId(i)}
317
+ role="option"
318
+ aria-selected={isSelected}
319
+ className={`combo-option${i === highlight ? ' combo-hot' : ''}${isSelected ? ' combo-selected' : ''}`}
320
+ onMouseDown={(e) => e.preventDefault() /* option click must not blur-settle first */}
321
+ onClick={() => commitOption(opt)}
322
+ >
323
+ <div className="combo-option-main">
324
+ <div className="combo-option-label">{highlightLabel(opt.label, (draft ?? '').trim())}</div>
325
+ {showDescription && opt.description !== undefined && (
326
+ <div className="combo-option-desc">{opt.description}</div>
327
+ )}
328
+ </div>
329
+ {isSelected && <i className="bi bi-check combo-check" aria-hidden="true" />}
330
+ </div>
331
+ );
332
+ })}
333
+ </Popover>
334
+ </div>
335
+ );
336
+ }