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,622 @@
|
|
|
1
|
+
// CollectionMultiSelect — the server-backed multi-pick: live model data as a
|
|
2
|
+
// checkbox list with search on top, SELECT/DESELECT-all with counts, and
|
|
3
|
+
// shift-click range selection. Ported from web-mojo
|
|
4
|
+
// src/core/forms/inputs/CollectionMultiSelect.js (588 lines: SearchView +
|
|
5
|
+
// ListItemsView + CollectionMultiSelectView) into the one-cache world: data
|
|
6
|
+
// flows through useModelList's query keys ([endpoint, params]), so pickers,
|
|
7
|
+
// tables and model hooks share one cache and one invalidation root — never a
|
|
8
|
+
// parallel fetch path (the #1275 epic contract).
|
|
9
|
+
//
|
|
10
|
+
// PRESENTATION: the DEFAULT is a dropdown — a summary trigger + <Popover>
|
|
11
|
+
// menu sharing MultiSelectDropdown's shell (one trigger voice for both
|
|
12
|
+
// multi-picks; the menu stays open while ticking, Done closes). The source
|
|
13
|
+
// (and its FormBuilder mount) rendered an always-visible panel, which eats
|
|
14
|
+
// half a form column; `variant="panel"` keeps that box for settings-page /
|
|
15
|
+
// always-on contexts. Search defaults ON in the dropdown (a server-backed
|
|
16
|
+
// dropdown without search is a page-one-only picker) and OFF in the panel
|
|
17
|
+
// (source parity).
|
|
18
|
+
//
|
|
19
|
+
// Deliberate departures from the source, all bug-class kills:
|
|
20
|
+
// · Ids are compared NORMALIZED (String()) everywhere — the loose `==`
|
|
21
|
+
// comparisons (and the strict-equals misses elsewhere) meant string vs
|
|
22
|
+
// number ids worked only by luck. The value prop keeps its caller-side
|
|
23
|
+
// types; comparisons all pass through one keyOf().
|
|
24
|
+
// · SELECT adds the visible selectable rows to the value (union). The
|
|
25
|
+
// source REPLACED the value with the visible rows, silently dropping
|
|
26
|
+
// selections made under a previous search — selection is an id set that
|
|
27
|
+
// must survive searches/refetches. DESELECT still clears the whole value
|
|
28
|
+
// (source parity — it is the "empty the value" affordance).
|
|
29
|
+
// · The shift-click anchor resets when the row set changes (new data /
|
|
30
|
+
// ignoreIds change) — the source kept a stale index into a replaced
|
|
31
|
+
// array and could range across rows that no longer existed.
|
|
32
|
+
// · itemTemplate (mustache HTML string) → renderItem(row): ReactNode.
|
|
33
|
+
// Trusted-HTML slots end here (architecture rule 6).
|
|
34
|
+
// · excludeIds is NOT ported: despite its "server-side" comment it was a
|
|
35
|
+
// second client-side filter identical to ignoreIds. One option remains.
|
|
36
|
+
import {
|
|
37
|
+
useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState,
|
|
38
|
+
type KeyboardEvent as ReactKeyboardEvent, type ReactNode,
|
|
39
|
+
} from 'react';
|
|
40
|
+
import { useModelList } from '../client/hooks';
|
|
41
|
+
import type { ModelDef } from '../client/model';
|
|
42
|
+
import type { Params } from '../client/types';
|
|
43
|
+
import { GroupContext } from '../client/group-context';
|
|
44
|
+
import { Popover, type PopoverPlacement } from './Popover';
|
|
45
|
+
|
|
46
|
+
/** Every id comparison in this component goes through here — string and
|
|
47
|
+
* number ids (and mixes of the two) work by construction. */
|
|
48
|
+
const keyOf = (v: string | number): string => String(v);
|
|
49
|
+
|
|
50
|
+
/** MOJOUtils.getNestedValue heritage: dot-notation nested access. */
|
|
51
|
+
function getPath(obj: unknown, path: string): unknown {
|
|
52
|
+
let cur: unknown = obj;
|
|
53
|
+
for (const part of path.split('.')) {
|
|
54
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
55
|
+
cur = (cur as Record<string, unknown>)[part];
|
|
56
|
+
}
|
|
57
|
+
return cur;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// One warn per (endpoint, field) — a bad field name is a config bug, not a
|
|
61
|
+
// render event (unknown-value rule: fall back WITH a warn, never to nothing).
|
|
62
|
+
const warnedFields = new Set<string>();
|
|
63
|
+
function warnFieldOnce(endpoint: string, kind: 'labelField' | 'valueField', field: string, note: string): void {
|
|
64
|
+
const key = `${endpoint}:${kind}:${field}`;
|
|
65
|
+
if (warnedFields.has(key)) return;
|
|
66
|
+
warnedFields.add(key);
|
|
67
|
+
console.warn(`CollectionMultiSelect: ${kind} "${field}" ${note} (endpoint ${endpoint})`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface Item<T> {
|
|
71
|
+
/** Normalized identity — the one string every comparison uses. */
|
|
72
|
+
key: string;
|
|
73
|
+
/** The row's raw id, emitted through onChange when the row is picked. */
|
|
74
|
+
id: string | number;
|
|
75
|
+
label: string;
|
|
76
|
+
disabled: boolean;
|
|
77
|
+
row: T;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface CollectionMultiSelectProps<T extends { id: number | string }> {
|
|
81
|
+
/** A defineModel definition — supplies the endpoint (and the row type). */
|
|
82
|
+
model?: ModelDef<T>;
|
|
83
|
+
/** Bare endpoint, for lists without a model definition. */
|
|
84
|
+
endpoint?: string;
|
|
85
|
+
/** Selected ids — CONTROLLED. String/number ids compare normalized. */
|
|
86
|
+
value: Array<string | number>;
|
|
87
|
+
/** Fires with the full next id array on every commit (each toggle IS a commit). */
|
|
88
|
+
onChange: (ids: Array<string | number>) => void;
|
|
89
|
+
/**
|
|
90
|
+
* 'dropdown' (default): summary trigger + Popover menu — the form-embedded
|
|
91
|
+
* presentation. 'panel': the always-visible box (the source's shape) for
|
|
92
|
+
* settings-page / always-on contexts.
|
|
93
|
+
*/
|
|
94
|
+
variant?: 'dropdown' | 'panel';
|
|
95
|
+
/** Trigger text while nothing is selected (dropdown). Default 'Select...'. */
|
|
96
|
+
placeholder?: string;
|
|
97
|
+
/** Summarize the trigger with row labels when all picks have been seen
|
|
98
|
+
* (else always "N selected"). Default true. */
|
|
99
|
+
showSelectedLabels?: boolean;
|
|
100
|
+
/** Above this many picks the trigger shows "N selected". Default 3. */
|
|
101
|
+
maxLabelsToShow?: number;
|
|
102
|
+
/** Menu placement against the trigger (dropdown). Default 'bottom-start'. */
|
|
103
|
+
placement?: PopoverPlacement;
|
|
104
|
+
/** Row field shown as the label; dot notation reaches nested fields. Default 'name'. */
|
|
105
|
+
labelField?: string;
|
|
106
|
+
/** Row field used as the id; dot notation reaches nested fields. Default 'id'. */
|
|
107
|
+
valueField?: string;
|
|
108
|
+
/** VISIBLE rows before the list scrolls (not the fetch size). Default 8. */
|
|
109
|
+
size?: number;
|
|
110
|
+
/** Explicit list max-height in px; defaults to size × 42 (the row height). */
|
|
111
|
+
maxHeight?: number;
|
|
112
|
+
/** Search input above the list — 400ms debounce into the `search` wire
|
|
113
|
+
* param. Defaults ON in the dropdown variant, OFF in the panel. */
|
|
114
|
+
enableSearch?: boolean;
|
|
115
|
+
searchPlaceholder?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Extra wire params merged into every fetch — a dict, or a callback
|
|
118
|
+
* re-evaluated per render (each fetch uses its freshest result; a changed
|
|
119
|
+
* result is a new query key and refetches). Overrides the built-in
|
|
120
|
+
* `size: 50` page size; `group` (requiresActiveGroup) and `search` win
|
|
121
|
+
* over it.
|
|
122
|
+
*/
|
|
123
|
+
defaultParams?: Params | (() => Params | null | undefined);
|
|
124
|
+
/**
|
|
125
|
+
* Fold the active group in as the `group` wire param. Without an active
|
|
126
|
+
* group the fetch is HELD (never an unscoped list where a scoped one was
|
|
127
|
+
* demanded) and the empty state shows.
|
|
128
|
+
*/
|
|
129
|
+
requiresActiveGroup?: boolean;
|
|
130
|
+
/** Ids hidden from the list client-side (the "already added" pattern —
|
|
131
|
+
* the documented exception to the no-client-filtering rule). */
|
|
132
|
+
ignoreIds?: Array<string | number>;
|
|
133
|
+
/** Custom row content in place of the label span; the checkbox stays. */
|
|
134
|
+
renderItem?: (row: T) => ReactNode;
|
|
135
|
+
/** Per-row disabled: unclickable, skipped by SELECT and range selection. */
|
|
136
|
+
isRowDisabled?: (row: T) => boolean;
|
|
137
|
+
/** The SELECT (n) / DESELECT (n) header row. Default true. */
|
|
138
|
+
showSelectAll?: boolean;
|
|
139
|
+
label?: string;
|
|
140
|
+
required?: boolean;
|
|
141
|
+
help?: string;
|
|
142
|
+
error?: string;
|
|
143
|
+
/** Disable the whole control: trigger, search, buttons and every row. */
|
|
144
|
+
disabled?: boolean;
|
|
145
|
+
/** id for the trigger button (the label points at it). */
|
|
146
|
+
id?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function CollectionMultiSelect<T extends { id: number | string }>({
|
|
150
|
+
model, endpoint, value, onChange,
|
|
151
|
+
variant = 'dropdown',
|
|
152
|
+
placeholder = 'Select...',
|
|
153
|
+
showSelectedLabels = true, maxLabelsToShow = 3,
|
|
154
|
+
placement = 'bottom-start',
|
|
155
|
+
labelField = 'name', valueField = 'id',
|
|
156
|
+
size = 8, maxHeight,
|
|
157
|
+
enableSearch, searchPlaceholder = 'Search…',
|
|
158
|
+
defaultParams, requiresActiveGroup = false, ignoreIds,
|
|
159
|
+
renderItem, isRowDisabled, showSelectAll = true,
|
|
160
|
+
label, required = false, help, error, disabled = false,
|
|
161
|
+
id,
|
|
162
|
+
}: CollectionMultiSelectProps<T>) {
|
|
163
|
+
const resolvedEndpoint = model?.endpoint ?? endpoint;
|
|
164
|
+
if (!resolvedEndpoint) throw new Error('CollectionMultiSelect: pass `model` or `endpoint`');
|
|
165
|
+
|
|
166
|
+
const searchOn = enableSearch ?? (variant === 'dropdown');
|
|
167
|
+
|
|
168
|
+
// ── Search: live input → 400ms debounce → the `search` wire param ──
|
|
169
|
+
// (the source's SearchView debounce, verbatim 400ms — deliberately NOT
|
|
170
|
+
// ModelTable's 300; a picker races the user's next keystroke less.)
|
|
171
|
+
const [input, setInput] = useState('');
|
|
172
|
+
const [term, setTerm] = useState('');
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
const t = setTimeout(() => setTerm(input.trim()), 400);
|
|
175
|
+
return () => clearTimeout(t);
|
|
176
|
+
}, [input]);
|
|
177
|
+
|
|
178
|
+
// ── Active group (requiresActiveGroup) ──────────────────────────────
|
|
179
|
+
// Read the context nullable-safely: consumers without a GroupProvider
|
|
180
|
+
// only misconfigure when they ASK for group scoping.
|
|
181
|
+
const groupCtx = useContext(GroupContext);
|
|
182
|
+
const activeGroupId = groupCtx?.group?.id ?? null;
|
|
183
|
+
const waitingForGroup = requiresActiveGroup && activeGroupId == null;
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (requiresActiveGroup && groupCtx === null) {
|
|
186
|
+
console.warn('CollectionMultiSelect: requiresActiveGroup is set but there is no <GroupProvider> above — the fetch stays held');
|
|
187
|
+
}
|
|
188
|
+
}, [requiresActiveGroup, groupCtx]);
|
|
189
|
+
|
|
190
|
+
// ── The fetch — SHARED cache keys ([endpoint, params], useModelList) ──
|
|
191
|
+
// defaultParams may be a callback: evaluated every render, so each fetch
|
|
192
|
+
// carries its freshest result (the source evaluated it once at setup).
|
|
193
|
+
const extra = typeof defaultParams === 'function' ? (defaultParams() ?? {}) : (defaultParams ?? {});
|
|
194
|
+
const wire: Params = {
|
|
195
|
+
size: 50, // page size on the WIRE (overridable via defaultParams) — not the visible-rows prop
|
|
196
|
+
...extra,
|
|
197
|
+
...(requiresActiveGroup && activeGroupId != null ? { group: activeGroupId } : {}),
|
|
198
|
+
...(searchOn && term ? { search: term } : {}),
|
|
199
|
+
};
|
|
200
|
+
const query = useModelList<T>(resolvedEndpoint, wire, { enabled: !waitingForGroup });
|
|
201
|
+
const rows = query.data?.rows;
|
|
202
|
+
|
|
203
|
+
// ignoreIds by content, not identity — an inline array literal must not
|
|
204
|
+
// reset the shift anchor (or churn anything) on every render.
|
|
205
|
+
const ignoreSig = JSON.stringify((ignoreIds ?? []).map(keyOf));
|
|
206
|
+
const ignoreKeys = useMemo(
|
|
207
|
+
() => new Set(JSON.parse(ignoreSig) as string[]),
|
|
208
|
+
[ignoreSig],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const items = useMemo<Item<T>[]>(() => {
|
|
212
|
+
const out: Item<T>[] = [];
|
|
213
|
+
for (const row of rows ?? []) {
|
|
214
|
+
const rawId = getPath(row, valueField);
|
|
215
|
+
if (rawId == null) {
|
|
216
|
+
warnFieldOnce(resolvedEndpoint, 'valueField', valueField, 'is missing on a row — row dropped');
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const id: string | number = typeof rawId === 'number' || typeof rawId === 'string' ? rawId : String(rawId);
|
|
220
|
+
const key = keyOf(id);
|
|
221
|
+
if (ignoreKeys.has(key)) continue; // client-side ignoreIds filter
|
|
222
|
+
const rawLabel = getPath(row, labelField);
|
|
223
|
+
if (rawLabel == null) {
|
|
224
|
+
warnFieldOnce(resolvedEndpoint, 'labelField', labelField, 'is missing on a row — showing the id');
|
|
225
|
+
}
|
|
226
|
+
out.push({
|
|
227
|
+
key,
|
|
228
|
+
id,
|
|
229
|
+
label: rawLabel == null ? String(id) : String(rawLabel),
|
|
230
|
+
disabled: disabled || (isRowDisabled?.(row) ?? false),
|
|
231
|
+
row,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}, [rows, ignoreKeys, valueField, labelField, disabled, isRowDisabled, resolvedEndpoint]);
|
|
236
|
+
|
|
237
|
+
// Selection is an ID SET over the controlled value — never row references,
|
|
238
|
+
// so it survives searches and refetches by construction.
|
|
239
|
+
const selectedKeys = useMemo(() => new Set(value.map(keyOf)), [value]);
|
|
240
|
+
|
|
241
|
+
// ── Trigger summary labels ──────────────────────────────────────────
|
|
242
|
+
// Labels accumulate from every row this control has SEEN (across searches
|
|
243
|
+
// and refetches) — a pick made under an old term keeps its label. An id
|
|
244
|
+
// never seen (hydrated initial value) is NOT an error — the summary just
|
|
245
|
+
// says "N selected"; no per-id fetches, no wrong labels, no warn.
|
|
246
|
+
const [labelCache, setLabelCache] = useState<ReadonlyMap<string, string>>(new Map());
|
|
247
|
+
useEffect(() => {
|
|
248
|
+
setLabelCache((prev) => {
|
|
249
|
+
let changed = false;
|
|
250
|
+
const next = new Map(prev);
|
|
251
|
+
for (const it of items) {
|
|
252
|
+
if (next.get(it.key) !== it.label) {
|
|
253
|
+
next.set(it.key, it.label);
|
|
254
|
+
changed = true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return changed ? next : prev;
|
|
258
|
+
});
|
|
259
|
+
}, [items]);
|
|
260
|
+
|
|
261
|
+
// ── Shift-click anchor: the last clicked row index ──────────────────
|
|
262
|
+
// Reset when the row set itself changes (new data, ignoreIds change) —
|
|
263
|
+
// indices into the old array are meaningless in the new one. Selection
|
|
264
|
+
// changes deliberately do NOT reset it (click, then shift-click works).
|
|
265
|
+
const anchorRef = useRef(-1);
|
|
266
|
+
useEffect(() => {
|
|
267
|
+
anchorRef.current = -1;
|
|
268
|
+
}, [rows, ignoreSig]);
|
|
269
|
+
|
|
270
|
+
const toggleRow = (index: number, shiftKey: boolean) => {
|
|
271
|
+
const item = items[index];
|
|
272
|
+
if (!item || item.disabled) return;
|
|
273
|
+
const anchor = anchorRef.current;
|
|
274
|
+
if (shiftKey && anchor >= 0 && anchor < items.length && anchor !== index) {
|
|
275
|
+
// Range toggle: the CLICKED row's new state applies across the
|
|
276
|
+
// whole span; disabled rows are skipped.
|
|
277
|
+
const nextSelect = !selectedKeys.has(item.key);
|
|
278
|
+
const start = Math.min(anchor, index);
|
|
279
|
+
const end = Math.max(anchor, index);
|
|
280
|
+
if (nextSelect) {
|
|
281
|
+
const have = new Set(selectedKeys);
|
|
282
|
+
const additions: Array<string | number> = [];
|
|
283
|
+
for (let i = start; i <= end; i++) {
|
|
284
|
+
const it = items[i]!;
|
|
285
|
+
if (it.disabled || have.has(it.key)) continue;
|
|
286
|
+
have.add(it.key);
|
|
287
|
+
additions.push(it.id);
|
|
288
|
+
}
|
|
289
|
+
if (additions.length > 0) onChange([...value, ...additions]);
|
|
290
|
+
} else {
|
|
291
|
+
const drop = new Set<string>();
|
|
292
|
+
for (let i = start; i <= end; i++) {
|
|
293
|
+
const it = items[i]!;
|
|
294
|
+
if (!it.disabled) drop.add(it.key);
|
|
295
|
+
}
|
|
296
|
+
const next = value.filter((v) => !drop.has(keyOf(v)));
|
|
297
|
+
if (next.length !== value.length) onChange(next);
|
|
298
|
+
}
|
|
299
|
+
} else if (selectedKeys.has(item.key)) {
|
|
300
|
+
onChange(value.filter((v) => keyOf(v) !== item.key));
|
|
301
|
+
} else {
|
|
302
|
+
onChange([...value, item.id]);
|
|
303
|
+
}
|
|
304
|
+
anchorRef.current = index;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// ── SELECT / DESELECT counts (live, disabled at their limits) ───────
|
|
308
|
+
// SELECT counts what it would add: visible rows that are enabled and not
|
|
309
|
+
// yet selected. DESELECT counts (and clears) the WHOLE value — including
|
|
310
|
+
// ids picked under an earlier search that aren't on screen right now.
|
|
311
|
+
const selectableCount = items.reduce((n, it) => n + (!it.disabled && !selectedKeys.has(it.key) ? 1 : 0), 0);
|
|
312
|
+
const selectedCount = value.length;
|
|
313
|
+
|
|
314
|
+
const selectAllVisible = () => {
|
|
315
|
+
const additions = items.filter((it) => !it.disabled && !selectedKeys.has(it.key)).map((it) => it.id);
|
|
316
|
+
if (additions.length > 0) onChange([...value, ...additions]);
|
|
317
|
+
};
|
|
318
|
+
const deselectAll = () => {
|
|
319
|
+
if (value.length > 0) onChange([]);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// ── Dropdown shell state (open / width / focus) ─────────────────────
|
|
323
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
324
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
325
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
326
|
+
const [open, setOpen] = useState(false);
|
|
327
|
+
// Menu width is glued to the trigger (never narrower than 260px — the
|
|
328
|
+
// search + action row needs the room) — set at open time so the popover's
|
|
329
|
+
// FIRST render is already right, then kept in sync while open.
|
|
330
|
+
const [menuWidth, setMenuWidth] = useState<number>();
|
|
331
|
+
|
|
332
|
+
const autoId = useId();
|
|
333
|
+
const menuId = `${autoId}-menu`;
|
|
334
|
+
const labelId = `${autoId}-label`;
|
|
335
|
+
const triggerId = id ?? `${autoId}-trigger`;
|
|
336
|
+
|
|
337
|
+
const close = useCallback((restoreFocus: boolean) => {
|
|
338
|
+
setOpen(false);
|
|
339
|
+
if (restoreFocus) triggerRef.current?.focus();
|
|
340
|
+
}, []);
|
|
341
|
+
|
|
342
|
+
const openMenu = () => {
|
|
343
|
+
const rect = triggerRef.current?.getBoundingClientRect();
|
|
344
|
+
if (rect) setMenuWidth(Math.max(rect.width, 260));
|
|
345
|
+
setOpen(true);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
useLayoutEffect(() => {
|
|
349
|
+
if (variant !== 'dropdown' || !open) return;
|
|
350
|
+
const el = triggerRef.current;
|
|
351
|
+
if (!el) return;
|
|
352
|
+
const sync = () => setMenuWidth(Math.max(el.getBoundingClientRect().width, 260));
|
|
353
|
+
sync();
|
|
354
|
+
const ro = new ResizeObserver(sync);
|
|
355
|
+
ro.observe(el);
|
|
356
|
+
return () => ro.disconnect();
|
|
357
|
+
}, [variant, open]);
|
|
358
|
+
|
|
359
|
+
const rowEls = () => Array.from(
|
|
360
|
+
bodyRef.current?.querySelectorAll<HTMLElement>('[data-cms-row]:not([aria-disabled="true"])') ?? [],
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// Opening moves focus INTO the menu (it is portaled to the end of <body>,
|
|
364
|
+
// so Tab from the trigger would not reach it): the search input when
|
|
365
|
+
// search is on, else the first enabled row. Focus returns to the trigger
|
|
366
|
+
// on Escape/Done (see close()).
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
if (variant !== 'dropdown' || !open) return;
|
|
369
|
+
if (searchOn) searchRef.current?.focus();
|
|
370
|
+
else rowEls()[0]?.focus();
|
|
371
|
+
}, [variant, open, searchOn]);
|
|
372
|
+
|
|
373
|
+
const onRowKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>, index: number) => {
|
|
374
|
+
switch (event.key) {
|
|
375
|
+
case ' ':
|
|
376
|
+
case 'Enter':
|
|
377
|
+
// The SAME toggleRow() a click runs — shiftKey included, so
|
|
378
|
+
// Shift+Space range-selects from the keyboard too.
|
|
379
|
+
event.preventDefault();
|
|
380
|
+
toggleRow(index, event.shiftKey);
|
|
381
|
+
return;
|
|
382
|
+
case 'ArrowDown':
|
|
383
|
+
case 'ArrowUp': {
|
|
384
|
+
event.preventDefault();
|
|
385
|
+
const list = rowEls();
|
|
386
|
+
if (list.length === 0) return;
|
|
387
|
+
const at = list.indexOf(event.currentTarget);
|
|
388
|
+
list[(at + (event.key === 'ArrowDown' ? 1 : -1) + list.length) % list.length]?.focus();
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
case 'Home':
|
|
392
|
+
case 'End': {
|
|
393
|
+
event.preventDefault();
|
|
394
|
+
const list = rowEls();
|
|
395
|
+
(event.key === 'Home' ? list[0] : list[list.length - 1])?.focus();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
default:
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// Skeleton whenever THIS key's rows aren't on screen yet (cold load or a
|
|
403
|
+
// search/params change still serving the previous key via placeholder) —
|
|
404
|
+
// the ModelTable pattern. Background refetches of the same key keep the
|
|
405
|
+
// live rows.
|
|
406
|
+
const pendingView = !waitingForGroup && (query.isPending || query.isPlaceholderData);
|
|
407
|
+
const listMaxHeight = maxHeight ?? size * 42;
|
|
408
|
+
|
|
409
|
+
const showActions = showSelectAll && !pendingView && items.length > 0;
|
|
410
|
+
|
|
411
|
+
// ── The shared body: search + actions + list/skeleton/empty/error ───
|
|
412
|
+
// Rendered identically inside the panel box and the dropdown menu.
|
|
413
|
+
const body = (
|
|
414
|
+
<div className="collection-multiselect-body" ref={bodyRef}>
|
|
415
|
+
{(searchOn || showActions) && (
|
|
416
|
+
<div className="collection-multiselect-head">
|
|
417
|
+
{/* search-box-fill: the house .search-box input is a 210px
|
|
418
|
+
TOOLBAR control; here it must fill the menu. */}
|
|
419
|
+
{searchOn && (
|
|
420
|
+
<div className="search-box search-box-fill collection-multiselect-search">
|
|
421
|
+
<i className="bi bi-search" />
|
|
422
|
+
<input
|
|
423
|
+
ref={searchRef}
|
|
424
|
+
type="search"
|
|
425
|
+
value={input}
|
|
426
|
+
placeholder={searchPlaceholder}
|
|
427
|
+
disabled={disabled}
|
|
428
|
+
onChange={(e) => setInput(e.target.value)}
|
|
429
|
+
onKeyDown={(e) => {
|
|
430
|
+
if (e.key === 'ArrowDown') {
|
|
431
|
+
e.preventDefault();
|
|
432
|
+
rowEls()[0]?.focus();
|
|
433
|
+
}
|
|
434
|
+
}}
|
|
435
|
+
aria-label={label ? `Search ${label}` : 'Search'}
|
|
436
|
+
/>
|
|
437
|
+
</div>
|
|
438
|
+
)}
|
|
439
|
+
{showActions && (
|
|
440
|
+
<div className="collection-multiselect-actions">
|
|
441
|
+
<button
|
|
442
|
+
type="button"
|
|
443
|
+
className="collection-multiselect-action"
|
|
444
|
+
disabled={disabled || selectableCount === 0}
|
|
445
|
+
onClick={selectAllVisible}
|
|
446
|
+
>
|
|
447
|
+
<i className="bi bi-check-square" />
|
|
448
|
+
SELECT{selectableCount > 0 && ` (${selectableCount})`}
|
|
449
|
+
</button>
|
|
450
|
+
<button
|
|
451
|
+
type="button"
|
|
452
|
+
className="collection-multiselect-action"
|
|
453
|
+
disabled={disabled || selectedCount === 0}
|
|
454
|
+
onClick={deselectAll}
|
|
455
|
+
>
|
|
456
|
+
DESELECT{selectedCount > 0 && ` (${selectedCount})`}
|
|
457
|
+
<i className="bi bi-square" />
|
|
458
|
+
</button>
|
|
459
|
+
</div>
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
)}
|
|
463
|
+
|
|
464
|
+
{pendingView ? (
|
|
465
|
+
<div className="collection-multiselect-list" style={{ maxHeight: listMaxHeight }} aria-hidden="true">
|
|
466
|
+
{Array.from({ length: Math.min(size, 8) }).map((_, i) => (
|
|
467
|
+
<div key={i} className="collection-multiselect-skel">
|
|
468
|
+
<span className="skel collection-multiselect-skel-check" />
|
|
469
|
+
<span className={`skel ${i % 2 === 0 ? 'skel-w-60' : 'skel-w-40'}`} />
|
|
470
|
+
</div>
|
|
471
|
+
))}
|
|
472
|
+
</div>
|
|
473
|
+
) : query.isError ? (
|
|
474
|
+
<div className="collection-multiselect-empty collection-multiselect-error" role="alert">
|
|
475
|
+
<i className="bi bi-exclamation-triangle" />
|
|
476
|
+
<div>{query.error instanceof Error ? query.error.message : 'Request failed'}</div>
|
|
477
|
+
<button type="button" className="btn btn-compact" onClick={() => void query.refetch()}>
|
|
478
|
+
Retry
|
|
479
|
+
</button>
|
|
480
|
+
</div>
|
|
481
|
+
) : items.length === 0 ? (
|
|
482
|
+
<div className="collection-multiselect-empty">
|
|
483
|
+
<i className="bi bi-inbox" />
|
|
484
|
+
<div>No items available</div>
|
|
485
|
+
</div>
|
|
486
|
+
) : (
|
|
487
|
+
<div
|
|
488
|
+
className="collection-multiselect-list"
|
|
489
|
+
style={{ maxHeight: listMaxHeight }}
|
|
490
|
+
role="group"
|
|
491
|
+
aria-label={label}
|
|
492
|
+
>
|
|
493
|
+
{items.map((item, index) => {
|
|
494
|
+
const checked = selectedKeys.has(item.key);
|
|
495
|
+
return (
|
|
496
|
+
<div
|
|
497
|
+
key={item.key}
|
|
498
|
+
data-cms-row=""
|
|
499
|
+
role="checkbox"
|
|
500
|
+
aria-checked={checked}
|
|
501
|
+
aria-disabled={item.disabled || undefined}
|
|
502
|
+
tabIndex={item.disabled ? -1 : 0}
|
|
503
|
+
className={[
|
|
504
|
+
'collection-multiselect-item',
|
|
505
|
+
checked ? 'is-selected' : '',
|
|
506
|
+
item.disabled ? 'is-disabled' : '',
|
|
507
|
+
].filter(Boolean).join(' ')}
|
|
508
|
+
onClick={(e) => toggleRow(index, e.shiftKey)}
|
|
509
|
+
onKeyDown={(e) => onRowKeyDown(e, index)}
|
|
510
|
+
>
|
|
511
|
+
{/* Purely visual — pointer-events:none in CSS, so every
|
|
512
|
+
interaction (incl. shiftKey) lands on the row; the
|
|
513
|
+
display can never diverge from the controlled state. */}
|
|
514
|
+
<input type="checkbox" className="tbl-check" checked={checked} disabled={item.disabled} tabIndex={-1} aria-hidden="true" readOnly />
|
|
515
|
+
{renderItem
|
|
516
|
+
? <span className="collection-multiselect-item-body">{renderItem(item.row)}</span>
|
|
517
|
+
: <span className="collection-multiselect-item-label">{item.label}</span>}
|
|
518
|
+
</div>
|
|
519
|
+
);
|
|
520
|
+
})}
|
|
521
|
+
</div>
|
|
522
|
+
)}
|
|
523
|
+
</div>
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const foot = error ? (
|
|
527
|
+
<div className="field-error">{error}</div>
|
|
528
|
+
) : help ? (
|
|
529
|
+
<div className="field-help">{help}</div>
|
|
530
|
+
) : null;
|
|
531
|
+
|
|
532
|
+
if (variant === 'panel') {
|
|
533
|
+
return (
|
|
534
|
+
<div className={`collection-multiselect${disabled ? ' collection-multiselect-disabled' : ''}`}>
|
|
535
|
+
{label && (
|
|
536
|
+
<div className="field-label">
|
|
537
|
+
{label}
|
|
538
|
+
{required && <em> *</em>}
|
|
539
|
+
</div>
|
|
540
|
+
)}
|
|
541
|
+
{body}
|
|
542
|
+
{foot}
|
|
543
|
+
</div>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Trigger summary: placeholder → up to N comma-joined labels (only when
|
|
548
|
+
// EVERY pick's label is known) → "N selected".
|
|
549
|
+
const allLabeled = value.every((v) => labelCache.has(keyOf(v)));
|
|
550
|
+
const triggerText = selectedCount === 0
|
|
551
|
+
? placeholder
|
|
552
|
+
: showSelectedLabels && selectedCount <= maxLabelsToShow && allLabeled
|
|
553
|
+
? value.map((v) => labelCache.get(keyOf(v))!).join(', ')
|
|
554
|
+
: `${selectedCount} selected`;
|
|
555
|
+
|
|
556
|
+
return (
|
|
557
|
+
<div className={`collection-multiselect collection-multiselect-dropdown${disabled ? ' collection-multiselect-disabled' : ''}`}>
|
|
558
|
+
{label && (
|
|
559
|
+
<div className="field-label" id={labelId}>
|
|
560
|
+
{label}
|
|
561
|
+
{required && <em> *</em>}
|
|
562
|
+
</div>
|
|
563
|
+
)}
|
|
564
|
+
|
|
565
|
+
<button
|
|
566
|
+
ref={triggerRef}
|
|
567
|
+
type="button"
|
|
568
|
+
id={triggerId}
|
|
569
|
+
className={`multiselect-trigger${open ? ' is-open' : ''}`}
|
|
570
|
+
disabled={disabled}
|
|
571
|
+
aria-haspopup="dialog"
|
|
572
|
+
aria-expanded={open}
|
|
573
|
+
aria-controls={open ? menuId : undefined}
|
|
574
|
+
// BOTH ids: the field label AND the trigger's own summary, so
|
|
575
|
+
// the accessible name reads "Groups, Ops, Design".
|
|
576
|
+
aria-labelledby={label ? `${labelId} ${triggerId}` : undefined}
|
|
577
|
+
onClick={() => (open ? close(true) : openMenu())}
|
|
578
|
+
onKeyDown={(event) => {
|
|
579
|
+
if (!open && event.key === 'ArrowDown') {
|
|
580
|
+
event.preventDefault();
|
|
581
|
+
openMenu();
|
|
582
|
+
}
|
|
583
|
+
}}
|
|
584
|
+
>
|
|
585
|
+
<span className={`multiselect-trigger-text${selectedCount === 0 ? ' is-placeholder' : ''}`}>
|
|
586
|
+
{triggerText}
|
|
587
|
+
</span>
|
|
588
|
+
<i className="bi bi-chevron-down" aria-hidden="true" />
|
|
589
|
+
</button>
|
|
590
|
+
|
|
591
|
+
<Popover
|
|
592
|
+
anchorRef={triggerRef}
|
|
593
|
+
open={open}
|
|
594
|
+
placement={placement}
|
|
595
|
+
// Escape always hands focus back; an outside click only does
|
|
596
|
+
// when focus is still parked inside the menu (never fight the
|
|
597
|
+
// element the user just clicked).
|
|
598
|
+
onClose={(reason) => close(
|
|
599
|
+
reason === 'escape' || bodyRef.current?.contains(document.activeElement) === true,
|
|
600
|
+
)}
|
|
601
|
+
id={menuId}
|
|
602
|
+
aria-label={label ?? 'Options'}
|
|
603
|
+
style={menuWidth === undefined ? undefined : { width: menuWidth }}
|
|
604
|
+
>
|
|
605
|
+
<div className="collection-multiselect-menu">
|
|
606
|
+
{body}
|
|
607
|
+
<div className="multiselect-footer">
|
|
608
|
+
<button
|
|
609
|
+
type="button"
|
|
610
|
+
className="btn btn-primary btn-compact multiselect-done"
|
|
611
|
+
onClick={() => close(true)}
|
|
612
|
+
>
|
|
613
|
+
Done
|
|
614
|
+
</button>
|
|
615
|
+
</div>
|
|
616
|
+
</div>
|
|
617
|
+
</Popover>
|
|
618
|
+
|
|
619
|
+
{foot}
|
|
620
|
+
</div>
|
|
621
|
+
);
|
|
622
|
+
}
|