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,98 @@
1
+ # grouping helpers + formatters
2
+
3
+ ## groupBy* helpers
4
+
5
+ ```ts
6
+ import { groupByDay, groupByField, groupByRecency, groupByBoolean } from 'portal-mojo/ui';
7
+ ```
8
+
9
+ Each returns `{groupBy, groupHeaderLabel}` — spread straight into
10
+ ModelTable props: `{...groupByRecency<User>('last_activity')}`. Keys are
11
+ STABLE bucket ids (sort-ordered where it matters); labels format
12
+ separately. A null key = ungrouped tail (no header; the prior section
13
+ continues). All date helpers parse through `date/fns detectTemporal`, so
14
+ they accept **every** shape django-mojo emits: epoch seconds, epoch
15
+ milliseconds, either as a numeric string (JSONField metadata routinely
16
+ carries these), `'YYYY-MM-DD'`, `'YYYY-MM'`, `'YYYY'`, full ISO with `Z` or
17
+ an offset, IANA-tailed wall times, and `Date`. A 4-digit numeric string is
18
+ read as a **year**, never an epoch. Anything unrecognizable degrades to the
19
+ fallback — a formatter never throws.
20
+
21
+ | Helper | Buckets |
22
+ |---|---|
23
+ | `groupByDay(field \| accessor)` | local `YYYY-MM-DD`; labels Today / Yesterday / Apr 25 / Dec 19, 2025 |
24
+ | `groupByRecency(field)` | Today / Yesterday / This week / This month / Earlier this year / Older — keys sort so `-date` order reads top-down |
25
+ | `groupByField(field, {labels?, format?, fallback?})` | categorical on `String(raw)`; `labels` wins over `format`; `fallback` names the empty bucket (default: tail). `0`/`false` DO bucket |
26
+ | `groupByBoolean(field, {trueLabel?, falseLabel?})` | binary; string-false forms (`'false','0','no','off'`) coerce false; missing → tail |
27
+
28
+ Align the grouped field with the table's sort — grouping runs in render
29
+ order, so a mismatched sort repeats headers per key change (defined
30
+ behavior).
31
+
32
+ ## fmt
33
+
34
+ ```ts
35
+ import { fmt } from 'portal-mojo/ui';
36
+ ```
37
+
38
+ The complete `DataFormatter.js` FUNCTION set, typed. Deliberately absent:
39
+ the **pipe-string parser** (`"filesize|muted"` — do-not-recreate: it defeats
40
+ the type checker and was the source's silent-failure vector) and the
41
+ **HTML-emitting** formatters (badge/avatar/linkify/clipboard/tooltip/status)
42
+ — those are components, not formatters.
43
+
44
+ ### Dates
45
+
46
+ | Fn | Notes |
47
+ |---|---|
48
+ | `fmt.date(v, fallback='—')` / `fmt.datetime(v, fallback='—')` | `MM/DD/YYYY` / `Mon D, YYYY h:mm` — accepts **epoch seconds** (the django-mojo wire shape), ms, ISO, Date |
49
+ | `fmt.relative(v, fallback='Never')` | "3 weeks ago" style |
50
+ | `fmt.relativeShort(v, fallback='—')` | compact age for chips: `now` / `5h` / `3d` / `3w` / `2mo` / `1y`. **Magnitude only** — a future date reads the same as a past one (use `relative` when direction matters). Source boundaries kept (`>`, not `>=`): 7d is `7d`, 8d is `1w` |
51
+
52
+ ### Numbers
53
+
54
+ | Fn | Notes |
55
+ |---|---|
56
+ | `fmt.number(v, decimals=0, fallback='—')` | thousands separators: `1234567` → `1,234,567`. *Deviation:* source defaulted to 2 decimals; portal columns are counts |
57
+ | `fmt.currency(v, code='USD', {unit, decimals, fallback})` | `129900` → `$1,299.00`. **Input is CENTS by default** — django-mojo stores money as integer minor units (`cents_to_currency`) so it never touches a float; pass `{unit:'major'}` for a dollars value or a zero-decimal currency (JPY). *Deviation:* arg 2 is an ISO **code**, not a symbol — the source's own callers passed `currency("EUR")` and got `EUR1,299.00`. An unknown code warns once and falls back to USD |
58
+ | `fmt.percent(v, decimals=0, multiply=true, fallback='—')` | `0.856` → `86%`; `multiply=false` when the value is already a percentage |
59
+ | `fmt.compact(v, decimals=1, fallback='—')` | `1234` → `1.2K`, `3400000` → `3.4M`. Uppercase K/M/B (source casing); caps at B, so `1e12` reads `1000.0B`. Below 1,000 the raw number passes through unseparated |
60
+ | `fmt.filesize(v, binary=false, decimals=1, fallback='—')` | `1536` → `1.5 KB`; `binary` switches to 1024/`KiB`. Whole bytes stay integral (`999 B`); caps at TB. Negatives degrade readably (`-500 B`) |
61
+ | `fmt.ordinal(v, suffixOnly=false, fallback='—')` | `3` → `3rd`, `11` → `11th`. *Deviation:* the suffix is computed on the absolute value, so `-1` → `-1st` (source made every negative `th`) |
62
+
63
+ ### Strings
64
+
65
+ | Fn | Notes |
66
+ |---|---|
67
+ | `fmt.truncate(v, length=50, suffix='...', fallback='—')` | cuts to `length` **then** appends the suffix, so the result runs longer than `length` (source behavior) |
68
+ | `fmt.truncateMiddle(v, size=8, replace='***', fallback='—')` | keeps both ends: `d41d***427e` — ids stay recognizable. `size` is the TOTAL kept, split in half |
69
+ | `fmt.truncateFront(v, length=8, prefix='...', fallback='—')` | keeps only the TAIL: `...2eZvKY` |
70
+ | `fmt.mask(v, char='*', showLast=4, fallback='—')` | all-but-last-4: `************1111`. Values no longer than `showLast` return intact. *Fixes a source bug:* `showLast:0` masked the string and re-appended it whole (`slice(-0)` is `slice(0)`) |
71
+ | `fmt.slug(v, separator='-')` | **fallback is `''`**, not `—` (a slug of nothing is nothing). *Deviations:* accents fold to ASCII (`Café` → `cafe`, matching django-mojo's `strip_accents`) instead of being dropped (`caf`); the separator is regex-escaped — the source interpolated it raw, so `slug(v,'.')` misbehaved and `slug(v,'')` threw on an empty quantifier |
72
+ | `fmt.phone(v, fallback='—')` | **DISPLAY ONLY.** E.164 (`+15555550142`) stays the wire shape — django-mojo REJECTS pretty formats on save, so never round-trip this into a field. US 10/11-digit → `(555) 555-0142` / `+1 (555) 555-0142`; anything else (a real international number) is returned trimmed and unchanged. *Deviation:* the source stripped the leading `+`, turning valid E.164 into digit soup. No `<a href="tel:">` — links are components |
73
+
74
+ ### Duration + booleans
75
+
76
+ | Fn | Notes |
77
+ |---|---|
78
+ | `fmt.duration(v, unit='ms', {short, precision, fallback})` | `8040000` → `2h 14m`; `duration(8040,'s')` is the same. `unit` ∈ `ms\|s\|m\|h\|d` (unknown → `ms` + one `console.warn`). `precision` (default 2) caps the unit count; sub-second values report `500ms`. *Deviations:* short form is the DEFAULT (source: long — get it with `{short:false}` → `2 hours 14 minutes`) and its parts are space-separated (source: `2h14m`) |
79
+ | `fmt.yesNo(v, {yes, no, fallback})` | `Yes`/`No`; string-false forms (`'false','0','no','off'`) and empty arrays/objects read false — the same coercion table as `groupByBoolean`. **null/undefined/`''` → `—`**: unset is not the same answer as "No" |
80
+
81
+ ### Tone
82
+
83
+ | Fn | Notes |
84
+ |---|---|
85
+ | `fmt.initials(name)` | 1–2 letters; null-safe (`'?'` for empty — live rows carry `display_name: null`) |
86
+ | `fmt.inferTone(value)` | status text → Badge tone ('active'→success, 'banned'→danger, booleans, …) |
87
+
88
+ Formatters NEVER throw on bad input — they degrade (rule: a formatter is
89
+ the last thing allowed to take down a row). Every one is null/undefined-safe,
90
+ parses strictly (`"12 items"` is NOT 12 — it degrades, unlike the source's
91
+ `parseFloat`), and formats at a fixed `en-US` locale rather than the
92
+ browser's. The trailing `fallback` param (or `opts.fallback`) overrides the
93
+ `—`; pass `''` when composing strings rather than filling a cell.
94
+
95
+ Live gallery: **Develop → Components → Formatters**.
96
+
97
+ `Badge`: `<Badge tone="success|warning|danger|info|muted|primary">` from
98
+ `portal-mojo/ui` — the standard status chip.
package/docs/idioms.md ADDED
@@ -0,0 +1,112 @@
1
+ # UX idioms — armed button · undo toast · progress toast
2
+
3
+ ```ts
4
+ import { ArmedButton } from 'portal-mojo/ui'; // component
5
+ import { toast, undoToast, progressToast } from 'portal-mojo/ui';
6
+ // toast.undo === undoToast · toast.progress === progressToast
7
+ ```
8
+
9
+ Ported from maestro's workspaces portal (`workspaces/js/dom.js`), these three
10
+ are the house grammar for **action feedback by reversibility**:
11
+
12
+ | The action is… | Use | Never |
13
+ |---|---|---|
14
+ | Irreversible, no input needed | `<ArmedButton>` two-step | a confirm dialog |
15
+ | Irreversible, needs input (a reason) | `formModal` flow | armed button (it can't collect input) |
16
+ | Reversible | act IMMEDIATELY + `toast.undo` | a confirm dialog |
17
+ | Long-running | `toast.progress` handle | fire-and-forget with a success toast at the end |
18
+
19
+ Live demo: portal → Develop → Components → **UX idioms**. First real
20
+ consumers: ApiKeysPage (revoke = armed, disable = undo), UserDetail
21
+ (revoke-all-sessions = armed).
22
+
23
+ ## ArmedButton
24
+
25
+ ```tsx
26
+ <ArmedButton
27
+ label="Revoke key"
28
+ armedLabel="Click again — token dies now"
29
+ onConfirm={() => revoke.mutateAsync({ id })} // may be async
30
+ icon="bi-trash" // optional, resting state only
31
+ className="btn-compact" // appended to `btn armed-btn`
32
+ disarmMs={6000} // auto-disarm window (source parity: 6s)
33
+ title="…" // defaults to string labels
34
+ disabled={false}
35
+ />
36
+ ```
37
+
38
+ Behavior contract (dom.js `armedButton` parity):
39
+
40
+ - First click **arms**: danger fill, warning icon, `armedLabel` shown. The
41
+ armed label MUST name the blast radius ("Click again — signs out every
42
+ device"), not repeat the verb.
43
+ - Second click disarms first, then awaits `onConfirm`. Re-clicks while the
44
+ promise is pending are ignored (button disables).
45
+ - **Auto-disarm** after `disarmMs`; `Escape` disarms; unmount disarms (state
46
+ dies with the node — a re-render that drops the button can never leave a
47
+ live trigger). Fail-safe cost is one extra click, never an unintended fire.
48
+ - Clicks `stopPropagation()` — safe inside clickable rows / expand rows.
49
+
50
+ Styling: `apps/portal/src/theme/idioms.css` — `.armed-btn` (rest) /
51
+ `.armed-btn.is-armed` (armed, pulsing; static under
52
+ `prefers-reduced-motion`). Tokens only, both themes; the dark theme swaps
53
+ the armed text color for contrast against the light --bad.
54
+
55
+ ## toast.undo / undoToast
56
+
57
+ ```ts
58
+ const handle = toast.undo('Key disabled', () => reenable(), { timeout: 6000 });
59
+ handle.dismiss(); // optional early resolution
60
+ ```
61
+
62
+ - **The action has already happened** when the toast shows — do the mutation
63
+ first, then offer Undo. This replaces `confirm()` for reversible actions.
64
+ - Clicking **Undo** dismisses at once and calls `onUndo`. Otherwise the card
65
+ auto-dismisses after `timeout` (default 6000ms) and the action stands.
66
+ - `onUndo` typically fires the inverse mutation; surface ITS failure with a
67
+ normal `toast.error` — the undo toast itself is done by then.
68
+
69
+ ## toast.progress / progressToast
70
+
71
+ ```ts
72
+ const p = toast.progress('Uploading export.csv…', { onCancel: () => abort() });
73
+ p.update(42); // clamped + rounded to 0–100
74
+ p.finalizing('Finishing upload'); // → 100% + spinner, cancel hidden; still unsettled
75
+ p.done('Uploaded'); // → 100% + ✓, auto-removes after 1.4s
76
+ p.fail('Cancelled'); // → red + ✕, auto-removes after 5s
77
+ p.remove(); // immediate removal
78
+ ```
79
+
80
+ - **Persistent**: never auto-dismisses while active — the caller drives it to
81
+ `done` / `fail` / `remove`. After the first of those, the handle is settled
82
+ and later calls no-op.
83
+ - `onCancel` renders a ✕ **only while active**. Clicking it calls `onCancel`
84
+ and nothing else — the CALLER aborts its operation and then settles the
85
+ toast (usually `fail('Cancelled')`). Cancel is a request, not a dismissal.
86
+ - `finalizing()` is additive and non-settling: it shows a spinner at 100% and
87
+ removes cancellation while an authoritative server read or consumer
88
+ callback completes. The caller must still finish with `done` or `fail`.
89
+ - Basic toasts stay capped at 5; undo/progress cards are exempt from the cap
90
+ (evicting a live progress bar would orphan the operation's only indicator).
91
+
92
+ ## Invariants
93
+
94
+ - All three render through the one `<ToastHost />` / store — mount nothing
95
+ new. Every existing `toast.success/error/info/warning` behavior (3.5s life,
96
+ newest-last, cap of 5) is byte-for-byte unchanged.
97
+ - Handles are safe to call after settlement/dismissal — every method no-ops
98
+ rather than throwing or resurrecting a card.
99
+ - `ArmedButton` is a plain `<button>` — no portal, no dialog; it composes
100
+ with rows, expand panels and modals without z-index ceremony.
101
+
102
+ ## Pitfalls
103
+
104
+ - Don't pair `ArmedButton` with a follow-up `modal.confirm` — the arming IS
105
+ the confirmation; stacking both is the friction the idiom removes.
106
+ - Don't `toast.undo` an action you haven't performed yet ("undo" that
107
+ actually means "cancel a pending thing" is a lie — use the progress
108
+ toast's cancel for that).
109
+ - Don't reuse a settled progress handle for a second operation — mint a new
110
+ one per run.
111
+ - The undo window is UX grace, not a transaction: if the inverse mutation
112
+ can fail, handle its rejection (the demo and ApiKeysPage show the shape).
@@ -0,0 +1,101 @@
1
+ # Image editor
2
+
3
+ Import from `portal-mojo/ui`:
4
+
5
+ ```tsx
6
+ import { ImageEditor, imageEditorModal, type ImageEditorResult } from 'portal-mojo/ui';
7
+ ```
8
+
9
+ `ImageEditor` is the dependency-free crop/transform/filter editor. It accepts a
10
+ controlled `source` (`Blob`, `File`, or URL), reports only through `onSave`, and
11
+ never triggers a browser file download. A URL source is fetched before decode:
12
+ same-origin requests include same-origin credentials, while cross-origin URLs
13
+ remain subject to CORS and receive no credentials. Source/capability URLs are
14
+ never echoed in editor errors. `imageEditorModal(source, options)` is the
15
+ awaitable native dialog form: it resolves an `ImageEditorResult` on save and
16
+ `null` on Cancel, Escape, backdrop dismissal, or owner-signal abort.
17
+
18
+ ```ts
19
+ interface ImageEditorResult {
20
+ blob: Blob; // image/png, alpha preserved
21
+ filename: string;
22
+ width: number;
23
+ height: number;
24
+ cropData: CropData | null; // logical pixels at the crop's input stage
25
+ operations: readonly ImageEditorOperation[];
26
+ }
27
+ ```
28
+
29
+ ```tsx
30
+ const result = await imageEditorModal(file, {
31
+ startMode: 'crop',
32
+ filename: file.name,
33
+ crop: { aspectRatio: 1, cropAndScale: { width: 200, height: 200 } },
34
+ });
35
+ if (result) upload(new File([result.blob], result.filename, { type: 'image/png' }));
36
+ ```
37
+
38
+ Options are `filename`, `startMode`, `modes`, `crop`, `initialOperations`,
39
+ `maxHistory` (1–20), and `saveText`. Modal use additionally accepts `title`,
40
+ `size` (`sm`, `md`, or `lg`), and an `AbortSignal` as `signal`; abort resolves
41
+ the modal with `null`. Embedded use additionally supplies `onSave`, optional
42
+ `onCancel`, `disabled`, and `onBusyChange`.
43
+
44
+ ## Low-level exports
45
+
46
+ The framework-free math and raster contracts are intentionally public from the
47
+ same `portal-mojo/ui` subpath for non-React composition and deterministic
48
+ verification:
49
+
50
+ - Geometry/state types include `ImageSize`, `ImagePoint`, `ImageRect`,
51
+ `CropData`, `CropOptions`, `TransformState`, `FilterState`,
52
+ `ImageEditorOperation`, and `ImageEditorSnapshot`. The corresponding defaults,
53
+ limits, crop handles, presets, and filter order are exported constants.
54
+ - Geometry helpers cover canvas/image mapping, crop initialize/constrain/move/
55
+ resize, transform clamp/rotate/zoom/output sizing, filter composition,
56
+ operation output/crop inspection, and immutable history commits.
57
+ - Raster helpers expose `PixelSurface`, `filterPixels`,
58
+ `applyOperationsToPixels`, `decodeImageSource`, `drawPixelSurface`,
59
+ `pixelSurfaceToBlob`, and `renderImageOperations`. `decodeImageSource`, canvas
60
+ drawing, and Blob encoding require browser APIs; callers that retain a
61
+ returned `DecodedImage` must call its `dispose()`.
62
+
63
+ These helpers operate on logical pixels and do not mount UI, upload Files,
64
+ persist state, or initiate a browser download. `decodeImageSource` is the one
65
+ exception that performs the URL fetch described above.
66
+
67
+ ## Composition and history
68
+
69
+ Operations are immutable and chronological. Transform creates a logical
70
+ full-resolution output canvas; pan is measured in logical image pixels and is
71
+ unchanged by a preview resize. Crop consumes the current working bitmap in
72
+ source-pixel coordinates and either retains its natural crop dimensions or
73
+ emits exact `cropAndScale` dimensions. Filters consume the current full working
74
+ bitmap after every prior operation. The preview's CSS size and device-pixel
75
+ ratio never participate in export.
76
+
77
+ Apply commits one snapshot. Switching modes commits only a dirty mode. Pointer
78
+ drags and slider movement update one draft, so they coalesce into one history
79
+ entry. Undo/redo crosses committed operations, a divergent commit truncates
80
+ redo, Reset returns to the decoded source, and at most 20 snapshots are kept.
81
+
82
+ ## Source-derived invariants
83
+
84
+ - Crop defaults to the centered 80% box; the minimum is 50 source pixels.
85
+ - Transform scale is clamped to 0.1–5 with the legacy 0.02 step; rotations
86
+ normalize to 0–359 degrees.
87
+ - Filter defaults, preset values, and application order are carried from
88
+ web-mojo: brightness → contrast → saturation → hue → blur → grayscale →
89
+ sepia. Unknown modes/presets warn and fall back.
90
+ - Invalid/non-finite ratios, dimensions, histories, and operations reject at
91
+ the React boundary. Decode/CORS/encoding failures use generic errors that do
92
+ not echo source or capability URLs and retain the prior decoded source and
93
+ history.
94
+ - Owned object URLs and image bitmaps are released on replacement/unmount;
95
+ async decode/save work is generation guarded. PNG encoding is explicit and
96
+ preserves alpha. Crop overlays never enter the output bitmap.
97
+
98
+ For image relations, `ImageField` can opt into `edit={{ crop: ... }}`. Editing
99
+ runs before the existing upload queue: Cancel or editor failure keeps the
100
+ selected original available, and no File id or owner attachment exists until
101
+ the edited PNG File enters that queue.
@@ -0,0 +1,242 @@
1
+ # Loaders — spinner · busy overlay · view loader · inline · Busy
2
+
3
+ ```ts
4
+ import {
5
+ Spinner, ViewLoader, LoadingState, InlineLoader, Busy, LoadingOverlay,
6
+ busy, busyWhile, closeAllBusy, useDelayedFlag,
7
+ LOADING_DELAY_MS, BUSY_MIN_VISIBLE_MS, BUSY_TIMEOUT_MS,
8
+ } from 'portal-mojo/ui';
9
+ // LoadingState === ViewLoader (alias — reads better in a route's early return)
10
+ ```
11
+
12
+ The "we're fetching / working" family. It **complements the skeleton
13
+ silhouette**, which stays the default for anything whose shape is already
14
+ known (see the decision table below). Live demo: portal → Develop →
15
+ Components → **Loaders**. Styling: `apps/portal/src/theme/loading.css` —
16
+ tokens only, both themes.
17
+
18
+ Ported from web-mojo `src/core/views/feedback/BusyIndicator.js` (its only
19
+ loading UI: a singleton frosted overlay behind `Modal.loading()` /
20
+ `app.showLoading()`, used by ~30 admin call sites). Reference counting, the
21
+ newest-message-wins label and the 30s watchdog are kept; the z-index
22
+ arithmetic, the hardcoded light-theme palette and the show-on-the-same-tick
23
+ behavior are not.
24
+
25
+ ## The anti-flash rule (the design idea)
26
+
27
+ > **An indicator that appears for 80ms is worse than none.** It reads as a
28
+ > glitch, and it makes fast software look broken.
29
+
30
+ Every loader here is gated by `useDelayedFlag`:
31
+
32
+ - nothing renders until the work has run **`LOADING_DELAY_MS` (200ms)** —
33
+ work that finishes inside that window produces no indicator at all;
34
+ - the overlay, once up, stays at least **`BUSY_MIN_VISIBLE_MS` (400ms)**, so
35
+ it can't blink out either.
36
+
37
+ Both are per-call overridable (`delay`, `minVisible`). `delay: 0` opts out —
38
+ correct for demos and for a route that is *known* to be slow.
39
+
40
+ ```ts
41
+ const show = useDelayedFlag(query.isFetching); // 200ms default
42
+ const show = useDelayedFlag(save.isPending, 400); // number = delay
43
+ const show = useDelayedFlag(active, { delay: 150, minVisible: 500 });
44
+ ```
45
+
46
+ ## Skeleton vs loader — which one
47
+
48
+ | Situation | Use |
49
+ |---|---|
50
+ | Table rows, detail grids, card lists — **shape known** | **Skeleton** (`.skel` silhouette: `skel-avatar`, `skel-stack`, `skel-w-60`, `skel-pill`) |
51
+ | A panel or route with nothing to show yet, shape unknown | `<ViewLoader label="Loading members…" />` |
52
+ | One row / cell / field resolving on its own | `<InlineLoader />` |
53
+ | A form or card whose own mutation is in flight | `<Busy active={save.isPending}>` |
54
+ | Work the user must wait out (export, long save, batch) | `busy()` / `<LoadingOverlay>` |
55
+ | Long work with a real percentage | `toast.progress` — see [idioms.md](./idioms.md) |
56
+ | Background refresh of data already on screen | **Nothing** (or the toolbar refresh spin) |
57
+
58
+ Rules of thumb: a skeleton **promises a shape**, so never render one for
59
+ data whose shape you can't predict (a report, an export, a search that may
60
+ return nothing). A loader **says work is happening**, so never render one
61
+ over content that is already on screen and still valid — dim it (`<Busy>`)
62
+ or leave it alone.
63
+
64
+ ModelTable, CollectionMultiSelect and DetailView already use skeletons and
65
+ should keep doing so. Nothing in this family replaces them.
66
+
67
+ ## Spinner
68
+
69
+ ```tsx
70
+ <Spinner /> {/* md, decorative */}
71
+ <Spinner size="xs" /> {/* xs | sm | md | lg, or a pixel number */}
72
+ <Spinner size={44} />
73
+ <Spinner size="sm" label="Refreshing" /> {/* the ONLY indication → named */}
74
+ ```
75
+
76
+ | Prop | Type | Notes |
77
+ |---|---|---|
78
+ | `size?` | `'xs' \| 'sm' \| 'md' \| 'lg' \| number` | 12 / 15 / 20 / 30px, or an explicit diameter. Unknown value → `'md'` **with a `console.warn`** (once per bad value). |
79
+ | `label?` | `string` | Omit when visible text sits beside it (then it renders `aria-hidden`). Pass it only for an icon-only affordance. |
80
+ | `className?` | `string` | Appended after `mojo-spinner …`. |
81
+
82
+ The ring is drawn in `currentColor`, so it inherits its context: white inside
83
+ `.btn-primary`, `--accent` inside a loader card, `--mute` in a table cell. No
84
+ color prop, and none needed.
85
+
86
+ ## busy() — the blocking full-screen overlay
87
+
88
+ ```ts
89
+ const b = busy('Rebuilding index…');
90
+ try { await rebuild(); } finally { b.close(); }
91
+
92
+ // Preferred — a throw can never strand the overlay:
93
+ await busyWhile('Preparing export…', () => mojoDownload(endpoint, params, 'csv'));
94
+ ```
95
+
96
+ | API | Signature |
97
+ |---|---|
98
+ | `busy` | `(label?: string, opts?: BusyOptions) => BusyHandle` |
99
+ | `busyWhile` | `<T>(label: string, run: () => Promise<T> \| T, opts?: BusyOptions) => Promise<T>` |
100
+ | `closeAllBusy` | `() => void` — escape hatch for an error boundary / hard reset |
101
+ | `BusyOptions` | `{ delay?: number; minVisible?: number; timeout?: number }` |
102
+ | `BusyHandle` | `{ close(): void; setLabel(label: string): void; isActive(): boolean }` |
103
+
104
+ - **Imperative on purpose**, matching `modal.*` / `toast.*`: call it from any
105
+ handler, or from non-component code (client interceptors, route guards).
106
+ There is **no host component to mount** — nothing to wire into `App.tsx`.
107
+ - **Reference-counted by ticket.** Nested calls compose; the newest live
108
+ ticket's label is displayed, and closing it falls back to the previous
109
+ label rather than leaving stale text. `close()` is idempotent.
110
+ - **Watchdog:** a ticket left open longer than `timeout` (default 30s)
111
+ force-closes with a `console.error` naming the label. A caller that throws
112
+ outside a `finally` can never wedge the app behind an overlay nobody can
113
+ dismiss. `timeout: 0` disables it for a legitimately unbounded operation.
114
+ - `<LoadingOverlay active label />` is the declarative binding to the same
115
+ overlay (renders nothing into the React tree). Changing `label` swaps the
116
+ text **without restarting the delay**.
117
+
118
+ ### Why a `<dialog>`, and what "blocking" means
119
+
120
+ The overlay is a native `<dialog>` opened with `showModal()` — a member of
121
+ the browser **top layer**, exactly the reasoning behind
122
+ [Popover](./popover.md):
123
+
124
+ - it paints **above open modals and drawers** with no z-index arithmetic
125
+ (web-mojo needed `ModalView.getFullscreenAwareZIndex().modal + 1000`);
126
+ - the rest of the document goes **inert** — pointer *and* keyboard. A
127
+ `pointer-events: none` scrim leaves <kbd>Tab</kbd> working, which is not
128
+ blocking, it just looks like it;
129
+ - **Escape is swallowed** (`cancel` → `preventDefault`): dismissible only by
130
+ the caller, because the operation keeps running either way;
131
+ - focus returns to the pre-open element on close (native `<dialog>`
132
+ behavior), so a save started from a toolbar button lands you back on it.
133
+
134
+ A modal opened *after* the overlay stacks above it (top-layer promotion
135
+ order) — that is deliberate: an error modal raised mid-operation must be
136
+ readable.
137
+
138
+ ## ViewLoader / LoadingState
139
+
140
+ ```tsx
141
+ if (query.isPending) return <ViewLoader label="Loading members…" />;
142
+
143
+ <ViewLoader size="lg" label="Loading dashboard…" hint="First load builds the cache." />
144
+ ```
145
+
146
+ | Prop | Type | Notes |
147
+ |---|---|---|
148
+ | `label?` | `string` | Default `'Loading…'`. Say **what** — "Loading members…" beats "Loading…". |
149
+ | `hint?` | `ReactNode` | Second line for the honestly slow. |
150
+ | `size?` | `'sm' \| 'md' \| 'lg'` | Reserved height 88 / 168 / 280px + spinner size. Unknown → `'md'` with a `console.warn`. |
151
+ | `delay?` | `number` | Default 200ms. |
152
+ | `className?` | `string` | |
153
+
154
+ Before the delay elapses it renders an **empty box of the same height**
155
+ (`aria-hidden`), not `null`: a fast panel pops in with no layout jump, and a
156
+ slow one doesn't shove content down when the spinner appears.
157
+
158
+ ## InlineLoader
159
+
160
+ ```tsx
161
+ <InlineLoader /> {/* "Loading…" */}
162
+ <InlineLoader label="checking sessions…" />
163
+ <InlineLoader label="" /> {/* bare spinner, still announced */}
164
+ ```
165
+
166
+ Small inline row: `label?`, `delay?`, `className?`. Renders `null` before the
167
+ delay (inline text needs no reserved height).
168
+
169
+ ## Busy — dim + disable a region
170
+
171
+ ```tsx
172
+ <Busy active={save.isPending} label="Saving…">
173
+ <SchemaForm … />
174
+ </Busy>
175
+ ```
176
+
177
+ | Prop | Type | Notes |
178
+ |---|---|---|
179
+ | `active` | `boolean` | A mutation's `isPending`. |
180
+ | `label?` | `string` | Caption under the veil spinner. |
181
+ | `delay?` | `number` | Anti-flash for the **dim only**. |
182
+ | `spinner?` | `boolean` | Default true; false = bare dim. |
183
+ | `className?` | `string` | Appended after `mojo-dim`. |
184
+
185
+ **Blocking is immediate, dimming is delayed.** The content wrapper gets the
186
+ native `inert` attribute the instant `active` goes true — no double-submits,
187
+ no tabbing into a form mid-save — while the visual dim waits out the
188
+ anti-flash window so a 60ms save doesn't strobe the panel. That split is the
189
+ whole reason this wrapper exists; `pointer-events: none` alone would leave
190
+ the keyboard path open.
191
+
192
+ **Focus survives it.** Going inert blurs whatever was focused inside (the
193
+ field the user was in when they hit save), so `Busy` remembers that element
194
+ and restores focus when `active` goes false — but only if focus is still
195
+ parked on `<body>`, i.e. nothing else claimed it. If the user deliberately
196
+ clicked away mid-save, they keep where they went.
197
+
198
+ ## Accessibility contract
199
+
200
+ - `Spinner` is `role="img"` + `aria-label` when named, `aria-hidden`
201
+ otherwise — **never** a live region. Nesting live regions double-announces.
202
+ - The overlay dialog carries `aria-busy="true"` and an `aria-label` mirroring
203
+ the label; the label element inside is `role="status"`, so a mid-flight
204
+ `setLabel('Finalizing…')` is announced politely.
205
+ - `ViewLoader` and `InlineLoader` are `role="status"`; `ViewLoader` adds
206
+ `aria-busy="true"`. Its pre-delay placeholder is `aria-hidden` (nothing to
207
+ announce yet).
208
+ - `Busy` sets `aria-busy` on the wrapper and `inert` on the content, which
209
+ also removes it from the accessibility tree while the mutation runs — and
210
+ restores focus to the element the inert switch blurred (see above). Its
211
+ veil is `pointer-events: none`: `inert` already stopped the clicks, and a
212
+ veil that swallowed them would break text selection after release.
213
+ - **Reduced motion:** the spinner does not spin. `theme.css` kills every
214
+ animation with a universal `!important` rule — right for a skeleton
215
+ (shimmer stops), wrong for a spinner (the ring would freeze mid-rotation
216
+ with one colored quarter and read as a stuck control). `loading.css`
217
+ therefore **substitutes** a uniform ring on a slow opacity pulse, with
218
+ `!important` so it out-ranks that universal rule (among important
219
+ declarations the more specific selector wins).
220
+
221
+ ## Pitfalls
222
+
223
+ - **Don't overlay a background refresh.** The blocking overlay is for work
224
+ the user must wait out. Data already on screen stays interactive; use
225
+ `<Busy>`, a skeleton, or the toolbar refresh spin.
226
+ - **Don't hand-roll `busy()` without a `finally`.** Use `busyWhile` — the
227
+ watchdog exists because that mistake was made in web-mojo, not
228
+ hypothetically.
229
+ - **Don't nest a `<LoadingOverlay>` per component.** One overlay serves the
230
+ app; several tickets are fine, several *hosts* are not a thing (there is no
231
+ host).
232
+ - **Don't use a loader where a progress toast belongs.** If you have real
233
+ percentages (uploads, multi-file exports), `toast.progress` keeps the app
234
+ usable; a blocking overlay for 40 seconds is hostile.
235
+ - **Don't add `delay={0}` to make it "feel responsive."** It does the
236
+ opposite — you get the flash the delay exists to remove. `delay: 0` is for
237
+ demos and known-slow routes.
238
+ - **The overlay is `dialog[open]`.** ModelTable's `autoRefresh` skips ticks
239
+ while any `dialog[open]` exists, so auto-refresh politely pauses during a
240
+ busy operation. That is intended; don't "fix" it.
241
+ - **Toasts render under the overlay** (`.toast-host` is z-index 60, the
242
+ overlay is in the top layer). Toast *after* closing the handle, not during.
@@ -0,0 +1,76 @@
1
+ # LocationClient + AddressField
2
+
3
+ ```ts
4
+ import { LocationClient } from 'portal-mojo/client';
5
+ import { AddressField } from 'portal-mojo/ui';
6
+ ```
7
+
8
+ `LocationClient` is the typed adapter for django-mojo's six public location
9
+ endpoints. It calls `mojoCall` directly—there is no Query cache—and parses the
10
+ backend's intentionally mixed response shapes without performing a second
11
+ envelope unwrap.
12
+
13
+ | Method | Wire contract |
14
+ |---|---|
15
+ | `validateAddress(address)` | `POST /api/location/address/validate`; reads nested `data` |
16
+ | `autocomplete(input, options?)` | `GET /api/location/address/suggestions`; reads top-level `success/data/size/count` |
17
+ | `placeDetails(placeId)` | `GET /api/location/address/place-details`; reads top-level `address` |
18
+ | `geocode(address)` | `POST /api/location/address/geocode`; reads top-level coordinates/components |
19
+ | `reverseGeocode({lat,lng})` | `GET /api/location/address/reverse-geocode` |
20
+ | `timezone({lat,lng,timestamp?})` | `GET /api/location/timezone` |
21
+
22
+ ## Provider session and races
23
+
24
+ The first suggestions request deliberately omits `session_token`; django-mojo
25
+ creates it. The client accepts that response token into a JavaScript private
26
+ field, reuses it on later suggestions and place-details GETs, then destroys it
27
+ when selection ends the session. There is no token getter or input prop.
28
+
29
+ Every request captures a monotonically increasing generation. A later request,
30
+ `cancelPending()`, `reset()`, `replaceTransport()`, `dispose()`, unmount, or
31
+ upstream-client change makes older completions reject with
32
+ `StaleLocationRequestError`. Use `isStaleLocationRequest(error)` when a UI
33
+ should silently discard that expected outcome.
34
+
35
+ Never put the session token in TanStack Query, router/search params, form
36
+ values, storage, application logs, or mock history. The mock exposes only
37
+ derived booleans/counts such as `has_session_token` and `input_length`.
38
+
39
+ ## AddressField
40
+
41
+ `AddressField` is controlled and commit-only. Keystrokes stay in a private
42
+ draft. Raw text commits on Enter/Tab/blur/outside; Escape restores the
43
+ controlled value. Selecting a suggestion privately fetches place details and
44
+ only then commits. A details failure drops the provider description and
45
+ restores the prior value.
46
+
47
+ ```tsx
48
+ <AddressField
49
+ fieldName="address1"
50
+ value={values.address1}
51
+ fields={{ city: 'city', state_code: 'state', postal_code: 'postal_code' }}
52
+ onCommit={(address1) => setValues(v => ({ ...v, address1 }))}
53
+ onPatch={(patch) => setValues(v => ({ ...v, ...patch }))}
54
+ />
55
+ ```
56
+
57
+ For schema-driven forms, use `type: 'address'`:
58
+
59
+ ```ts
60
+ const fields: Field[] = [{
61
+ name: 'address1', type: 'address', label: 'Street address',
62
+ addressFields: {
63
+ city: 'city', state_code: 'state', postal_code: 'postal_code',
64
+ latitude: 'latitude', longitude: 'longitude',
65
+ },
66
+ } /* plus every destination as a declared Field */];
67
+ ```
68
+
69
+ `address1` defaults to the address field's own name. Other destinations are
70
+ opt-in. SchemaForm and FormView discard undeclared destinations. FormView
71
+ queues the accepted patch as one autosave batch; a server failure rolls every
72
+ field in that batch back to its shared server snapshot.
73
+
74
+ The suggestion list uses the shared `Popover`, which is the sole owner of
75
+ outside-mousedown and Escape dismissal. Styles live in
76
+ `theme/location-address.css` and use design tokens in both themes.