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.
- package/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
package/src/ui/index.ts
ADDED
|
@@ -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
|
+
}
|