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,381 @@
1
+ // TagInput — chip-style free-text tags with a full keyboard flow, ported from
2
+ // web-mojo's TagInputView (src/core/forms/inputs/TagInput.js).
3
+ //
4
+ // THE VALUE IS A CSV STRING, both directions. django-mojo models split the
5
+ // stored string on the separator, so `onChange` hands back the joined string
6
+ // first and the array second — a component that emitted only an array would
7
+ // quietly change the wire shape. Two invariants keep that string honest:
8
+ // 1. no committed tag may contain the separator (every commit is split first);
9
+ // 2. the separator must be exactly ONE character (anything else falls back to
10
+ // "," with a console.warn — the house rule for unknown option values).
11
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
12
+
13
+ export interface TagInputProps {
14
+ /** Renders a hidden input under this name, for native form posts. */
15
+ name?: string;
16
+ /** CSV string (the wire shape) or an array — both parse to the same tags. */
17
+ value?: string | string[];
18
+ /** Fires on every commit/removal: the CSV string FIRST, then the array. */
19
+ onChange?: (value: string, tags: string[]) => void;
20
+ placeholder?: string;
21
+ /** Cap on interactive adds (default 50); also the counter's denominator. */
22
+ maxTags?: number;
23
+ allowDuplicates?: boolean;
24
+ /** One character; joins the CSV and splits incoming values (default ","). */
25
+ separator?: string;
26
+ trimTags?: boolean;
27
+ minLength?: number;
28
+ maxLength?: number;
29
+ disabled?: boolean;
30
+ /** Chips only: no input, no remove icons. */
31
+ readonly?: boolean;
32
+ className?: string;
33
+ }
34
+
35
+ // One warn per bad separator — a mis-sized separator is a config bug, not a
36
+ // render event (same policy as SchemaForm's unknown select values).
37
+ const warnedSeparators = new Set<string>();
38
+
39
+ function resolveSeparator(separator: string): string {
40
+ if (separator.length === 1) return separator;
41
+ if (!warnedSeparators.has(separator)) {
42
+ warnedSeparators.add(separator);
43
+ console.warn(`TagInput: separator ${JSON.stringify(separator)} must be exactly one character — falling back to ","`);
44
+ }
45
+ return ',';
46
+ }
47
+
48
+ /**
49
+ * String/array → tag list: split on the separator, trim, drop empties, and
50
+ * drop duplicates unless they are allowed. Array entries are split too, so an
51
+ * array can never smuggle a separator into a tag and break the CSV round-trip.
52
+ *
53
+ * Deliberately does NOT apply minLength/maxLength/maxTags: those cap what a
54
+ * user may ADD. Silently dropping stored values would lose backend data on the
55
+ * next save (web-mojo's setTags did exactly that).
56
+ */
57
+ function parseTags(
58
+ value: string | string[] | null | undefined,
59
+ separator: string,
60
+ trimTags: boolean,
61
+ allowDuplicates: boolean,
62
+ ): string[] {
63
+ const pieces = Array.isArray(value)
64
+ ? value.flatMap((v) => String(v ?? '').split(separator))
65
+ : String(value ?? '').split(separator);
66
+ const out: string[] = [];
67
+ for (const piece of pieces) {
68
+ const tag = trimTags ? piece.trim() : piece;
69
+ if (tag === '' || tag.trim() === '') continue;
70
+ if (!allowDuplicates && out.includes(tag)) continue;
71
+ out.push(tag);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ export function TagInput({
77
+ name,
78
+ value = '',
79
+ onChange,
80
+ placeholder = 'Add tags...',
81
+ maxTags = 50,
82
+ allowDuplicates = false,
83
+ separator = ',',
84
+ trimTags = true,
85
+ minLength = 1,
86
+ maxLength = 50,
87
+ disabled = false,
88
+ readonly = false,
89
+ className,
90
+ }: TagInputProps) {
91
+ const sep = resolveSeparator(separator);
92
+ const tags = useMemo(
93
+ () => parseTags(value, sep, trimTags, allowDuplicates),
94
+ [value, sep, trimTags, allowDuplicates],
95
+ );
96
+ const editable = !disabled && !readonly;
97
+
98
+ // Draft text is the ONLY local value state — it is not a tag yet. The ref
99
+ // mirrors it synchronously so blur can't commit a stale draft after a Tab
100
+ // (Tab clears the draft, then the browser moves focus in the same tick).
101
+ const [draft, setDraft] = useState('');
102
+ const draftRef = useRef('');
103
+ const setDraftValue = (next: string) => {
104
+ draftRef.current = next;
105
+ setDraft(next);
106
+ };
107
+
108
+ // A rejected add flashes here for ~3s. Boxed in an object ON PURPOSE: a
109
+ // bare string would make a repeat of the same message a no-op setState, so
110
+ // the second rejection would inherit the first one's dying timer.
111
+ const [error, setError] = useState<{ text: string } | null>(null);
112
+ const flashError = (text: string) => setError({ text });
113
+ useEffect(() => {
114
+ if (!error) return;
115
+ const t = setTimeout(() => setError(null), 3000);
116
+ return () => clearTimeout(t);
117
+ }, [error]);
118
+
119
+ // Controlled: with no onChange the field can only ever show what the parent
120
+ // already holds, so adds would silently vanish. Say so once per instance
121
+ // rather than rendering a dead control (house rule: never fail silently).
122
+ useEffect(() => {
123
+ if (!onChange && !readonly && !disabled) {
124
+ console.warn('TagInput: no onChange handler — value is controlled, so added/removed tags will not stick. Pass onChange, or mark the field readonly.');
125
+ }
126
+ // Mount-only: this is a config check, not a render event.
127
+ }, []);
128
+
129
+ const wrapRef = useRef<HTMLDivElement>(null);
130
+ const inputRef = useRef<HTMLInputElement>(null);
131
+ const chipRefs = useRef<(HTMLSpanElement | null)[]>([]);
132
+
133
+ // Focus after a removal has to wait for the re-render that drops the chip,
134
+ // so it is a request applied post-commit. -1 means the text input. The
135
+ // nonce guarantees a render even if the parent ignores onChange.
136
+ const pendingFocus = useRef<number | null>(null);
137
+ const [, bumpFocus] = useState(0);
138
+ const requestFocus = (index: number) => {
139
+ pendingFocus.current = index;
140
+ bumpFocus((n) => n + 1);
141
+ };
142
+ useLayoutEffect(() => {
143
+ const req = pendingFocus.current;
144
+ if (req === null) return;
145
+ pendingFocus.current = null;
146
+ if (req < 0) inputRef.current?.focus();
147
+ else (chipRefs.current[req] ?? inputRef.current)?.focus();
148
+ });
149
+
150
+ const emit = (next: string[]) => onChange?.(next.join(sep), next);
151
+
152
+ /**
153
+ * The single add path — every commit route (Enter, Tab, separator key,
154
+ * input-change, blur) funnels through here. Splits on the separator and on
155
+ * newlines first, so a paste of "a,b,c," becomes three tags rather than one
156
+ * tag that would re-split on the next load.
157
+ */
158
+ const commitText = (raw: string): boolean => {
159
+ if (!editable) return false;
160
+ const pieces = raw.split(sep).flatMap((p) => p.split(/[\r\n]+/));
161
+ let next = tags;
162
+ let added = false;
163
+ for (const piece of pieces) {
164
+ const tag = trimTags ? piece.trim() : piece;
165
+ if (tag.trim() === '') continue; // nothing typed — no error
166
+ if (tag.length < minLength) {
167
+ flashError(`Tag must be at least ${minLength} character${minLength === 1 ? '' : 's'}`);
168
+ continue;
169
+ }
170
+ if (tag.length > maxLength) {
171
+ flashError(`Tag must be at most ${maxLength} characters`);
172
+ continue;
173
+ }
174
+ if (!allowDuplicates && next.includes(tag)) {
175
+ flashError(`Tag "${tag}" already exists`);
176
+ continue;
177
+ }
178
+ if (next.length >= maxTags) {
179
+ flashError(`Maximum ${maxTags} tags allowed`);
180
+ break;
181
+ }
182
+ next = [...next, tag];
183
+ added = true;
184
+ }
185
+ if (added) emit(next);
186
+ return added;
187
+ };
188
+
189
+ const removeAt = (index: number): boolean => {
190
+ if (!editable) return false;
191
+ if (index < 0 || index >= tags.length) return false;
192
+ emit(tags.filter((_, i) => i !== index));
193
+ return true;
194
+ };
195
+
196
+ /** Remove + keep the keyboard flow alive: previous chip, else the input. */
197
+ const removeAndRefocus = (index: number) => {
198
+ const held = wrapRef.current?.contains(document.activeElement) ?? false;
199
+ if (!removeAt(index)) return;
200
+ if (held) requestFocus(index > 0 ? index - 1 : -1);
201
+ };
202
+
203
+ // ── input ─────────────────────────────────────────────────────────────
204
+ // Change-path commit: text ending in the separator or a newline. Covers
205
+ // pastes and soft keyboards, which never fire a usable keydown.
206
+ const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
207
+ const v = e.target.value;
208
+ const last = v.slice(-1);
209
+ if (last === sep || last === '\n' || last === '\r') {
210
+ commitText(v.slice(0, -1));
211
+ setDraftValue('');
212
+ return;
213
+ }
214
+ setDraftValue(v);
215
+ };
216
+
217
+ const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
218
+ const text = draftRef.current;
219
+ switch (e.key) {
220
+ case 'Enter':
221
+ // Only swallow Enter when it does something here; an empty
222
+ // field lets an enclosing form submit as usual.
223
+ if (text.trim()) {
224
+ e.preventDefault();
225
+ commitText(text);
226
+ setDraftValue('');
227
+ }
228
+ break;
229
+ case 'Tab':
230
+ // Deliberate deviation from web-mojo: commit but do NOT
231
+ // preventDefault, so Tab still leaves the field. The source
232
+ // trapped focus in the input for as long as it held text.
233
+ if (text.trim()) {
234
+ commitText(text);
235
+ setDraftValue('');
236
+ }
237
+ break;
238
+ case sep:
239
+ // The separator can never be part of a tag — always swallowed.
240
+ e.preventDefault();
241
+ if (text.trim()) {
242
+ commitText(text);
243
+ setDraftValue('');
244
+ }
245
+ break;
246
+ case 'Backspace':
247
+ if (text === '' && tags.length > 0) {
248
+ e.preventDefault();
249
+ removeAt(tags.length - 1);
250
+ }
251
+ break;
252
+ case 'ArrowLeft':
253
+ if (text === '' && tags.length > 0) {
254
+ e.preventDefault();
255
+ chipRefs.current[tags.length - 1]?.focus();
256
+ }
257
+ break;
258
+ case 'ArrowRight':
259
+ if (text === '' && tags.length > 0) {
260
+ e.preventDefault();
261
+ chipRefs.current[0]?.focus();
262
+ }
263
+ break;
264
+ case 'Escape':
265
+ // Clears the draft only. web-mojo also blurred; leaving focus
266
+ // put means Escape in a modal still reaches the dialog once
267
+ // there is nothing left to clear.
268
+ if (text !== '') {
269
+ e.preventDefault();
270
+ setDraftValue('');
271
+ }
272
+ break;
273
+ }
274
+ };
275
+
276
+ const onInputBlur = () => {
277
+ const text = draftRef.current;
278
+ if (text === '') return;
279
+ // Commit on blur (the house commit set is select/Enter/blur) — typed
280
+ // text is never silently dropped. Rejected adds clear too and say why,
281
+ // exactly as the Enter path does.
282
+ commitText(text);
283
+ setDraftValue('');
284
+ };
285
+
286
+ // ── chips ─────────────────────────────────────────────────────────────
287
+ const onChipKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>, index: number) => {
288
+ switch (e.key) {
289
+ case 'Backspace':
290
+ case 'Delete':
291
+ // Delete is handled too — the chip's own aria-label promises it
292
+ // (web-mojo said so but only wired Backspace).
293
+ e.preventDefault();
294
+ if (!editable) return;
295
+ if (removeAt(index)) requestFocus(index > 0 ? index - 1 : -1);
296
+ break;
297
+ case 'ArrowLeft':
298
+ e.preventDefault();
299
+ if (index === 0) inputRef.current?.focus();
300
+ else chipRefs.current[index - 1]?.focus();
301
+ break;
302
+ case 'ArrowRight':
303
+ e.preventDefault();
304
+ if (index === tags.length - 1) inputRef.current?.focus();
305
+ else chipRefs.current[index + 1]?.focus();
306
+ break;
307
+ }
308
+ };
309
+
310
+ const classes = [
311
+ 'tag-input',
312
+ disabled ? 'tag-input-disabled' : '',
313
+ readonly ? 'tag-input-readonly' : '',
314
+ className ?? '',
315
+ ].filter(Boolean).join(' ');
316
+
317
+ return (
318
+ <div className={classes}>
319
+ {/* role=group, not web-mojo's role=combobox: there is no popup to
320
+ expand, and aria-expanded="false" forever is a lie to AT. */}
321
+ <div
322
+ ref={wrapRef}
323
+ className="tag-input-wrap"
324
+ role="group"
325
+ aria-label={name ? `${name} tags` : 'Tags'}
326
+ // Clicking the wrapper's own padding must not blur the input
327
+ // (blur commits) — swallow the default focus shift and place
328
+ // focus explicitly on click instead.
329
+ onMouseDown={(e) => { if (editable && e.target === e.currentTarget) e.preventDefault(); }}
330
+ onClick={() => { if (editable) inputRef.current?.focus(); }}
331
+ >
332
+ {tags.map((tag, i) => (
333
+ <span
334
+ key={`${i}:${tag}`}
335
+ ref={(el) => { chipRefs.current[i] = el; }}
336
+ className="tag-chip"
337
+ tabIndex={0}
338
+ role="button"
339
+ aria-label={editable ? `Tag: ${tag}. Press Delete or Backspace to remove.` : `Tag: ${tag}`}
340
+ onKeyDown={(e) => onChipKeyDown(e, i)}
341
+ onClick={(e) => { e.stopPropagation(); chipRefs.current[i]?.focus(); }}
342
+ >
343
+ <span className="tag-chip-text">{tag}</span>
344
+ {editable && (
345
+ // Mouse affordance only — aria-hidden so it does not
346
+ // double the chip in the a11y tree (nesting an
347
+ // interactive element inside role=button is invalid).
348
+ <i
349
+ className="bi bi-x tag-chip-x"
350
+ aria-hidden="true"
351
+ onClick={(e) => { e.stopPropagation(); removeAndRefocus(i); }}
352
+ />
353
+ )}
354
+ </span>
355
+ ))}
356
+ {!readonly && (
357
+ <input
358
+ ref={inputRef}
359
+ type="text"
360
+ className="tag-input-field"
361
+ value={draft}
362
+ placeholder={placeholder}
363
+ disabled={disabled}
364
+ autoComplete="off"
365
+ onChange={onInputChange}
366
+ onKeyDown={onInputKeyDown}
367
+ onBlur={onInputBlur}
368
+ />
369
+ )}
370
+ </div>
371
+ {/* Native form posts get the CSV, same as web-mojo's hidden input. */}
372
+ {name && <input type="hidden" name={name} value={tags.join(sep)} />}
373
+ <div className="tag-input-foot">
374
+ <span className="tag-input-count">{tags.length}/{maxTags} tags</span>
375
+ {/* Live region stays mounted so AT announces the flash; CSS
376
+ hides it while empty (.tag-input-error:empty). */}
377
+ <span className="tag-input-error" role="status" aria-live="polite">{error?.text ?? ''}</span>
378
+ </div>
379
+ </div>
380
+ );
381
+ }
@@ -0,0 +1,52 @@
1
+ // ThemeManager port: the preference/resolved two-value model. Preference is
2
+ // 'light' | 'dark' | 'system' (persisted); resolved is what's on screen.
3
+ // Stamps data-theme on <html>; all tokens key off it (see theme.css).
4
+ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
5
+
6
+ export type ThemePref = 'light' | 'dark' | 'system';
7
+ type Resolved = 'light' | 'dark';
8
+
9
+ const STORAGE_KEY = 'mojo:theme';
10
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
11
+
12
+ function resolve(pref: ThemePref): Resolved {
13
+ return pref === 'system' ? (media.matches ? 'dark' : 'light') : pref;
14
+ }
15
+
16
+ const ThemeContext = createContext<{ pref: ThemePref; resolved: Resolved; setPref: (p: ThemePref) => void }>({
17
+ pref: 'system',
18
+ resolved: 'light',
19
+ setPref: () => {},
20
+ });
21
+
22
+ export function ThemeProvider({ children }: { children: ReactNode }) {
23
+ const [pref, setPrefState] = useState<ThemePref>(() => {
24
+ try {
25
+ const saved = localStorage.getItem(STORAGE_KEY);
26
+ return saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system';
27
+ } catch { return 'system'; }
28
+ });
29
+ const [resolved, setResolved] = useState<Resolved>(() => resolve(pref));
30
+
31
+ useEffect(() => {
32
+ const apply = () => setResolved(resolve(pref));
33
+ apply();
34
+ media.addEventListener('change', apply);
35
+ return () => media.removeEventListener('change', apply);
36
+ }, [pref]);
37
+
38
+ useEffect(() => {
39
+ document.documentElement.dataset.theme = resolved;
40
+ }, [resolved]);
41
+
42
+ const setPref = (p: ThemePref) => {
43
+ setPrefState(p);
44
+ try { localStorage.setItem(STORAGE_KEY, p); } catch { /* private mode */ }
45
+ };
46
+
47
+ return <ThemeContext.Provider value={{ pref, resolved, setPref }}>{children}</ThemeContext.Provider>;
48
+ }
49
+
50
+ export function useTheme() {
51
+ return useContext(ThemeContext);
52
+ }