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,58 @@
1
+ // portal-mojo/ui — mission-control UI: server tables, filters, schema forms,
2
+ // detail views, native-<dialog> modals, toasts, theming. Components render
3
+ // semantic classes (panel, chip, tbl, …) styled by the consuming app's
4
+ // theme.css tokens — light AND dark (reference set: apps/portal/src/theme.css).
5
+ export * from './ThemeProvider';
6
+ export * from './Guarded';
7
+ export * from './GroupSwitcher';
8
+ export * from './RequiresGroup';
9
+ export * from './menu-registry';
10
+ export * from './SidebarNav';
11
+ export * from './armed-button';
12
+ export * from './loading';
13
+ export * from './CollectionMultiSelect';
14
+ export * from './CollectionSelect';
15
+ export * from './AddressField';
16
+ export * from './ComboBox';
17
+ export * from './date/Calendar';
18
+ export * from './date/DatePicker';
19
+ export * from './date/DateRangePicker';
20
+ export * from './date/DateTimePicker';
21
+ export * from './date/PresetRail';
22
+ export * from './date/TimePicker';
23
+ export * from './date/TimezoneSelect';
24
+ export * from './MarkdownView';
25
+ export * from './RecordFeed';
26
+ export * from './RightPanel';
27
+ export * from './MultiSelectDropdown';
28
+ // Date math shares fmt's namespace pattern — collision-prone names stay scoped.
29
+ export * as dateFns from './date/fns';
30
+ export * from './detail';
31
+ export * from './modal';
32
+ export * from './password';
33
+ export * from './Popover';
34
+ export * from './TagInput';
35
+ export * from './toast';
36
+ export * from './FileDrop';
37
+ export * from './FileField';
38
+ export * from './image-editor';
39
+ export * from './UploadQueue';
40
+ export * from './AttachmentQueue';
41
+ export * from './ui';
42
+ export * from './FormFields';
43
+ export * from './FormView';
44
+ export { Tabs } from './Tabs';
45
+ export type { TabItem, TabsProps, TabVariant } from './Tabs';
46
+ export { FormWizard, formWizardModal } from './FormWizard';
47
+ export type { FormWizardModalOptions, FormWizardProps, FormWizardSection } from './FormWizard';
48
+ export * from './form-autosave';
49
+ export * from './field-registry';
50
+ export * from './field-wire';
51
+ export * from './DetailView';
52
+ export * from './FilterBar';
53
+ export * from './ModelTable';
54
+ export * from './grouping';
55
+ // Formatters keep their web-mojo pipe ergonomics via a namespace: fmt.date(…),
56
+ // fmt.relative(…), fmt.initials(…), fmt.inferTone(…).
57
+ export * as fmt from './format';
58
+ export type { Tone } from './format';
@@ -0,0 +1,604 @@
1
+ // Loaders — the "we're fetching / working" family: one spinner atom, a
2
+ // blocking full-screen overlay, a section loader, an inline loader, and a
3
+ // dim-while-mutating wrapper. Companion to (never a replacement for) the
4
+ // skeleton silhouette: skeletons stand in for CONTENT SHAPE that is about to
5
+ // arrive; loaders say WORK IS HAPPENING when there is no shape to promise.
6
+ // See packages/portal-mojo/docs/loading.md for the decision table.
7
+ //
8
+ // MERGE-WIRE: index.ts — add `export * from './loading';`
9
+ //
10
+ // Ported from web-mojo `src/core/views/feedback/BusyIndicator.js` (the only
11
+ // loading UI it had: a singleton frosted overlay, reference-counted, message
12
+ // swappable, z-index recomputed per show to clear open modals, 30s watchdog).
13
+ // Everything that survives is listed here; everything that changed is why:
14
+ //
15
+ // KEPT reference counting — nested call sites compose (show/show/hide/hide)
16
+ // KEPT newest message wins while the overlay is up
17
+ // KEPT the 30s watchdog: a caller that throws without closing must not
18
+ // wedge the app behind an overlay nobody can dismiss (console.error)
19
+ // FIXED z-index arithmetic (`modal.z + 1000`) → a native <dialog> +
20
+ // showModal(): the browser TOP LAYER paints above the whole document,
21
+ // so the overlay clears any open modal with no numbers involved —
22
+ // the same reasoning Popover.tsx uses, and the native inertness of a
23
+ // modal dialog is what makes this actually block input
24
+ // FIXED hardcoded #fff/#0d6efd/#e9ecef (light-theme only, unreadable in
25
+ // dark) → tokens in apps/portal/src/theme/loading.css, both themes
26
+ // NEW the anti-flash `delay` + `minVisible` hold. web-mojo showed the
27
+ // overlay on the same tick as the call, so every sub-100ms action
28
+ // strobed. A spinner that appears for 80ms is worse than none.
29
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
30
+
31
+ /** Nothing renders before an operation has run this long (ms). */
32
+ export const LOADING_DELAY_MS = 200;
33
+ /** Once the overlay IS up it stays at least this long — no blink-out (ms). */
34
+ export const BUSY_MIN_VISIBLE_MS = 400;
35
+ /** Watchdog: a busy ticket never left open longer than this (ms). */
36
+ export const BUSY_TIMEOUT_MS = 30_000;
37
+
38
+ const DEFAULT_BUSY_LABEL = 'Working…';
39
+ const isBrowser = typeof document !== 'undefined';
40
+
41
+ // ── The anti-flash rule ──────────────────────────────────────────────────
42
+ // Every loader in this file is gated by this hook. Two thresholds, both
43
+ // perceptual: work that finishes inside `delay` produces NO indicator at all
44
+ // (the eye reads it as instant), and an indicator that does appear stays for
45
+ // at least `minVisible` (a 20ms flash reads as a glitch, not as progress).
46
+
47
+ export interface DelayOptions {
48
+ /** Suppress the indicator until the work has run this long. Default 200ms. */
49
+ delay?: number;
50
+ /** Once shown, keep it up at least this long. Default 0 (off). */
51
+ minVisible?: number;
52
+ }
53
+
54
+ /**
55
+ * `active` with the flash filed off: true only after `active` has held for
56
+ * `delay`, and (with `minVisible`) never true for less than that once shown.
57
+ *
58
+ * ```ts
59
+ * const show = useDelayedFlag(query.isFetching); // 200ms default
60
+ * const show = useDelayedFlag(save.isPending, 400); // number = delay
61
+ * const show = useDelayedFlag(busyish, { delay: 150, minVisible: 500 });
62
+ * ```
63
+ */
64
+ export function useDelayedFlag(active: boolean, opts: number | DelayOptions = LOADING_DELAY_MS): boolean {
65
+ const delay = typeof opts === 'number' ? opts : opts.delay ?? LOADING_DELAY_MS;
66
+ const minVisible = typeof opts === 'number' ? 0 : opts.minVisible ?? 0;
67
+
68
+ const [on, setOn] = useState(() => active && delay <= 0);
69
+ // When the flag went true, so the hide side can honor minVisible without
70
+ // making `on` an effect dependency (that would restart the timers).
71
+ const shownAt = useRef(0);
72
+
73
+ useEffect(() => {
74
+ if (active) {
75
+ if (delay <= 0) {
76
+ shownAt.current = Date.now();
77
+ setOn(true);
78
+ return;
79
+ }
80
+ const timer = window.setTimeout(() => {
81
+ shownAt.current = Date.now();
82
+ setOn(true);
83
+ }, delay);
84
+ return () => window.clearTimeout(timer);
85
+ }
86
+
87
+ // Inactive. Never shown (or already hidden) → nothing to hold.
88
+ if (shownAt.current === 0) {
89
+ setOn(false);
90
+ return;
91
+ }
92
+ const held = Date.now() - shownAt.current;
93
+ if (held >= minVisible) {
94
+ shownAt.current = 0;
95
+ setOn(false);
96
+ return;
97
+ }
98
+ const timer = window.setTimeout(() => {
99
+ shownAt.current = 0;
100
+ setOn(false);
101
+ }, minVisible - held);
102
+ return () => window.clearTimeout(timer);
103
+ }, [active, delay, minVisible]);
104
+
105
+ return on;
106
+ }
107
+
108
+ // ── Spinner: the atom ────────────────────────────────────────────────────
109
+
110
+ export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg';
111
+
112
+ const SPINNER_SIZES: readonly SpinnerSize[] = ['xs', 'sm', 'md', 'lg'];
113
+ const warned = new Set<string>();
114
+
115
+ /** Unknown values fall back to a default WITH a console.warn (house rule),
116
+ * once per distinct bad value so a render loop can't flood the console. */
117
+ function normalizeSize(size: SpinnerSize | number | undefined, where: string): SpinnerSize | number {
118
+ if (size === undefined) return 'md';
119
+ if (typeof size === 'number') {
120
+ if (Number.isFinite(size) && size > 0) return size;
121
+ } else if (SPINNER_SIZES.includes(size)) {
122
+ return size;
123
+ }
124
+ const key = `${where}:${String(size)}`;
125
+ if (!warned.has(key)) {
126
+ warned.add(key);
127
+ console.warn(`${where}: unknown size ${JSON.stringify(size)} — falling back to "md"`);
128
+ }
129
+ return 'md';
130
+ }
131
+
132
+ export interface SpinnerProps {
133
+ /** 'xs' | 'sm' | 'md' | 'lg', or an explicit pixel diameter. Default 'md'. */
134
+ size?: SpinnerSize | number;
135
+ /**
136
+ * Accessible name. OMIT IT when visible text sits beside the spinner —
137
+ * the spinner is then decorative and renders `aria-hidden`. Pass it only
138
+ * when the spinner is the ONLY indication (an icon-only button).
139
+ */
140
+ label?: string;
141
+ className?: string;
142
+ }
143
+
144
+ /**
145
+ * The ring. Colored by `currentColor`, so it inherits its context (white in
146
+ * a primary button, accent in a loader, muted in a table cell).
147
+ */
148
+ export function Spinner({ size, label, className }: SpinnerProps) {
149
+ const s = normalizeSize(size, 'Spinner');
150
+ const cls = typeof s === 'number' ? 'mojo-spinner' : `mojo-spinner mojo-spinner-${s}`;
151
+ // React's CSSProperties has no index signature for custom properties.
152
+ const style = typeof s === 'number' ? ({ '--spinner-size': `${s}px` } as CSSProperties) : undefined;
153
+ return (
154
+ <span
155
+ className={`${cls}${className ? ` ${className}` : ''}`}
156
+ style={style}
157
+ // role="img" + a name, never role="status": the atom must not be a
158
+ // live region — its containers (overlay / ViewLoader / InlineLoader)
159
+ // own the announcement, and nesting live regions double-announces.
160
+ {...(label ? { role: 'img', 'aria-label': label } : { 'aria-hidden': true })}
161
+ />
162
+ );
163
+ }
164
+
165
+ // ── busy(): the blocking full-screen overlay ─────────────────────────────
166
+ // Imperative, like modal.* and toast.* — call it from any handler, close the
167
+ // handle when the work settles. Implementation notes:
168
+ //
169
+ // · ONE <dialog> element, created on demand, `showModal()`d. Top layer, so
170
+ // it paints above open modals/drawers without a z-index in sight; the rest
171
+ // of the document goes inert, which is what "blocking" has to mean for
172
+ // keyboard users too (pointer-events alone leaves Tab working).
173
+ // · Escape is swallowed (`cancel` → preventDefault): dismissible only by the
174
+ // caller. Focus returns to the pre-open element when it closes (native).
175
+ // · Reference-counted by TICKET, not by an integer: each caller owns its own
176
+ // handle, the newest live ticket's label is the one displayed, and closing
177
+ // the newest reveals the previous label instead of leaving stale text.
178
+ // · Not React-hosted on purpose: no <BusyHost/> to mount, nothing to wire
179
+ // into App.tsx, and it works from non-component code (client interceptors,
180
+ // route guards). <LoadingOverlay> below is the declarative binding.
181
+
182
+ export interface BusyOptions extends DelayOptions {
183
+ /**
184
+ * Force-close after this long with a console.error (web-mojo parity).
185
+ * Default 30s; pass 0 to disable for a legitimately unbounded operation.
186
+ */
187
+ timeout?: number;
188
+ }
189
+
190
+ export interface BusyHandle {
191
+ /** Take this operation's ticket down. Idempotent. */
192
+ close: () => void;
193
+ /** Swap the label mid-flight ('Saving…' → 'Finalizing…'). */
194
+ setLabel: (label: string) => void;
195
+ /** False once closed (by the caller or the watchdog). */
196
+ isActive: () => boolean;
197
+ }
198
+
199
+ interface BusyTicket {
200
+ label: string;
201
+ minVisible: number;
202
+ mature: boolean;
203
+ delayTimer: number | null;
204
+ watchdog: number | null;
205
+ }
206
+
207
+ let tickets: BusyTicket[] = [];
208
+ let el: HTMLDialogElement | null = null;
209
+ let labelEl: HTMLParagraphElement | null = null;
210
+ let shownAt = 0;
211
+ let holdMs = 0;
212
+ let hideTimer: number | null = null;
213
+
214
+ function ensureEl(): HTMLDialogElement | null {
215
+ if (!isBrowser) return null;
216
+ if (el) return el;
217
+ const dlg = document.createElement('dialog');
218
+ dlg.className = 'mojo-busy';
219
+ dlg.setAttribute('aria-busy', 'true');
220
+ // The ONLY way out is the caller's close(). Escape must not strand an
221
+ // operation still running behind a dismissed overlay.
222
+ dlg.addEventListener('cancel', (event) => event.preventDefault());
223
+
224
+ const card = document.createElement('div');
225
+ card.className = 'mojo-busy-card';
226
+ const ring = document.createElement('span');
227
+ ring.className = 'mojo-spinner mojo-spinner-md';
228
+ ring.setAttribute('aria-hidden', 'true');
229
+ const text = document.createElement('p');
230
+ text.className = 'mojo-busy-label';
231
+ // Polite live region: a mid-flight label swap is announced, the spinner is not.
232
+ text.setAttribute('role', 'status');
233
+ card.append(ring, text);
234
+ dlg.append(card);
235
+ document.body.appendChild(dlg);
236
+
237
+ el = dlg;
238
+ labelEl = text;
239
+ return dlg;
240
+ }
241
+
242
+ function currentLabel(): string {
243
+ return tickets.length > 0 ? tickets[tickets.length - 1]!.label : DEFAULT_BUSY_LABEL;
244
+ }
245
+
246
+ function show() {
247
+ const dlg = ensureEl();
248
+ if (!dlg) return;
249
+ if (!dlg.open) {
250
+ if (typeof dlg.showModal === 'function') {
251
+ try {
252
+ dlg.showModal();
253
+ } catch {
254
+ // Already open in some other stack — the attribute path below
255
+ // still paints (z-index fallback in loading.css).
256
+ dlg.setAttribute('open', '');
257
+ }
258
+ } else {
259
+ dlg.setAttribute('open', '');
260
+ }
261
+ shownAt = Date.now();
262
+ holdMs = tickets.find((t) => t.mature)?.minVisible ?? BUSY_MIN_VISIBLE_MS;
263
+ }
264
+ const text = currentLabel();
265
+ if (labelEl) labelEl.textContent = text;
266
+ dlg.setAttribute('aria-label', text);
267
+ }
268
+
269
+ function hide() {
270
+ if (!el) return;
271
+ if (el.open) el.close();
272
+ el.remove();
273
+ el = null;
274
+ labelEl = null;
275
+ shownAt = 0;
276
+ holdMs = 0;
277
+ }
278
+
279
+ function cancelHide() {
280
+ if (hideTimer !== null) {
281
+ window.clearTimeout(hideTimer);
282
+ hideTimer = null;
283
+ }
284
+ }
285
+
286
+ function scheduleHide() {
287
+ if (!el || !el.open) {
288
+ hide();
289
+ return;
290
+ }
291
+ if (hideTimer !== null) return;
292
+ const held = Date.now() - shownAt;
293
+ if (held >= holdMs) {
294
+ hide();
295
+ return;
296
+ }
297
+ hideTimer = window.setTimeout(() => {
298
+ hideTimer = null;
299
+ if (tickets.length === 0) hide();
300
+ }, holdMs - held);
301
+ }
302
+
303
+ function sync() {
304
+ if (!isBrowser) return;
305
+ const isOpen = el?.open === true;
306
+ // Visible once ANY ticket matures — and it stays visible while ANY ticket
307
+ // is still live, mature or not (a follow-up operation started under a live
308
+ // overlay must not blink it out while its own delay runs down).
309
+ if (tickets.some((t) => t.mature) || (isOpen && tickets.length > 0)) {
310
+ cancelHide();
311
+ show();
312
+ return;
313
+ }
314
+ scheduleHide();
315
+ }
316
+
317
+ function drop(ticket: BusyTicket) {
318
+ if (ticket.delayTimer !== null) {
319
+ window.clearTimeout(ticket.delayTimer);
320
+ ticket.delayTimer = null;
321
+ }
322
+ if (ticket.watchdog !== null) {
323
+ window.clearTimeout(ticket.watchdog);
324
+ ticket.watchdog = null;
325
+ }
326
+ if (!tickets.includes(ticket)) return false;
327
+ tickets = tickets.filter((t) => t !== ticket);
328
+ sync();
329
+ return true;
330
+ }
331
+
332
+ /**
333
+ * Show the blocking full-screen overlay. Returns a handle; the caller MUST
334
+ * close it (use `busyWhile` to make that structural).
335
+ *
336
+ * ```ts
337
+ * const b = busy('Rebuilding index…');
338
+ * try { await rebuild(); } finally { b.close(); }
339
+ * ```
340
+ *
341
+ * Use it only for work the user genuinely must wait out — a long save, an
342
+ * export, a destructive batch. Background refreshes get a `ViewLoader`, a
343
+ * skeleton, or nothing at all.
344
+ */
345
+ export function busy(label: string = DEFAULT_BUSY_LABEL, opts: BusyOptions = {}): BusyHandle {
346
+ const delay = opts.delay ?? LOADING_DELAY_MS;
347
+ const timeout = opts.timeout ?? BUSY_TIMEOUT_MS;
348
+ const ticket: BusyTicket = {
349
+ label,
350
+ minVisible: opts.minVisible ?? BUSY_MIN_VISIBLE_MS,
351
+ mature: delay <= 0,
352
+ delayTimer: null,
353
+ watchdog: null,
354
+ };
355
+ tickets = [...tickets, ticket];
356
+
357
+ if (isBrowser) {
358
+ if (!ticket.mature) {
359
+ ticket.delayTimer = window.setTimeout(() => {
360
+ ticket.delayTimer = null;
361
+ ticket.mature = true;
362
+ sync();
363
+ }, delay);
364
+ }
365
+ if (timeout > 0) {
366
+ ticket.watchdog = window.setTimeout(() => {
367
+ ticket.watchdog = null;
368
+ console.error(`busy(${JSON.stringify(ticket.label)}) ran past ${timeout}ms — force-closing. A caller is not closing its handle.`);
369
+ drop(ticket);
370
+ }, timeout);
371
+ }
372
+ }
373
+ sync();
374
+
375
+ return {
376
+ close: () => { drop(ticket); },
377
+ setLabel: (next: string) => {
378
+ ticket.label = next;
379
+ sync();
380
+ },
381
+ isActive: () => tickets.includes(ticket),
382
+ };
383
+ }
384
+
385
+ /**
386
+ * `busy()` with the close built in — the form to reach for, because a throw
387
+ * can never strand the overlay.
388
+ *
389
+ * ```ts
390
+ * await busyWhile('Preparing export…', () => mojoDownload(endpoint, params, 'csv'));
391
+ * ```
392
+ */
393
+ export async function busyWhile<T>(label: string, run: () => Promise<T> | T, opts?: BusyOptions): Promise<T> {
394
+ const handle = busy(label, opts);
395
+ try {
396
+ return await run();
397
+ } finally {
398
+ handle.close();
399
+ }
400
+ }
401
+
402
+ /** Escape hatch: drop every overlay ticket (error boundary / hard reset). */
403
+ export function closeAllBusy(): void {
404
+ for (const ticket of [...tickets]) drop(ticket);
405
+ }
406
+
407
+ export interface LoadingOverlayProps extends BusyOptions {
408
+ /** While true, the overlay is up (subject to `delay`). */
409
+ active: boolean;
410
+ /** Message on the card. Changing it swaps the text without restarting the delay. */
411
+ label?: string;
412
+ }
413
+
414
+ /**
415
+ * Declarative binding for `busy()` — same overlay, driven by a boolean:
416
+ *
417
+ * ```tsx
418
+ * <LoadingOverlay active={save.isPending} label="Saving changes…" />
419
+ * ```
420
+ *
421
+ * Renders nothing into the React tree (the overlay lives in the top layer).
422
+ */
423
+ export function LoadingOverlay({ active, label = DEFAULT_BUSY_LABEL, delay, minVisible, timeout }: LoadingOverlayProps) {
424
+ const handleRef = useRef<BusyHandle | null>(null);
425
+ const labelRef = useRef(label);
426
+
427
+ // Declared FIRST so the ref is current before the ticket effect reads it.
428
+ useEffect(() => {
429
+ labelRef.current = label;
430
+ handleRef.current?.setLabel(label);
431
+ }, [label]);
432
+
433
+ // `label` is deliberately NOT a dependency here: re-running this effect on
434
+ // a text change would close and re-open the ticket, restarting the delay.
435
+ // The effect above pushes label changes through the live handle instead.
436
+ useEffect(() => {
437
+ if (!active) return;
438
+ const handle = busy(labelRef.current, { delay, minVisible, timeout });
439
+ handleRef.current = handle;
440
+ return () => {
441
+ handle.close();
442
+ handleRef.current = null;
443
+ };
444
+ }, [active, delay, minVisible, timeout]);
445
+
446
+ return null;
447
+ }
448
+
449
+ // ── ViewLoader: the section / route loader ───────────────────────────────
450
+
451
+ export type ViewLoaderSize = 'sm' | 'md' | 'lg';
452
+
453
+ const VIEW_SIZES: readonly ViewLoaderSize[] = ['sm', 'md', 'lg'];
454
+ const VIEW_SPINNER: Record<ViewLoaderSize, SpinnerSize> = { sm: 'sm', md: 'md', lg: 'lg' };
455
+
456
+ /** Unknown size → 'md' WITH a console.warn (once per bad value). */
457
+ function normalizeViewSize(size: ViewLoaderSize | undefined): ViewLoaderSize {
458
+ if (size === undefined || VIEW_SIZES.includes(size)) return size ?? 'md';
459
+ const key = `ViewLoader:${String(size)}`;
460
+ if (!warned.has(key)) {
461
+ warned.add(key);
462
+ console.warn(`ViewLoader: unknown size ${JSON.stringify(size)} — falling back to "md"`);
463
+ }
464
+ return 'md';
465
+ }
466
+
467
+ export interface ViewLoaderProps {
468
+ /** Say what is loading — "Loading members…" beats "Loading…". */
469
+ label?: string;
470
+ /** Second line for the honestly slow ("Large exports can take a minute"). */
471
+ hint?: ReactNode;
472
+ /** Reserved height + spinner size: 'sm' (panel row) | 'md' | 'lg' (route). */
473
+ size?: ViewLoaderSize;
474
+ /** Anti-flash threshold, default 200ms. */
475
+ delay?: number;
476
+ className?: string;
477
+ }
478
+
479
+ /**
480
+ * The panel/route loader: a centered spinner + label for a surface that has
481
+ * NOTHING to show yet and no shape to promise.
482
+ *
483
+ * ```tsx
484
+ * if (query.isPending) return <ViewLoader label="Loading members…" />;
485
+ * ```
486
+ *
487
+ * If you know the shape of what's coming (rows, a detail grid, a card list),
488
+ * use the skeleton silhouette instead — see docs/loading.md.
489
+ */
490
+ export function ViewLoader({ label = 'Loading…', hint, size, delay = LOADING_DELAY_MS, className }: ViewLoaderProps) {
491
+ const show = useDelayedFlag(true, delay);
492
+ const s = normalizeViewSize(size);
493
+ const base = `mojo-viewloader mojo-viewloader-${s}${className ? ` ${className}` : ''}`;
494
+
495
+ // Pre-delay it still occupies its height — a fast panel that pops in has
496
+ // no layout jump, and a slow one doesn't push content down on arrival.
497
+ if (!show) return <div className={`${base} is-waiting`} aria-hidden="true" />;
498
+
499
+ return (
500
+ <div className={base} role="status" aria-busy="true">
501
+ <Spinner size={VIEW_SPINNER[s]} />
502
+ <span className="mojo-viewloader-label">{label}</span>
503
+ {hint != null && <span className="mojo-viewloader-hint">{hint}</span>}
504
+ </div>
505
+ );
506
+ }
507
+
508
+ /** Alias — `LoadingState` reads better inside a route's early return. */
509
+ export const LoadingState = ViewLoader;
510
+
511
+ // ── InlineLoader: a row / field / cell ───────────────────────────────────
512
+
513
+ export interface InlineLoaderProps {
514
+ /** Default 'Loading…'. Pass '' for a bare spinner (still announced). */
515
+ label?: string;
516
+ /** Anti-flash threshold, default 200ms. */
517
+ delay?: number;
518
+ className?: string;
519
+ }
520
+
521
+ /**
522
+ * Small inline "…loading" for one row, cell, or field — never centered, never
523
+ * reserving space. Renders nothing before `delay`.
524
+ */
525
+ export function InlineLoader({ label = 'Loading…', delay = LOADING_DELAY_MS, className }: InlineLoaderProps) {
526
+ const show = useDelayedFlag(true, delay);
527
+ if (!show) return null;
528
+ return (
529
+ <span
530
+ className={`mojo-inline-loader${className ? ` ${className}` : ''}`}
531
+ role="status"
532
+ // Empty label = bare spinner, which is decorative — the status
533
+ // region would otherwise announce nothing at all.
534
+ {...(label === '' ? { 'aria-label': 'Loading' } : {})}
535
+ >
536
+ <Spinner size="xs" />
537
+ {label !== '' && <span className="mojo-inline-loader-label">{label}</span>}
538
+ </span>
539
+ );
540
+ }
541
+
542
+ // ── Busy: dim + disable a region while a mutation is in flight ───────────
543
+
544
+ export interface BusyProps {
545
+ /** In-flight flag (a mutation's `isPending`). */
546
+ active: boolean;
547
+ /** Optional caption under the veil spinner. */
548
+ label?: string;
549
+ /** Anti-flash threshold for the DIM only — blocking is immediate. */
550
+ delay?: number;
551
+ /** Set false for a bare dim with no spinner (dense rows). */
552
+ spinner?: boolean;
553
+ className?: string;
554
+ children: ReactNode;
555
+ }
556
+
557
+ /**
558
+ * Wrap a form/card whose own submit is in flight: input is blocked THE INSTANT
559
+ * `active` goes true (no double-submits), while the dim + spinner wait out the
560
+ * anti-flash delay so a 60ms save doesn't strobe the panel.
561
+ *
562
+ * ```tsx
563
+ * <Busy active={save.isPending} label="Saving…">
564
+ * <SchemaForm … />
565
+ * </Busy>
566
+ * ```
567
+ *
568
+ * Blocking is the native `inert` attribute — pointer AND keyboard AND the
569
+ * accessibility tree, which `pointer-events: none` alone never covers.
570
+ */
571
+ export function Busy({ active, label, delay = LOADING_DELAY_MS, spinner = true, className, children }: BusyProps) {
572
+ const dim = useDelayedFlag(active, delay);
573
+ const contentRef = useRef<HTMLDivElement>(null);
574
+ const restoreRef = useRef<HTMLElement | null>(null);
575
+
576
+ // Going inert BLURS whatever was focused inside — the field the user was
577
+ // typing in when they hit save. Remember it, and put focus back when the
578
+ // mutation settles, but only if nothing else claimed it meanwhile (the
579
+ // user may have deliberately clicked away; yanking focus back is worse
580
+ // than losing it).
581
+ useEffect(() => {
582
+ if (active) {
583
+ const el = document.activeElement;
584
+ restoreRef.current = el instanceof HTMLElement && contentRef.current?.contains(el) ? el : null;
585
+ return;
586
+ }
587
+ const target = restoreRef.current;
588
+ restoreRef.current = null;
589
+ const parked = document.activeElement === null || document.activeElement === document.body;
590
+ if (target?.isConnected && parked) target.focus({ preventScroll: true });
591
+ }, [active]);
592
+
593
+ return (
594
+ <div className={`mojo-dim${dim ? ' is-busy' : ''}${className ? ` ${className}` : ''}`} aria-busy={active}>
595
+ <div className="mojo-dim-content" ref={contentRef} inert={active}>{children}</div>
596
+ {dim && spinner && (
597
+ <div className="mojo-dim-veil" role="status">
598
+ <Spinner size="sm" />
599
+ {label != null && label !== '' && <span className="mojo-dim-veil-label">{label}</span>}
600
+ </div>
601
+ )}
602
+ </div>
603
+ );
604
+ }