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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Sidebar menu engine — the registry + resolution logic, ported from
|
|
2
|
+
// web-mojo Sidebar.js (menus Map, autoSwitchToMenuForRoute, menuContainsRoute,
|
|
3
|
+
// getGroupMenu/_groupKindMatches, _applyFallbackMenu, _menuHasVisibleItems).
|
|
4
|
+
//
|
|
5
|
+
// Menus are DATA in a registry; the sidebar renders whichever one is active.
|
|
6
|
+
// web-mojo kept mutable active-menu state driven by events; here the active
|
|
7
|
+
// menu is a PURE FUNCTION of (route, active group, me):
|
|
8
|
+
// 1. the first registered menu whose visible items contain the route —
|
|
9
|
+
// group menus are eligible only when a kind-matched group is active
|
|
10
|
+
// (web-mojo skipped them only when NO group was active; requiring the
|
|
11
|
+
// kind match too is a deliberate tightening)
|
|
12
|
+
// 2. otherwise the fallback chain: defaultMenu → first non-group menu with
|
|
13
|
+
// at least one visible item → first non-group menu
|
|
14
|
+
// Entering a group menu happens by NAVIGATION (e.g. the app routes into the
|
|
15
|
+
// group's home on switcher selection) — no hidden menu state to desync.
|
|
16
|
+
//
|
|
17
|
+
// Admin sections contribute menus through this same registry (see
|
|
18
|
+
// portal-mojo/admin adminSectionsMenu) — sections never own the sidebar.
|
|
19
|
+
import { matchPath } from 'react-router-dom';
|
|
20
|
+
import { hasPermission, type Me, type MemberLike } from '../client/me';
|
|
21
|
+
import type { PermSpec } from '../client/model';
|
|
22
|
+
import type { Group } from '../client/group-context';
|
|
23
|
+
|
|
24
|
+
export interface MenuItem {
|
|
25
|
+
/** Stable identity for route-less parents (accordion state, aria ids). */
|
|
26
|
+
id?: string;
|
|
27
|
+
/** Section label row (the side-label idiom). Mutually exclusive with route. */
|
|
28
|
+
divider?: string;
|
|
29
|
+
label?: string;
|
|
30
|
+
/** Extra quick-navigation terms that are never rendered. */
|
|
31
|
+
keywords?: string[];
|
|
32
|
+
icon?: string;
|
|
33
|
+
/** Absolute route within the hash router, e.g. '/users'. */
|
|
34
|
+
route?: string;
|
|
35
|
+
children?: MenuItem[];
|
|
36
|
+
/** Permission name(s) required to see the item (ANY-of; member-aware). */
|
|
37
|
+
permissions?: string | string[];
|
|
38
|
+
/**
|
|
39
|
+
* Additional permission clauses: ANY within one clause, AND across clauses.
|
|
40
|
+
* `permissions`, when present, is evaluated as one more required clause.
|
|
41
|
+
*/
|
|
42
|
+
permissionClauses?: PermSpec[];
|
|
43
|
+
/** Only visible when the active group's kind matches. */
|
|
44
|
+
requiresGroupKind?: string | string[];
|
|
45
|
+
badge?: { text: string; tone?: 'success' | 'warning' | 'danger' | 'info' | 'muted' | 'primary' };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MenuConfig {
|
|
49
|
+
name: string;
|
|
50
|
+
/** Shell metadata. Omitted group menus infer `group`; others infer `global`. */
|
|
51
|
+
scope?: 'admin' | 'group' | 'account' | 'global';
|
|
52
|
+
/** Route-less parents stay static unless a consumer explicitly opts in. */
|
|
53
|
+
presentation?: 'static' | 'accordion';
|
|
54
|
+
/**
|
|
55
|
+
* null/undefined → a global menu. A kind string/array scopes the menu to
|
|
56
|
+
* active groups of that kind; 'any' matches every kind (used as the
|
|
57
|
+
* fallback group menu).
|
|
58
|
+
*/
|
|
59
|
+
groupKind?: string | string[] | 'any' | null;
|
|
60
|
+
items: MenuItem[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface MenuContext {
|
|
64
|
+
me: Me | null;
|
|
65
|
+
member: MemberLike | null;
|
|
66
|
+
group: Group | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Registry (insertion-ordered; subscribable so late registration — lazy
|
|
70
|
+
// admin sections, HMR — re-renders subscribers) ───────────────────────
|
|
71
|
+
|
|
72
|
+
const menus = new Map<string, MenuConfig>();
|
|
73
|
+
let defaultMenuName: string | null = null;
|
|
74
|
+
let version = 0;
|
|
75
|
+
const listeners = new Set<() => void>();
|
|
76
|
+
|
|
77
|
+
function bump(): void {
|
|
78
|
+
version += 1;
|
|
79
|
+
for (const cb of listeners) cb();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Register (or replace) one menu. Registration order is resolution order. */
|
|
83
|
+
export function registerMenu(config: MenuConfig): void {
|
|
84
|
+
menus.set(config.name, config);
|
|
85
|
+
bump();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function registerMenus(configs: MenuConfig[]): void {
|
|
89
|
+
for (const c of configs) menus.set(c.name, c);
|
|
90
|
+
bump();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The menu the fallback chain prefers when no route matches. */
|
|
94
|
+
export function setDefaultMenu(name: string): void {
|
|
95
|
+
defaultMenuName = name;
|
|
96
|
+
bump();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getMenus(): ReadonlyMap<string, MenuConfig> {
|
|
100
|
+
return menus;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function subscribeMenus(cb: () => void): () => void {
|
|
104
|
+
listeners.add(cb);
|
|
105
|
+
return () => listeners.delete(cb);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function menusVersion(): number {
|
|
109
|
+
return version;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Matching + visibility (pure) ──────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
export function groupKindMatches(menuKind: MenuConfig['groupKind'], kind: string | null | undefined): boolean {
|
|
115
|
+
if (!menuKind || !kind) return false;
|
|
116
|
+
if (menuKind === 'any') return true;
|
|
117
|
+
if (Array.isArray(menuKind)) return menuKind.includes(kind);
|
|
118
|
+
return menuKind === kind;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Route match: exact for '/', prefix (param-aware) otherwise. */
|
|
122
|
+
export function routesMatch(itemRoute: string, pathname: string): boolean {
|
|
123
|
+
return matchPath({ path: itemRoute, end: itemRoute === '/' }, pathname) != null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Permission + group-kind visibility for one item (dividers always show). */
|
|
127
|
+
export function itemVisible(item: MenuItem, ctx: MenuContext): boolean {
|
|
128
|
+
if (item.divider) return true;
|
|
129
|
+
if (item.permissions && !hasPermission(ctx.me, item.permissions, ctx.member)) return false;
|
|
130
|
+
if (item.permissionClauses?.some((clause) => !hasPermission(ctx.me, clause, ctx.member))) return false;
|
|
131
|
+
if (item.requiresGroupKind) {
|
|
132
|
+
if (!groupKindMatches(item.requiresGroupKind, ctx.group?.kind)) return false;
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Visible immediate children, shared by route resolution and SidebarNav. */
|
|
138
|
+
export function visibleMenuChildren(item: MenuItem, ctx: MenuContext): MenuItem[] {
|
|
139
|
+
return (item.children ?? []).filter((child) => itemVisible(child, ctx));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Does this item's visible subtree own the current route? */
|
|
143
|
+
export function itemContainsRoute(item: MenuItem, pathname: string, ctx: MenuContext): boolean {
|
|
144
|
+
if (!itemVisible(item, ctx)) return false;
|
|
145
|
+
if (item.route && routesMatch(item.route, pathname)) return true;
|
|
146
|
+
return visibleMenuChildren(item, ctx).some((child) => itemContainsRoute(child, pathname, ctx));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Effective scope with backward-compatible groupKind inference. */
|
|
150
|
+
export function menuScope(menu: MenuConfig): NonNullable<MenuConfig['scope']> {
|
|
151
|
+
return menu.scope ?? (menu.groupKind ? 'group' : 'global');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function menuHasVisibleItems(menu: MenuConfig, ctx: MenuContext): boolean {
|
|
155
|
+
return menu.items.some((item) => !item.divider && itemVisible(item, ctx));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Does this menu own the route (visible items + children considered)? */
|
|
159
|
+
export function menuContainsRoute(menu: MenuConfig, pathname: string, ctx: MenuContext): boolean {
|
|
160
|
+
return menu.items.some((item) => itemContainsRoute(item, pathname, ctx));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Is a group menu eligible at all under the current context? */
|
|
164
|
+
function groupMenuEligible(menu: MenuConfig, ctx: MenuContext): boolean {
|
|
165
|
+
if (!menu.groupKind) return true;
|
|
166
|
+
return ctx.group != null && groupKindMatches(menu.groupKind, ctx.group.kind);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The active menu for (route, context) — autoSwitchToMenuForRoute + the
|
|
171
|
+
* _applyFallbackMenu chain, as one pure resolution.
|
|
172
|
+
*/
|
|
173
|
+
export function resolveActiveMenu(pathname: string, ctx: MenuContext): MenuConfig | null {
|
|
174
|
+
// 1. Route containment, registration order.
|
|
175
|
+
for (const menu of menus.values()) {
|
|
176
|
+
if (!groupMenuEligible(menu, ctx)) continue;
|
|
177
|
+
if (menuContainsRoute(menu, pathname, ctx)) return menu;
|
|
178
|
+
}
|
|
179
|
+
// 2. defaultMenu.
|
|
180
|
+
if (defaultMenuName) {
|
|
181
|
+
const dm = menus.get(defaultMenuName);
|
|
182
|
+
if (dm) return dm;
|
|
183
|
+
}
|
|
184
|
+
// 3. First non-group menu with visible items; else first non-group.
|
|
185
|
+
let firstNonGroup: MenuConfig | null = null;
|
|
186
|
+
for (const menu of menus.values()) {
|
|
187
|
+
if (menu.groupKind) continue;
|
|
188
|
+
firstNonGroup ??= menu;
|
|
189
|
+
if (menuHasVisibleItems(menu, ctx)) return menu;
|
|
190
|
+
}
|
|
191
|
+
return firstNonGroup;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The kind-matched group menu (exact/array match beats 'any') — getGroupMenu. */
|
|
195
|
+
export function resolveGroupMenu(group: Group): MenuConfig | null {
|
|
196
|
+
let anyMenu: MenuConfig | null = null;
|
|
197
|
+
for (const menu of menus.values()) {
|
|
198
|
+
if (!menu.groupKind) continue;
|
|
199
|
+
if (menu.groupKind === 'any') {
|
|
200
|
+
anyMenu ??= menu;
|
|
201
|
+
} else if (groupKindMatches(menu.groupKind, group.kind)) {
|
|
202
|
+
return menu;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return anyMenu;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** TEST/DEV ONLY: wipe the registry (module state survives HMR reloads). */
|
|
209
|
+
export function resetMenus(): void {
|
|
210
|
+
menus.clear();
|
|
211
|
+
defaultMenuName = null;
|
|
212
|
+
bump();
|
|
213
|
+
}
|
package/src/ui/modal.tsx
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// ModalManager — ports web-mojo's imperative, awaitable Modal API onto
|
|
2
|
+
// native <dialog> + a portal-free host. `await modal.confirm(...)` from any
|
|
3
|
+
// event handler; no JSX modal state to hoist. The z-index/backdrop stack
|
|
4
|
+
// manager web-mojo needed does not exist here: <dialog> stacks natively.
|
|
5
|
+
import { useEffect, useRef, useSyncExternalStore, type CSSProperties, type ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
export type ModalSize = 'sm' | 'md' | 'lg';
|
|
8
|
+
|
|
9
|
+
/** Drawer width: the 'wide' preset, or an explicit pixel number. */
|
|
10
|
+
export type DrawerWidth = number | 'wide';
|
|
11
|
+
|
|
12
|
+
/** One entry of the drawer's "context · title · meta" subtitle row. */
|
|
13
|
+
export type DrawerMeta = string | { icon?: string; text: ReactNode };
|
|
14
|
+
|
|
15
|
+
export interface DrawerOptions<T> {
|
|
16
|
+
/** Big header line. */
|
|
17
|
+
title?: ReactNode;
|
|
18
|
+
/** Small uppercase tag above the title. */
|
|
19
|
+
eyebrow?: ReactNode;
|
|
20
|
+
/** Subtitle row under the title: bare strings or `{icon, text}`. */
|
|
21
|
+
meta?: DrawerMeta[];
|
|
22
|
+
/** Static body. Ignored when `render` is given. */
|
|
23
|
+
content?: ReactNode;
|
|
24
|
+
/** Body that owns the result: call `close(value)` to resolve the await. */
|
|
25
|
+
render?: (close: (value: T | null) => void) => ReactNode;
|
|
26
|
+
/** Default 480px; 'wide' → 720px; or an explicit pixel number. */
|
|
27
|
+
width?: DrawerWidth;
|
|
28
|
+
/** Escape / backdrop / header-X dismissal (resolves null). Default true. */
|
|
29
|
+
dismissable?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface BaseItem {
|
|
33
|
+
id: number;
|
|
34
|
+
render: (close: (value: unknown) => void) => ReactNode;
|
|
35
|
+
resolve: (value: unknown) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ModalItem extends BaseItem {
|
|
39
|
+
variant: 'modal';
|
|
40
|
+
size: ModalSize;
|
|
41
|
+
flush: boolean; // no body padding (DetailView-style)
|
|
42
|
+
/** Dynamic dismissal gate. Explicit close(value) always bypasses it. */
|
|
43
|
+
canDismiss?: () => boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface DrawerItem extends BaseItem {
|
|
47
|
+
variant: 'drawer';
|
|
48
|
+
title?: ReactNode;
|
|
49
|
+
eyebrow?: ReactNode;
|
|
50
|
+
meta?: DrawerMeta[];
|
|
51
|
+
width: number;
|
|
52
|
+
dismissable: boolean;
|
|
53
|
+
closing: boolean; // exit animation in flight — see drawer()'s two-phase close
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type StackItem = ModalItem | DrawerItem;
|
|
57
|
+
|
|
58
|
+
let nextId = 1;
|
|
59
|
+
let stack: StackItem[] = [];
|
|
60
|
+
const listeners = new Set<() => void>();
|
|
61
|
+
|
|
62
|
+
function emit() {
|
|
63
|
+
listeners.forEach((fn) => fn());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ModalOptions {
|
|
67
|
+
size?: ModalSize;
|
|
68
|
+
flush?: boolean;
|
|
69
|
+
/** Guards Escape/backdrop dismissal. Programmatic close still succeeds. */
|
|
70
|
+
canDismiss?: () => boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function open<T>(render: (close: (value: T) => void) => ReactNode, opts: ModalOptions = {}): Promise<T | null> {
|
|
74
|
+
return new Promise<T | null>((resolve) => {
|
|
75
|
+
const id = nextId++;
|
|
76
|
+
let settled = false;
|
|
77
|
+
const close = (value: unknown) => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
stack = stack.filter((m) => m.id !== id);
|
|
81
|
+
emit();
|
|
82
|
+
resolve(value as T | null);
|
|
83
|
+
};
|
|
84
|
+
stack = [...stack, {
|
|
85
|
+
id,
|
|
86
|
+
variant: 'modal',
|
|
87
|
+
size: opts.size ?? 'md',
|
|
88
|
+
flush: opts.flush ?? false,
|
|
89
|
+
canDismiss: opts.canDismiss,
|
|
90
|
+
render: render as (close: (value: unknown) => void) => ReactNode,
|
|
91
|
+
resolve: close,
|
|
92
|
+
}];
|
|
93
|
+
emit();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Drawer ────────────────────────────────────────────────────────────
|
|
98
|
+
// Right slide-over. Same <dialog> substrate as every other modal (backdrop,
|
|
99
|
+
// Escape and stacking come free), so a drawer opened from a modal simply
|
|
100
|
+
// takes the top layer. web-mojo's Modal.drawer() header shape — eyebrow /
|
|
101
|
+
// title / meta row — is carried over verbatim; the right-anchored panel and
|
|
102
|
+
// the awaitable close are the parts it never had.
|
|
103
|
+
|
|
104
|
+
const DRAWER_W_DEFAULT = 480;
|
|
105
|
+
const DRAWER_W_WIDE = 720;
|
|
106
|
+
const DRAWER_W_MIN = 280;
|
|
107
|
+
/** Must match the .mojo-drawer-closing animation in the app's drawer.css. */
|
|
108
|
+
const DRAWER_EXIT_MS = 200;
|
|
109
|
+
|
|
110
|
+
function reducedMotion(): boolean {
|
|
111
|
+
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function drawerWidthPx(width: DrawerWidth | undefined): number {
|
|
115
|
+
if (width === undefined) return DRAWER_W_DEFAULT;
|
|
116
|
+
if (width === 'wide') return DRAWER_W_WIDE;
|
|
117
|
+
if (typeof width === 'number' && Number.isFinite(width) && width > 0) return Math.max(DRAWER_W_MIN, width);
|
|
118
|
+
console.warn(`modal.drawer: unknown width ${JSON.stringify(width)} — falling back to ${DRAWER_W_DEFAULT}px`);
|
|
119
|
+
return DRAWER_W_DEFAULT;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function drawer<T = unknown>(opts: DrawerOptions<T>): Promise<T | null> {
|
|
123
|
+
const body = opts.render ?? (() => opts.content ?? null);
|
|
124
|
+
let dismissable = opts.dismissable ?? true;
|
|
125
|
+
if (!dismissable && !opts.render) {
|
|
126
|
+
// Nothing else can close it: no X, no Escape, no backdrop, and a static
|
|
127
|
+
// `content` has no close() to call. Fall back rather than trap the user.
|
|
128
|
+
console.warn('modal.drawer: dismissable:false needs a render() that can close the drawer — falling back to dismissable');
|
|
129
|
+
dismissable = true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return new Promise<T | null>((resolve) => {
|
|
133
|
+
const id = nextId++;
|
|
134
|
+
let settled = false;
|
|
135
|
+
|
|
136
|
+
// Two-phase close so the slide-out actually plays: flip `closing` (CSS
|
|
137
|
+
// transforms the panel off-screen), wait out the animation, THEN drop
|
|
138
|
+
// the item — unmounting calls dialog.close() — and settle the promise.
|
|
139
|
+
const close = (value: unknown) => {
|
|
140
|
+
if (settled) return; // Escape + backdrop + a body button can race
|
|
141
|
+
settled = true;
|
|
142
|
+
stack = stack.map((m) => (m.id === id && m.variant === 'drawer' ? { ...m, closing: true } : m));
|
|
143
|
+
emit();
|
|
144
|
+
const drop = () => {
|
|
145
|
+
stack = stack.filter((m) => m.id !== id);
|
|
146
|
+
emit();
|
|
147
|
+
resolve(value as T | null);
|
|
148
|
+
};
|
|
149
|
+
if (reducedMotion()) drop();
|
|
150
|
+
else window.setTimeout(drop, DRAWER_EXIT_MS);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
stack = [...stack, {
|
|
154
|
+
id,
|
|
155
|
+
variant: 'drawer',
|
|
156
|
+
title: opts.title,
|
|
157
|
+
eyebrow: opts.eyebrow,
|
|
158
|
+
meta: opts.meta,
|
|
159
|
+
width: drawerWidthPx(opts.width),
|
|
160
|
+
dismissable,
|
|
161
|
+
closing: false,
|
|
162
|
+
render: body as (close: (value: unknown) => void) => ReactNode,
|
|
163
|
+
resolve: close,
|
|
164
|
+
}];
|
|
165
|
+
emit();
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const modal = {
|
|
170
|
+
open,
|
|
171
|
+
|
|
172
|
+
/** Detail modal: large, flush body — the Modal.detail() envelope. */
|
|
173
|
+
detail(render: (close: (value: unknown) => void) => ReactNode): Promise<unknown> {
|
|
174
|
+
return open(render, { size: 'lg', flush: true });
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Right slide-over drawer. Awaitable like every other static: resolves
|
|
179
|
+
* whatever `render`'s `close(value)` passes, or `null` on dismiss.
|
|
180
|
+
* TRANSIENT and modal — not the persistent shell right-panel slot.
|
|
181
|
+
*/
|
|
182
|
+
drawer,
|
|
183
|
+
|
|
184
|
+
confirm(opts: { title?: string; message: ReactNode; confirmText?: string; cancelText?: string; danger?: boolean }): Promise<boolean> {
|
|
185
|
+
return open<boolean>((close) => (
|
|
186
|
+
<div className="modal-pad">
|
|
187
|
+
{opts.title && <h2 className="modal-title">{opts.title}</h2>}
|
|
188
|
+
<div className="modal-message">{opts.message}</div>
|
|
189
|
+
<div className="modal-actions">
|
|
190
|
+
<button className="btn" onClick={() => close(false)}>{opts.cancelText ?? 'Cancel'}</button>
|
|
191
|
+
<button className={opts.danger ? 'btn btn-danger' : 'btn btn-primary'} onClick={() => close(true)}>
|
|
192
|
+
{opts.confirmText ?? 'Confirm'}
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
), { size: 'sm' }).then((v) => v === true);
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function ModalDialog({ item }: { item: ModalItem }) {
|
|
201
|
+
const ref = useRef<HTMLDialogElement>(null);
|
|
202
|
+
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
ref.current?.showModal();
|
|
205
|
+
}, []);
|
|
206
|
+
|
|
207
|
+
const dismiss = () => {
|
|
208
|
+
if (item.canDismiss?.() === false) return;
|
|
209
|
+
item.resolve(null);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<dialog
|
|
214
|
+
ref={ref}
|
|
215
|
+
className={`mojo-modal mojo-modal-${item.size}${item.flush ? ' mojo-modal-flush' : ''}`}
|
|
216
|
+
onCancel={(e) => { e.preventDefault(); dismiss(); }}
|
|
217
|
+
onMouseDown={(e) => { if (e.target === ref.current) dismiss(); }}
|
|
218
|
+
>
|
|
219
|
+
{item.render(item.resolve)}
|
|
220
|
+
</dialog>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function DrawerDialog({ item }: { item: DrawerItem }) {
|
|
225
|
+
const ref = useRef<HTMLDialogElement>(null);
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
const el = ref.current;
|
|
229
|
+
el?.showModal();
|
|
230
|
+
// Unmount is the END of the exit animation (the manager holds the item
|
|
231
|
+
// until then), so this is the "closing class, animate, then close()"
|
|
232
|
+
// order — and it fires the native close event / restores focus.
|
|
233
|
+
return () => { if (el?.open) el.close(); };
|
|
234
|
+
}, []);
|
|
235
|
+
|
|
236
|
+
const dismiss = () => { if (item.dismissable) item.resolve(null); };
|
|
237
|
+
const hasHead = item.dismissable || item.eyebrow != null || item.title != null || (item.meta?.length ?? 0) > 0;
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<dialog
|
|
241
|
+
ref={ref}
|
|
242
|
+
className={`mojo-drawer${item.closing ? ' mojo-drawer-closing' : ''}`}
|
|
243
|
+
// React's CSSProperties has no index signature for custom props.
|
|
244
|
+
style={{ '--drawer-w': `${item.width}px` } as CSSProperties}
|
|
245
|
+
onCancel={(e) => { e.preventDefault(); dismiss(); }}
|
|
246
|
+
onMouseDown={(e) => { if (e.target === ref.current) dismiss(); }}
|
|
247
|
+
>
|
|
248
|
+
{/* The panel fills the dialog, so the dialog element is only ever
|
|
249
|
+
the event target for ::backdrop clicks — that is the dismiss. */}
|
|
250
|
+
<div className="drawer-panel">
|
|
251
|
+
{hasHead && (
|
|
252
|
+
<header className="drawer-head">
|
|
253
|
+
<div className="drawer-head-text">
|
|
254
|
+
{item.eyebrow != null && <span className="drawer-eyebrow">{item.eyebrow}</span>}
|
|
255
|
+
{item.title != null && <h2 className="drawer-title">{item.title}</h2>}
|
|
256
|
+
{!!item.meta?.length && (
|
|
257
|
+
<div className="drawer-meta">
|
|
258
|
+
{item.meta.map((m, i) => {
|
|
259
|
+
const entry = typeof m === 'string' ? { icon: undefined, text: m } : m;
|
|
260
|
+
return (
|
|
261
|
+
<span key={i}>
|
|
262
|
+
{entry.icon && <i className={`bi ${entry.icon}`} />}
|
|
263
|
+
{entry.text}
|
|
264
|
+
</span>
|
|
265
|
+
);
|
|
266
|
+
})}
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
{item.dismissable && (
|
|
271
|
+
<button className="btn-icon" onClick={dismiss} title="Close" aria-label="Close">
|
|
272
|
+
<i className="bi bi-x-lg" />
|
|
273
|
+
</button>
|
|
274
|
+
)}
|
|
275
|
+
</header>
|
|
276
|
+
)}
|
|
277
|
+
<div className="drawer-body">{item.render(item.resolve)}</div>
|
|
278
|
+
</div>
|
|
279
|
+
</dialog>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function ModalHost() {
|
|
284
|
+
const items = useSyncExternalStore(
|
|
285
|
+
(cb) => { listeners.add(cb); return () => listeners.delete(cb); },
|
|
286
|
+
() => stack,
|
|
287
|
+
);
|
|
288
|
+
return (
|
|
289
|
+
<>
|
|
290
|
+
{items.map((item) => (
|
|
291
|
+
item.variant === 'drawer'
|
|
292
|
+
? <DrawerDialog key={item.id} item={item} />
|
|
293
|
+
: <ModalDialog key={item.id} item={item} />
|
|
294
|
+
))}
|
|
295
|
+
</>
|
|
296
|
+
);
|
|
297
|
+
}
|