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,587 @@
|
|
|
1
|
+
// DateTimePicker — the combined date + time field. Ported from web-mojo
|
|
2
|
+
// src/core/forms/inputs/DateTimePicker.js (523 lines, read in full) + the
|
|
3
|
+
// `.mojo-datetime-*` block of calendar.css.
|
|
4
|
+
//
|
|
5
|
+
// The source's locked "variant A" layout, unchanged: ONE popover holding the
|
|
6
|
+
// day Calendar on the left, the embedded TimePicker steppers on the right
|
|
7
|
+
// under a "Time" heading, a full-width timezone row below the pair (so the
|
|
8
|
+
// IANA combobox gets the whole width), and a Now/Done footer. One field, one
|
|
9
|
+
// value — the timezone is part of THIS picker, never a second field.
|
|
10
|
+
//
|
|
11
|
+
// Composition: `Calendar precision="day" mode="single"` + `TimePicker inline
|
|
12
|
+
// showFooter={false} timezone={false}` + a timezone slot. Every child is
|
|
13
|
+
// controlled from the one state below, so a value can never be displayed that
|
|
14
|
+
// this component's state does not hold.
|
|
15
|
+
//
|
|
16
|
+
// What leaves depends on `outputFormat`:
|
|
17
|
+
// 'iso' (default) → '2026-05-04T14:30:00-07:00' | '2026-05-04T14:30:00'
|
|
18
|
+
// 'iana' (legacy) → '2026-05-04 14:30 America/Los_Angeles'
|
|
19
|
+
// 'object' → { date: '2026-05-04', time: '14:30', timezone? }
|
|
20
|
+
// and all three parse back in, plus date-only, 12h text, and `Z`/`±HHMM`
|
|
21
|
+
// offsets in every spelling.
|
|
22
|
+
//
|
|
23
|
+
// The ISO offset is computed with `ianaOffset(zone, <the selected instant>)`,
|
|
24
|
+
// so it is DST-correct AT THE VALUE'S OWN DATE — a July value serializes
|
|
25
|
+
// -07:00 and a January one -08:00. (TimePicker cannot do this: a wall-clock
|
|
26
|
+
// time has no date, so it must resolve against `new Date()`.)
|
|
27
|
+
//
|
|
28
|
+
// React shape: the value is CONTROLLED but the picker also holds its own
|
|
29
|
+
// committed state, seeded from `value` and re-seeded whenever `value` changes
|
|
30
|
+
// to something that is not an echo of its own last emit — 'iso' output
|
|
31
|
+
// carries only the OFFSET, so re-parsing our own echo would silently degrade
|
|
32
|
+
// 'America/Los_Angeles' to '-07:00'. Same shape (and same reason) as
|
|
33
|
+
// Calendar/TimePicker.
|
|
34
|
+
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
35
|
+
import type { ReactNode } from 'react';
|
|
36
|
+
import { Popover } from '../Popover';
|
|
37
|
+
import { Calendar } from './Calendar';
|
|
38
|
+
import { TimePicker } from './TimePicker';
|
|
39
|
+
import type { TimeChangeEvent, TimeFormat, TimezoneSelectSlotProps } from './TimePicker';
|
|
40
|
+
import { TimezoneSelect, localTimezone } from './TimezoneSelect';
|
|
41
|
+
import {
|
|
42
|
+
compareTime, compareYmd, formatForDisplay, formatTime, formatYmd,
|
|
43
|
+
ianaOffset, parseDateTime, parseTime, parseYmd, today,
|
|
44
|
+
} from './fns';
|
|
45
|
+
import type { ParsedTime, ParsedYmd } from './fns';
|
|
46
|
+
|
|
47
|
+
/** 'iso' → `YYYY-MM-DDTHH:MM:00±HH:MM`; 'iana' → `YYYY-MM-DD HH:MM Zone`;
|
|
48
|
+
* 'object' → `{date, time, timezone?}`. */
|
|
49
|
+
export type DateTimeOutputFormat = 'iso' | 'iana' | 'object';
|
|
50
|
+
|
|
51
|
+
/** The object shape that comes OUT (`outputFormat: 'object'`): both parts are
|
|
52
|
+
* always present; `timezone` only when the zone picker is enabled. */
|
|
53
|
+
export interface DateTimeObjectValue {
|
|
54
|
+
date: string;
|
|
55
|
+
time: string;
|
|
56
|
+
timezone?: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The object shape accepted IN — every part optional, so a caller can seed a
|
|
60
|
+
* zone with no date, or a date with no time (which reads as 00:00). */
|
|
61
|
+
export interface DateTimeObjectInput {
|
|
62
|
+
date?: string | null;
|
|
63
|
+
time?: string | null;
|
|
64
|
+
timezone?: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What `onChange` hands back, in the `outputFormat` shape. */
|
|
68
|
+
export type DateTimeValue = string | DateTimeObjectValue | null;
|
|
69
|
+
/** What `value` accepts. Every `DateTimeValue` is a valid `DateTimeInput`, so
|
|
70
|
+
* writing `event.value` straight back is type-safe. */
|
|
71
|
+
export type DateTimeInput = string | DateTimeObjectInput | null;
|
|
72
|
+
|
|
73
|
+
export interface DateTimeChangeEvent {
|
|
74
|
+
/** The `outputFormat` shape — write this straight back into `value`. */
|
|
75
|
+
value: DateTimeValue;
|
|
76
|
+
/** Display text (`displayFormat` + `timeFormat`, zone appended when set). */
|
|
77
|
+
formatted: string;
|
|
78
|
+
/** The PREVIOUS canonical serialized STRING (`''` when empty) — the
|
|
79
|
+
* diffing key, never a mirror of `value`. */
|
|
80
|
+
oldValue: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface DateTimePickerProps {
|
|
84
|
+
/** Renders a hidden input carrying the serialized string (native-form use). */
|
|
85
|
+
name?: string;
|
|
86
|
+
/** Controlled value — string / `{date,time,timezone}` / null. */
|
|
87
|
+
value?: DateTimeInput;
|
|
88
|
+
/** Fires on COMMIT only, and only when the serialized value changed. */
|
|
89
|
+
onChange?: (event: DateTimeChangeEvent) => void;
|
|
90
|
+
/** Date half of the trigger text (formatForDisplay tokens). Default
|
|
91
|
+
* 'MMM DD, YYYY'. */
|
|
92
|
+
displayFormat?: string;
|
|
93
|
+
/** Display clock: '24h' (default) | '12h'. Unknown values warn + fall back. */
|
|
94
|
+
timeFormat?: TimeFormat;
|
|
95
|
+
/** Minute stepping increment for the time column, ≥1 (default 1). */
|
|
96
|
+
timeStep?: number;
|
|
97
|
+
/** Bounds. A date ('2026-05-04') or a datetime ('2026-05-04 09:00'): the
|
|
98
|
+
* DATE part bounds the calendar; a TIME part additionally clamps the time
|
|
99
|
+
* on the boundary day itself. */
|
|
100
|
+
min?: string | null;
|
|
101
|
+
max?: string | null;
|
|
102
|
+
/** Trigger text when empty. Default 'Pick date & time...'. */
|
|
103
|
+
placeholder?: string | null;
|
|
104
|
+
disabled?: boolean;
|
|
105
|
+
/** Displays but never opens/edits (the trigger stays focusable). */
|
|
106
|
+
readOnly?: boolean;
|
|
107
|
+
/** Suppresses the clear ✕. */
|
|
108
|
+
required?: boolean;
|
|
109
|
+
/** Paints the trigger with the error border. */
|
|
110
|
+
invalid?: boolean;
|
|
111
|
+
className?: string;
|
|
112
|
+
/** Trigger id, for a label's htmlFor. */
|
|
113
|
+
id?: string;
|
|
114
|
+
/** Render the panel in place — no trigger, no popover (and no Done: there
|
|
115
|
+
* is nothing to dismiss). */
|
|
116
|
+
inline?: boolean;
|
|
117
|
+
/** Individually blocked days, "YYYY-MM-DD". */
|
|
118
|
+
disabledDates?: string[];
|
|
119
|
+
/** First weekday: 0=Sun, 1=Mon, … Default 1. */
|
|
120
|
+
firstDay?: number;
|
|
121
|
+
/** BCP-47 locale for month/weekday names. Default 'en-US'. */
|
|
122
|
+
locale?: string;
|
|
123
|
+
/** true enables the timezone row; an array enables it AND is the zone list. */
|
|
124
|
+
timezone?: boolean | string[];
|
|
125
|
+
/** Zone list when `timezone` is `true`. Default: the engine's own list. */
|
|
126
|
+
timezones?: string[] | null;
|
|
127
|
+
/** Default 'iso'. Unknown values warn + fall back. */
|
|
128
|
+
outputFormat?: DateTimeOutputFormat;
|
|
129
|
+
/** Zone-picker slot. Absent → the house <TimezoneSelect>. */
|
|
130
|
+
renderTimezoneSelect?: (props: TimezoneSelectSlotProps) => ReactNode;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const DEFAULT_DISPLAY_FORMAT = 'MMM DD, YYYY';
|
|
134
|
+
const DEFAULT_PLACEHOLDER = 'Pick date & time...';
|
|
135
|
+
|
|
136
|
+
/** A zone that is already a fixed offset (what an 'iso' value parses back to). */
|
|
137
|
+
const OFFSET_ZONE = /^[+-]\d{2}:\d{2}$/;
|
|
138
|
+
/** An offset separated from the time by a SPACE — see `parseInitial`. */
|
|
139
|
+
const TRAILING_OFFSET = /\s+([+-]\d{2}:?\d{2}|Z)$/;
|
|
140
|
+
/** Does a bound string carry a time part at all, or is it date-only? */
|
|
141
|
+
const HAS_TIME_PART = /[T ]\s*\d{1,2}:\d{2}/;
|
|
142
|
+
|
|
143
|
+
interface DTState {
|
|
144
|
+
date: ParsedYmd | null;
|
|
145
|
+
time: ParsedTime | null;
|
|
146
|
+
timezone: string | null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** min/max parsed: the date always, the time only when the string spelled one. */
|
|
150
|
+
interface Bound { date: ParsedYmd; time: ParsedTime | null }
|
|
151
|
+
|
|
152
|
+
// ── Pure helpers ───────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function emptyState(): DTState { return { date: null, time: null, timezone: null }; }
|
|
155
|
+
|
|
156
|
+
/** Unknown enum-ish prop values fall back WITH a warn — never render nothing. */
|
|
157
|
+
function normTimeFormat(f: TimeFormat | undefined): TimeFormat {
|
|
158
|
+
if (f == null || f === '24h' || f === '12h') return f ?? '24h';
|
|
159
|
+
console.warn(`DateTimePicker: unknown timeFormat "${String(f)}" — falling back to "24h"`);
|
|
160
|
+
return '24h';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normOutputFormat(f: DateTimeOutputFormat | undefined): DateTimeOutputFormat {
|
|
164
|
+
if (f == null || f === 'iso' || f === 'iana' || f === 'object') return f ?? 'iso';
|
|
165
|
+
console.warn(`DateTimePicker: unknown outputFormat "${String(f)}" — falling back to "iso"`);
|
|
166
|
+
return 'iso';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 'Z' / '±HHMM' / '±HH:MM' → the canonical '±HH:MM'. */
|
|
170
|
+
function normOffset(raw: string): string {
|
|
171
|
+
if (raw === 'Z') return '+00:00';
|
|
172
|
+
return /^[+-]\d{4}$/.test(raw) ? `${raw.slice(0, 3)}:${raw.slice(3)}` : raw;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Parse any accepted input shape into `{date, time, timezone}` (source
|
|
177
|
+
* `_parseInitial` + `dateFns.parseDateTime`). Two extensions over
|
|
178
|
+
* `parseDateTime`, both because it would otherwise drop data the caller gave:
|
|
179
|
+
*
|
|
180
|
+
* 1. An object with no date still keeps its `timezone` (parseDateTime returns
|
|
181
|
+
* null for the whole thing, losing the zone).
|
|
182
|
+
* 2. An offset separated from the time by a SPACE — '2026-05-04 14:30 -07:00'.
|
|
183
|
+
* parseDateTime only reads an offset glued to the time (ISO spelling) and
|
|
184
|
+
* an IANA *name* after a space, so this spelling lost its zone silently.
|
|
185
|
+
*/
|
|
186
|
+
function parseInitial(raw: DateTimeInput | undefined): DTState {
|
|
187
|
+
if (raw == null || raw === '') return emptyState();
|
|
188
|
+
if (typeof raw === 'object') {
|
|
189
|
+
const date = raw.date ? parseYmd(raw.date) : null;
|
|
190
|
+
const time = raw.time ? parseTime(raw.time) : null;
|
|
191
|
+
return {
|
|
192
|
+
date,
|
|
193
|
+
// A date with no time reads as midnight, as everywhere else.
|
|
194
|
+
time: date && !time ? { hours: 0, minutes: 0 } : time,
|
|
195
|
+
timezone: raw.timezone || null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const str = String(raw).trim();
|
|
199
|
+
const spaced = str.match(TRAILING_OFFSET);
|
|
200
|
+
if (spaced) {
|
|
201
|
+
const head = parseDateTime(str.slice(0, spaced.index).trim());
|
|
202
|
+
if (head) return { date: head.date, time: head.time, timezone: normOffset(spaced[1]!) };
|
|
203
|
+
}
|
|
204
|
+
const parsed = parseDateTime(str);
|
|
205
|
+
return parsed ? { date: parsed.date, time: parsed.time, timezone: parsed.timezone } : emptyState();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Canonical serialization — always a STRING (the hidden input + the diff key).
|
|
209
|
+
* 'object' output diffs on this too, exactly as the source did. */
|
|
210
|
+
function serialize(s: DTState, tzEnabled: boolean, outputFormat: DateTimeOutputFormat): string {
|
|
211
|
+
if (!s.date) return '';
|
|
212
|
+
const d = formatYmd(s.date);
|
|
213
|
+
const t = s.time ? formatTime(s.time, '24h') : '00:00';
|
|
214
|
+
const zone = tzEnabled ? s.timezone : null;
|
|
215
|
+
if (outputFormat === 'iana') return zone ? `${d} ${t} ${zone}` : `${d} ${t}`;
|
|
216
|
+
// ISO 8601 — the default.
|
|
217
|
+
let iso = `${d}T${t}:00`;
|
|
218
|
+
if (zone) {
|
|
219
|
+
// The reference instant is THE VALUE ITSELF, so the offset is the one
|
|
220
|
+
// in force on the selected date (DST-correct), not today's.
|
|
221
|
+
const offset = OFFSET_ZONE.test(zone)
|
|
222
|
+
// A zone that is already an offset passes through: `ianaOffset`
|
|
223
|
+
// returns null for it, which is how the source dropped it on
|
|
224
|
+
// re-save (same fix as TimePicker).
|
|
225
|
+
? zone
|
|
226
|
+
: ianaOffset(zone, new Date(s.date.y, s.date.m - 1, s.date.d, s.time?.hours ?? 0, s.time?.minutes ?? 0));
|
|
227
|
+
if (offset) iso += offset;
|
|
228
|
+
}
|
|
229
|
+
return iso;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The `outputFormat` shape handed to `onChange` (source `getValue`). */
|
|
233
|
+
function readOut(s: DTState, tzEnabled: boolean, outputFormat: DateTimeOutputFormat): DateTimeValue {
|
|
234
|
+
if (!s.date) return outputFormat === 'object' ? null : '';
|
|
235
|
+
if (outputFormat === 'object') {
|
|
236
|
+
const out: DateTimeObjectValue = {
|
|
237
|
+
date: formatYmd(s.date),
|
|
238
|
+
time: s.time ? formatTime(s.time, '24h') : '00:00',
|
|
239
|
+
};
|
|
240
|
+
// Source parity: the key exists (possibly null) whenever the zone
|
|
241
|
+
// picker is on, and is absent entirely when it is off.
|
|
242
|
+
if (tzEnabled) out.timezone = s.timezone ?? null;
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
return serialize(s, tzEnabled, outputFormat);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function displayText(s: DTState, tzEnabled: boolean, displayFormat: string, timeFormat: TimeFormat): string {
|
|
249
|
+
if (!s.date) return '';
|
|
250
|
+
const datePart = formatForDisplay(s.date, displayFormat);
|
|
251
|
+
const timePart = s.time
|
|
252
|
+
? formatTime(s.time, timeFormat)
|
|
253
|
+
: (timeFormat === '12h' ? '12:00 AM' : '00:00');
|
|
254
|
+
const out = `${datePart} ${timePart}`;
|
|
255
|
+
return tzEnabled && s.timezone ? `${out} ${s.timezone}` : out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Identity key for a `value` prop — how an echo of our own emit is spotted. */
|
|
259
|
+
function valueKey(v: DateTimeInput | undefined): string {
|
|
260
|
+
if (v == null || v === '') return '';
|
|
261
|
+
if (typeof v === 'object') return `${v.date ?? ''}|${v.time ?? ''}|${v.timezone ?? ''}`;
|
|
262
|
+
return v;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Calendar bound: source `_dateBound` — accept a date OR a datetime and keep
|
|
266
|
+
* the date part. */
|
|
267
|
+
function dateBound(v: string | null | undefined): string | null {
|
|
268
|
+
if (v == null || v === '') return null;
|
|
269
|
+
return String(v).trim().split(/[ T]/)[0] ?? null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseBound(v: string | null | undefined): Bound | null {
|
|
273
|
+
if (v == null || v === '') return null;
|
|
274
|
+
const str = String(v).trim();
|
|
275
|
+
const date = parseYmd(dateBound(str));
|
|
276
|
+
if (!date) return null;
|
|
277
|
+
const parsed = HAS_TIME_PART.test(str) ? parseDateTime(str) : null;
|
|
278
|
+
return { date, time: parsed ? parsed.time : null };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Snap a datetime into [min, max]. The date is snapped to the boundary DAY;
|
|
283
|
+
* the time is only clamped when the bound actually spelled one AND we are on
|
|
284
|
+
* that boundary day (a bare '2026-05-04' max must not force 00:00).
|
|
285
|
+
*
|
|
286
|
+
* Nice-to-have over source, which only date-bounded the calendar and left the
|
|
287
|
+
* time free — a 09:00–17:30 window on the first/last day was unenforceable.
|
|
288
|
+
*/
|
|
289
|
+
function clampDateTime(date: ParsedYmd, time: ParsedTime, minB: Bound | null, maxB: Bound | null): { date: ParsedYmd; time: ParsedTime } {
|
|
290
|
+
let d = date;
|
|
291
|
+
let t = time;
|
|
292
|
+
if (minB && compareYmd(d, minB.date) < 0) { d = { ...minB.date }; if (minB.time) t = { ...minB.time }; }
|
|
293
|
+
if (maxB && compareYmd(d, maxB.date) > 0) { d = { ...maxB.date }; if (maxB.time) t = { ...maxB.time }; }
|
|
294
|
+
if (minB?.time && compareYmd(d, minB.date) === 0 && compareTime(t, minB.time) < 0) t = { ...minB.time };
|
|
295
|
+
if (maxB?.time && compareYmd(d, maxB.date) === 0 && compareTime(t, maxB.time) > 0) t = { ...maxB.time };
|
|
296
|
+
return { date: d, time: t };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The zone slot's default tenant: the house picker, adapted to the slot's
|
|
300
|
+
* `(tz: string | null) => void` contract. */
|
|
301
|
+
function houseTimezoneSelect({ value, onChange, timezones, disabled }: TimezoneSelectSlotProps): ReactNode {
|
|
302
|
+
return (
|
|
303
|
+
<TimezoneSelect
|
|
304
|
+
value={value}
|
|
305
|
+
timezones={timezones ?? null}
|
|
306
|
+
disabled={disabled}
|
|
307
|
+
onChange={(event) => onChange(event.value)}
|
|
308
|
+
/>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── Component ──────────────────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
export function DateTimePicker(props: DateTimePickerProps) {
|
|
315
|
+
const timeFormat = normTimeFormat(props.timeFormat);
|
|
316
|
+
const outputFormat = normOutputFormat(props.outputFormat);
|
|
317
|
+
const {
|
|
318
|
+
name, value = '', onChange,
|
|
319
|
+
displayFormat = DEFAULT_DISPLAY_FORMAT,
|
|
320
|
+
min = null, max = null,
|
|
321
|
+
disabled = false, readOnly = false, required = false, invalid = false,
|
|
322
|
+
className, id, inline = false,
|
|
323
|
+
disabledDates, firstDay = 1, locale = 'en-US',
|
|
324
|
+
renderTimezoneSelect = houseTimezoneSelect,
|
|
325
|
+
} = props;
|
|
326
|
+
const placeholder = props.placeholder ?? DEFAULT_PLACEHOLDER;
|
|
327
|
+
const stepRaw = Number.parseInt(String(props.timeStep ?? 1), 10);
|
|
328
|
+
const timeStep = Math.max(1, Number.isFinite(stepRaw) ? stepRaw : 1);
|
|
329
|
+
const tzEnabled = props.timezone === true || Array.isArray(props.timezone);
|
|
330
|
+
const tzList = Array.isArray(props.timezone) ? props.timezone : (props.timezones ?? undefined);
|
|
331
|
+
const locked = disabled || readOnly;
|
|
332
|
+
|
|
333
|
+
const [state, setState] = useState<DTState>(() => {
|
|
334
|
+
const init = parseInitial(value);
|
|
335
|
+
return { ...init, timezone: init.timezone ?? (tzEnabled ? localTimezone() : null) };
|
|
336
|
+
});
|
|
337
|
+
const [open, setOpen] = useState(false);
|
|
338
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
339
|
+
const reactId = useId();
|
|
340
|
+
const popId = `${reactId}-dt`;
|
|
341
|
+
|
|
342
|
+
// ── Controlled sync (source `setValue` semantics) ─────────────
|
|
343
|
+
// Our own commits echo back as a `value` prop change; re-parsing that echo
|
|
344
|
+
// is exactly what would degrade an IANA zone to its offset, so skip it.
|
|
345
|
+
const prevKey = useRef(valueKey(value));
|
|
346
|
+
const echoKey = useRef<string | null>(null);
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
const key = valueKey(value);
|
|
349
|
+
if (key === prevKey.current) return;
|
|
350
|
+
prevKey.current = key;
|
|
351
|
+
const echo = echoKey.current !== null && echoKey.current === key;
|
|
352
|
+
echoKey.current = null;
|
|
353
|
+
if (echo) return;
|
|
354
|
+
const init = parseInitial(value);
|
|
355
|
+
// Source: a value without a zone LEAVES the current zone in place.
|
|
356
|
+
setState((s) => ({ date: init.date, time: init.time, timezone: init.timezone ?? s.timezone }));
|
|
357
|
+
}, [value]);
|
|
358
|
+
|
|
359
|
+
// Rule 4: an unparseable value renders as empty, but never silently —
|
|
360
|
+
// once per distinct bad value, and never during render.
|
|
361
|
+
const warnedRef = useRef<string | null>(null);
|
|
362
|
+
useEffect(() => {
|
|
363
|
+
if (value == null || value === '' || typeof value === 'object') return;
|
|
364
|
+
if (parseInitial(value).date) return;
|
|
365
|
+
if (warnedRef.current === value) return;
|
|
366
|
+
warnedRef.current = value;
|
|
367
|
+
console.warn(`DateTimePicker: value ${JSON.stringify(value)} is not a datetime this picker can parse — showing empty`);
|
|
368
|
+
}, [value]);
|
|
369
|
+
|
|
370
|
+
// Turning the zone row on seeds the local zone (source constructor). Like
|
|
371
|
+
// the source this does NOT emit — the owner learns on the next commit.
|
|
372
|
+
useEffect(() => {
|
|
373
|
+
if (!tzEnabled) return;
|
|
374
|
+
setState((s) => (s.timezone ? s : { ...s, timezone: localTimezone() }));
|
|
375
|
+
}, [tzEnabled]);
|
|
376
|
+
|
|
377
|
+
useEffect(() => { if (locked) setOpen(false); }, [locked]);
|
|
378
|
+
|
|
379
|
+
// ── Bounds ────────────────────────────────────────────────────
|
|
380
|
+
const minB = useMemo(() => parseBound(min), [min]);
|
|
381
|
+
const maxB = useMemo(() => parseBound(max), [max]);
|
|
382
|
+
|
|
383
|
+
// ── Commit ────────────────────────────────────────────────────
|
|
384
|
+
// The ONE value-mutation boundary — every interaction funnels here, so the
|
|
385
|
+
// disabled/readOnly guard lives here too (an `inline` panel has no popover
|
|
386
|
+
// to keep shut).
|
|
387
|
+
const commit = (next: DTState) => {
|
|
388
|
+
if (locked) return;
|
|
389
|
+
const oldStored = serialize(state, tzEnabled, outputFormat);
|
|
390
|
+
const newStored = serialize(next, tzEnabled, outputFormat);
|
|
391
|
+
setState(next);
|
|
392
|
+
if (oldStored === newStored) return; // no-op edits stay silent
|
|
393
|
+
const emitted = readOut(next, tzEnabled, outputFormat);
|
|
394
|
+
echoKey.current = valueKey(emitted);
|
|
395
|
+
onChange?.({
|
|
396
|
+
value: emitted,
|
|
397
|
+
formatted: displayText(next, tzEnabled, displayFormat, timeFormat),
|
|
398
|
+
oldValue: oldStored,
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/** Source `_refreshTimezoneDefault`: a commit never leaves the zone row
|
|
403
|
+
* showing a zone the value doesn't carry. */
|
|
404
|
+
const zoneNow = () => (tzEnabled ? state.timezone ?? localTimezone() : state.timezone);
|
|
405
|
+
|
|
406
|
+
const commitDate = (date: ParsedYmd | null) => {
|
|
407
|
+
if (!date) return;
|
|
408
|
+
// Source: picking a date with no time yet defaults the time to 00:00.
|
|
409
|
+
const clamped = clampDateTime(date, state.time ?? { hours: 0, minutes: 0 }, minB, maxB);
|
|
410
|
+
commit({ date: clamped.date, time: clamped.time, timezone: zoneNow() });
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
const commitTime = (time: ParsedTime | null) => {
|
|
414
|
+
if (!time) { commit({ date: state.date, time: null, timezone: zoneNow() }); return; }
|
|
415
|
+
// Source: a time set without a date defaults the date to today (and
|
|
416
|
+
// the calendar follows — it reads the same state).
|
|
417
|
+
const clamped = clampDateTime(state.date ?? today(), time, minB, maxB);
|
|
418
|
+
commit({ date: clamped.date, time: clamped.time, timezone: zoneNow() });
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const commitNow = () => {
|
|
422
|
+
const now = new Date();
|
|
423
|
+
const clamped = clampDateTime(today(), { hours: now.getHours(), minutes: now.getMinutes() }, minB, maxB);
|
|
424
|
+
commit({ date: clamped.date, time: clamped.time, timezone: zoneNow() });
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const setTimezone = (tz: string | null) => {
|
|
428
|
+
const next = tz || null;
|
|
429
|
+
if (next === state.timezone) return;
|
|
430
|
+
commit({ date: state.date, time: state.time, timezone: next });
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const clearValue = () => {
|
|
434
|
+
if (!state.date && !state.time) return; // nothing to clear
|
|
435
|
+
// Source `clear()` keeps the zone — it is a picker setting, not data.
|
|
436
|
+
commit({ date: null, time: null, timezone: state.timezone });
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const handleTimeChange = (event: TimeChangeEvent) => {
|
|
440
|
+
// The embedded picker runs zone-less on the default 'iso' output, so
|
|
441
|
+
// its value is always the plain 'HH:MM' string (or '' when cleared).
|
|
442
|
+
const raw = typeof event.value === 'string' ? event.value : event.value ? event.value.time : '';
|
|
443
|
+
commitTime(raw ? parseTime(raw) : null);
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// ── Render ────────────────────────────────────────────────────
|
|
447
|
+
const stored = serialize(state, tzEnabled, outputFormat);
|
|
448
|
+
const text = displayText(state, tzEnabled, displayFormat, timeFormat);
|
|
449
|
+
const clearable = !required && !disabled && !readOnly;
|
|
450
|
+
const hidden = name ? <input type="hidden" name={name} value={stored} readOnly /> : null;
|
|
451
|
+
|
|
452
|
+
// Day-scoped time bounds: the embedded picker clamps ITSELF on the
|
|
453
|
+
// boundary day, so its state can never disagree with ours (a parent-only
|
|
454
|
+
// clamp would leave its steppers showing the pre-clamp value).
|
|
455
|
+
const innerMin = state.date && minB?.time && compareYmd(state.date, minB.date) === 0
|
|
456
|
+
? formatTime(minB.time, '24h') : null;
|
|
457
|
+
const innerMax = state.date && maxB?.time && compareYmd(state.date, maxB.date) === 0
|
|
458
|
+
? formatTime(maxB.time, '24h') : null;
|
|
459
|
+
|
|
460
|
+
const panel = (
|
|
461
|
+
<div className="mojo-datetime-popover-inner">
|
|
462
|
+
<div className="mojo-datetime-row">
|
|
463
|
+
<div className="mojo-datetime-cal-col">
|
|
464
|
+
<Calendar
|
|
465
|
+
precision="day"
|
|
466
|
+
mode="single"
|
|
467
|
+
months={1}
|
|
468
|
+
value={state.date ? formatYmd(state.date) : null}
|
|
469
|
+
min={dateBound(min)}
|
|
470
|
+
max={dateBound(max)}
|
|
471
|
+
disabledDates={disabledDates}
|
|
472
|
+
firstDay={firstDay}
|
|
473
|
+
locale={locale}
|
|
474
|
+
onSelect={locked ? undefined : (event) => commitDate(parseYmd(event.value))}
|
|
475
|
+
/>
|
|
476
|
+
</div>
|
|
477
|
+
<div className="mojo-datetime-time-col">
|
|
478
|
+
<div className="mojo-datetime-time-head">Time</div>
|
|
479
|
+
<TimePicker
|
|
480
|
+
inline
|
|
481
|
+
showFooter={false}
|
|
482
|
+
timezone={false}
|
|
483
|
+
format={timeFormat}
|
|
484
|
+
step={timeStep}
|
|
485
|
+
value={state.time ? formatTime(state.time, '24h') : ''}
|
|
486
|
+
min={innerMin}
|
|
487
|
+
max={innerMax}
|
|
488
|
+
disabled={disabled}
|
|
489
|
+
readOnly={readOnly}
|
|
490
|
+
onChange={handleTimeChange}
|
|
491
|
+
/>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
|
|
495
|
+
{tzEnabled && (
|
|
496
|
+
<div className="mojo-datetime-tz-row">
|
|
497
|
+
<div className="mojo-datetime-tz-label">Timezone</div>
|
|
498
|
+
<div className="mojo-datetime-tz-host">
|
|
499
|
+
{renderTimezoneSelect({
|
|
500
|
+
value: state.timezone,
|
|
501
|
+
onChange: setTimezone,
|
|
502
|
+
timezones: tzList,
|
|
503
|
+
disabled: locked,
|
|
504
|
+
})}
|
|
505
|
+
</div>
|
|
506
|
+
</div>
|
|
507
|
+
)}
|
|
508
|
+
|
|
509
|
+
<div className="mojo-datetime-foot">
|
|
510
|
+
<button
|
|
511
|
+
type="button" className="btn btn-compact mojo-datetime-now"
|
|
512
|
+
disabled={locked} onClick={commitNow}
|
|
513
|
+
>Now</button>
|
|
514
|
+
{/* Done exists to dismiss the popover — inline has none, so it
|
|
515
|
+
is omitted rather than rendered dead (source rendered it). */}
|
|
516
|
+
{!inline && (
|
|
517
|
+
<button
|
|
518
|
+
type="button" className="btn btn-primary btn-compact mojo-datetime-done"
|
|
519
|
+
onClick={() => setOpen(false)}
|
|
520
|
+
>Done</button>
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
</div>
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const rootClass = `mojo-datetime-picker${className ? ` ${className}` : ''}`;
|
|
527
|
+
|
|
528
|
+
// ── Inline: no trigger, no popover ────────────────────────────
|
|
529
|
+
if (inline) {
|
|
530
|
+
return (
|
|
531
|
+
<div className={rootClass}>
|
|
532
|
+
{hidden}
|
|
533
|
+
<div
|
|
534
|
+
className={`mojo-datetime-picker-inline${invalid ? ' is-invalid' : ''}${disabled ? ' is-disabled' : ''}`}
|
|
535
|
+
aria-disabled={locked || undefined}
|
|
536
|
+
>
|
|
537
|
+
{panel}
|
|
538
|
+
</div>
|
|
539
|
+
</div>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── Trigger + popover ─────────────────────────────────────────
|
|
544
|
+
return (
|
|
545
|
+
<div className={rootClass}>
|
|
546
|
+
<button
|
|
547
|
+
type="button"
|
|
548
|
+
ref={triggerRef}
|
|
549
|
+
id={id}
|
|
550
|
+
className={`mojo-datetime-trigger${invalid ? ' is-invalid' : ''}${clearable ? ' has-clear' : ''}${readOnly ? ' is-readonly' : ''}`}
|
|
551
|
+
disabled={disabled}
|
|
552
|
+
aria-haspopup="dialog"
|
|
553
|
+
aria-expanded={open}
|
|
554
|
+
aria-controls={open ? popId : undefined}
|
|
555
|
+
// readOnly keeps the button focusable but actionless — that is
|
|
556
|
+
// aria-disabled, not aria-readonly (invalid for role=button).
|
|
557
|
+
aria-disabled={readOnly || undefined}
|
|
558
|
+
onClick={() => { if (!locked) setOpen((o) => !o); }}
|
|
559
|
+
>
|
|
560
|
+
<i className="bi bi-calendar3" aria-hidden="true" />
|
|
561
|
+
<span className={`mojo-datetime-trigger-text${state.date ? '' : ' is-empty'}`}>
|
|
562
|
+
{text || placeholder}
|
|
563
|
+
</span>
|
|
564
|
+
</button>
|
|
565
|
+
{/* Sibling, not nested: the source put a <button> inside the
|
|
566
|
+
trigger <button> (invalid HTML). The wrapper carries hover. */}
|
|
567
|
+
{clearable && (
|
|
568
|
+
<button
|
|
569
|
+
type="button" className="mojo-datetime-trigger-clear" aria-label="Clear"
|
|
570
|
+
tabIndex={-1} onClick={clearValue}
|
|
571
|
+
>✕</button>
|
|
572
|
+
)}
|
|
573
|
+
{hidden}
|
|
574
|
+
<Popover
|
|
575
|
+
anchorRef={triggerRef}
|
|
576
|
+
open={open}
|
|
577
|
+
onClose={() => setOpen(false)}
|
|
578
|
+
placement="bottom-start"
|
|
579
|
+
id={popId}
|
|
580
|
+
className="mojo-datetime-popover"
|
|
581
|
+
aria-label="Choose date and time"
|
|
582
|
+
>
|
|
583
|
+
{panel}
|
|
584
|
+
</Popover>
|
|
585
|
+
</div>
|
|
586
|
+
);
|
|
587
|
+
}
|