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,624 @@
|
|
|
1
|
+
// TimePicker — HH:MM time input with the stepper panel and optional IANA
|
|
2
|
+
// timezone. Ported from web-mojo src/core/forms/inputs/TimePicker.js (648
|
|
3
|
+
// lines, read in full) + the `.mojo-time-*` block of calendar.css.
|
|
4
|
+
//
|
|
5
|
+
// The panel is the source's locked "variant B": per unit a column of
|
|
6
|
+
// ▲ / value input / ▼ / label, with direct numeric typing on the value.
|
|
7
|
+
// (Wheels were deferred to a future mobile mode in the source; not carried.)
|
|
8
|
+
//
|
|
9
|
+
// Storage is ALWAYS canonical 24h `HH:MM` internally — `format` only decides
|
|
10
|
+
// what the panel and the trigger show. What leaves the component depends on
|
|
11
|
+
// `outputFormat`:
|
|
12
|
+
// 'iso' (default) → '14:30-07:00' (zone set) | '14:30'
|
|
13
|
+
// 'iana' (legacy) → '14:30 America/Los_Angeles' | '14:30'
|
|
14
|
+
// 'object' → { time: '14:30', timezone: 'America/Los_Angeles' }
|
|
15
|
+
// and all three shapes PARSE back in, plus 12h "h:mm AM/PM" and ISO
|
|
16
|
+
// `Z`/`±HHMM`/`±HH:MM` offsets.
|
|
17
|
+
//
|
|
18
|
+
// React shape: the TIME is controlled — `value` in, `onChange` out, change
|
|
19
|
+
// fires on COMMIT only (stepper click, AM/PM, Enter/blur, Now, clear, zone
|
|
20
|
+
// pick), never per keystroke; a typed-but-uncommitted value lives in `draft`
|
|
21
|
+
// and can never be read as the value. The ZONE is internal state seeded from
|
|
22
|
+
// `value` (an 'iso' round-trip only carries the OFFSET, so re-deriving the
|
|
23
|
+
// zone from our own echoed output would silently degrade
|
|
24
|
+
// "America/Los_Angeles" to "-07:00" — the echo guard is what stops that).
|
|
25
|
+
//
|
|
26
|
+
// Timezone-picker SEAM: this component owns every bit of the zone's value and
|
|
27
|
+
// serialization logic but renders the picker through `renderTimezoneSelect`.
|
|
28
|
+
// Without that prop it falls back to a plain token-styled <select> built from
|
|
29
|
+
// Intl.supportedValuesOf('timeZone'), so the feature is complete standalone.
|
|
30
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
31
|
+
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject } from 'react';
|
|
32
|
+
import { Popover } from '../Popover';
|
|
33
|
+
import { compareTime, formatTime, ianaOffset, parseTime } from './fns';
|
|
34
|
+
import type { ParsedTime } from './fns';
|
|
35
|
+
|
|
36
|
+
export type TimeFormat = '24h' | '12h';
|
|
37
|
+
/** 'iso' → `HH:MM±HH:MM`; 'iana' → `HH:MM Zone`; 'object' → `{time, timezone}`. */
|
|
38
|
+
export type TimeOutputFormat = 'iso' | 'iana' | 'object';
|
|
39
|
+
|
|
40
|
+
export interface TimeObjectValue { time: string; timezone: string | null }
|
|
41
|
+
/** Everything the picker accepts in and (per `outputFormat`) hands back. */
|
|
42
|
+
export type TimeValue = string | TimeObjectValue | null;
|
|
43
|
+
|
|
44
|
+
export interface TimeChangeEvent {
|
|
45
|
+
/** The `outputFormat` shape — write this straight back into `value`. */
|
|
46
|
+
value: TimeValue;
|
|
47
|
+
/** Display text (`format`, zone appended when one is set). */
|
|
48
|
+
formatted: string;
|
|
49
|
+
/** The PREVIOUS canonical serialized string (source parity: always a
|
|
50
|
+
* string, `''` when there was no time — it is the diffing key). */
|
|
51
|
+
oldValue: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Props handed to `renderTimezoneSelect`. The real TimezoneSelect (built
|
|
55
|
+
* separately) plugs in here; nothing about the value pipeline moves with it. */
|
|
56
|
+
export interface TimezoneSelectSlotProps {
|
|
57
|
+
value: string | null;
|
|
58
|
+
onChange: (tz: string | null) => void;
|
|
59
|
+
timezones?: string[];
|
|
60
|
+
disabled?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TimePickerProps {
|
|
64
|
+
/** Renders a hidden input carrying the serialized string (native-form use). */
|
|
65
|
+
name?: string;
|
|
66
|
+
/** Controlled value — string / `{time,timezone}` / null. */
|
|
67
|
+
value?: TimeValue;
|
|
68
|
+
/** Display clock: '24h' (default) | '12h'. Unknown values warn + fall back. */
|
|
69
|
+
format?: TimeFormat;
|
|
70
|
+
/** Minute stepping increment, ≥1 (default 1). Hours always step by 1. */
|
|
71
|
+
step?: number;
|
|
72
|
+
/** Bounds as time strings ("09:00", "5:30 pm") — clamped on every commit. */
|
|
73
|
+
min?: string | null;
|
|
74
|
+
max?: string | null;
|
|
75
|
+
/** Trigger text when empty. Defaults to 'HH:MM' / 'h:mm AM/PM'. */
|
|
76
|
+
placeholder?: string | null;
|
|
77
|
+
disabled?: boolean;
|
|
78
|
+
/** Displays but never opens/edits (the trigger stays focusable). */
|
|
79
|
+
readOnly?: boolean;
|
|
80
|
+
/** Suppresses the clear ✕. */
|
|
81
|
+
required?: boolean;
|
|
82
|
+
/** Paints the trigger with the error border. */
|
|
83
|
+
invalid?: boolean;
|
|
84
|
+
className?: string;
|
|
85
|
+
/** Render the panel in place — no trigger, no popover. */
|
|
86
|
+
inline?: boolean;
|
|
87
|
+
/** false → the embeddable footerless panel (DateTimePicker's time column). */
|
|
88
|
+
showFooter?: boolean;
|
|
89
|
+
/** true enables the zone picker; an array enables it AND is the zone list. */
|
|
90
|
+
timezone?: boolean | string[];
|
|
91
|
+
/** Zone list when `timezone` is `true`. Default: Intl's full list. */
|
|
92
|
+
timezones?: string[] | null;
|
|
93
|
+
/** Default 'iso'. Unknown values warn + fall back. */
|
|
94
|
+
outputFormat?: TimeOutputFormat;
|
|
95
|
+
/** Zone-picker slot. Absent → the built-in fallback <select>. */
|
|
96
|
+
renderTimezoneSelect?: (props: TimezoneSelectSlotProps) => ReactNode;
|
|
97
|
+
/** Fires on COMMIT only, and only when the serialized value changed. */
|
|
98
|
+
onChange?: (event: TimeChangeEvent) => void;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type StepField = 'hour' | 'minute';
|
|
102
|
+
|
|
103
|
+
interface TimeState { time: ParsedTime | null; timezone: string | null }
|
|
104
|
+
|
|
105
|
+
/** A zone that is already a fixed offset (what an 'iso' value parses back to). */
|
|
106
|
+
const OFFSET_ZONE = /^[+-]\d{2}:\d{2}$/;
|
|
107
|
+
|
|
108
|
+
/** Used only when Intl.supportedValuesOf is unavailable (source's curated set). */
|
|
109
|
+
const FALLBACK_ZONES = [
|
|
110
|
+
'UTC', 'GMT',
|
|
111
|
+
'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
|
|
112
|
+
'America/Anchorage', 'America/Honolulu', 'America/Phoenix',
|
|
113
|
+
'America/Toronto', 'America/Vancouver', 'America/Mexico_City',
|
|
114
|
+
'America/Sao_Paulo', 'America/Buenos_Aires', 'America/Bogota',
|
|
115
|
+
'Europe/London', 'Europe/Dublin', 'Europe/Paris', 'Europe/Berlin',
|
|
116
|
+
'Europe/Madrid', 'Europe/Rome', 'Europe/Amsterdam', 'Europe/Stockholm',
|
|
117
|
+
'Europe/Athens', 'Europe/Moscow', 'Europe/Istanbul',
|
|
118
|
+
'Africa/Cairo', 'Africa/Johannesburg', 'Africa/Lagos', 'Africa/Nairobi',
|
|
119
|
+
'Asia/Dubai', 'Asia/Tehran', 'Asia/Karachi', 'Asia/Kolkata',
|
|
120
|
+
'Asia/Bangkok', 'Asia/Singapore', 'Asia/Hong_Kong', 'Asia/Shanghai',
|
|
121
|
+
'Asia/Tokyo', 'Asia/Seoul', 'Asia/Jakarta', 'Asia/Manila',
|
|
122
|
+
'Australia/Perth', 'Australia/Sydney', 'Australia/Melbourne',
|
|
123
|
+
'Pacific/Auckland', 'Pacific/Fiji', 'Pacific/Honolulu',
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
// ── Pure helpers ───────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
function pad2(n: number): string { return n < 10 ? '0' + n : String(n); }
|
|
129
|
+
|
|
130
|
+
function detectLocalZone(): string {
|
|
131
|
+
try { return new Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
|
|
132
|
+
catch { return 'UTC'; }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Unknown enum-ish prop values fall back WITH a warn — never render nothing. */
|
|
136
|
+
function normFormat(f: TimeFormat | undefined): TimeFormat {
|
|
137
|
+
if (f == null || f === '24h' || f === '12h') return f ?? '24h';
|
|
138
|
+
console.warn(`TimePicker: unknown format "${String(f)}" — falling back to "24h"`);
|
|
139
|
+
return '24h';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normOutputFormat(f: TimeOutputFormat | undefined): TimeOutputFormat {
|
|
143
|
+
if (f == null || f === 'iso' || f === 'iana' || f === 'object') return f ?? 'iso';
|
|
144
|
+
console.warn(`TimePicker: unknown outputFormat "${String(f)}" — falling back to "iso"`);
|
|
145
|
+
return 'iso';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Parse any accepted input shape into `{time, timezone}` (source `_parseInitial`).
|
|
150
|
+
* Handles: `{time,timezone}` objects, ISO `HH:MM±HH:MM` / `HH:MMZ` / `HH:MM±HHMM`,
|
|
151
|
+
* `HH:MM Zone` (IANA tail), 12h `h:mm AM/PM`, and bare `HH:MM`.
|
|
152
|
+
*/
|
|
153
|
+
function parseInitial(raw: TimeValue | undefined): TimeState {
|
|
154
|
+
if (raw == null || raw === '') return { time: null, timezone: null };
|
|
155
|
+
if (typeof raw === 'object') {
|
|
156
|
+
return {
|
|
157
|
+
time: raw.time ? parseTime(raw.time) : null,
|
|
158
|
+
timezone: raw.timezone || null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const str = String(raw).trim();
|
|
162
|
+
const isoOffset = str.match(/^(\d{1,2}:\d{2})(Z|[+-]\d{2}:?\d{2})$/);
|
|
163
|
+
if (isoOffset) {
|
|
164
|
+
const off = isoOffset[2]!;
|
|
165
|
+
let timezone: string;
|
|
166
|
+
if (off === 'Z') timezone = '+00:00';
|
|
167
|
+
else if (/^[+-]\d{4}$/.test(off)) timezone = `${off.slice(0, 3)}:${off.slice(3)}`;
|
|
168
|
+
else timezone = off;
|
|
169
|
+
return { time: parseTime(isoOffset[1]!), timezone };
|
|
170
|
+
}
|
|
171
|
+
const sp = str.indexOf(' ');
|
|
172
|
+
if (sp > -1) {
|
|
173
|
+
const tail = str.slice(sp + 1).trim();
|
|
174
|
+
// An AM/PM tail belongs to the time — parseTime owns the whole string.
|
|
175
|
+
if (/^(am|pm)$/i.test(tail)) return { time: parseTime(str), timezone: null };
|
|
176
|
+
return { time: parseTime(str.slice(0, sp)), timezone: tail || null };
|
|
177
|
+
}
|
|
178
|
+
return { time: parseTime(str), timezone: null };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Canonical serialization — always a STRING (the hidden input + the diff key). */
|
|
182
|
+
function serialize(s: TimeState, tzEnabled: boolean, outputFormat: TimeOutputFormat): string {
|
|
183
|
+
if (!s.time) return '';
|
|
184
|
+
const t = formatTime(s.time, '24h');
|
|
185
|
+
if (!tzEnabled || !s.timezone) return t;
|
|
186
|
+
if (outputFormat === 'iana') return `${t} ${s.timezone}`;
|
|
187
|
+
// Deviation from source: a zone that is ALREADY an offset (what an 'iso'
|
|
188
|
+
// value parses back to) passes straight through. `ianaOffset('-07:00')`
|
|
189
|
+
// returns null, so the source silently dropped the offset on re-save.
|
|
190
|
+
if (OFFSET_ZONE.test(s.timezone)) return `${t}${s.timezone}`;
|
|
191
|
+
const offset = ianaOffset(s.timezone, new Date());
|
|
192
|
+
return offset ? `${t}${offset}` : t;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function displayText(s: TimeState, tzEnabled: boolean, format: TimeFormat): string {
|
|
196
|
+
if (!s.time) return '';
|
|
197
|
+
const t = formatTime(s.time, format);
|
|
198
|
+
return tzEnabled && s.timezone ? `${t} ${s.timezone}` : t;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The `outputFormat` shape handed to `onChange` (source `getValue`). */
|
|
202
|
+
function readOut(s: TimeState, tzEnabled: boolean, outputFormat: TimeOutputFormat): TimeValue {
|
|
203
|
+
if (!s.time) return outputFormat === 'object' ? null : '';
|
|
204
|
+
if (tzEnabled && outputFormat === 'object') {
|
|
205
|
+
return { time: formatTime(s.time, '24h'), timezone: s.timezone || null };
|
|
206
|
+
}
|
|
207
|
+
return serialize(s, tzEnabled, outputFormat);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Identity key for a `value` prop — how an echo of our own emit is spotted. */
|
|
211
|
+
function valueKey(v: TimeValue | undefined): string {
|
|
212
|
+
if (v == null || v === '') return '';
|
|
213
|
+
if (typeof v === 'object') return `${v.time ?? ''}|${v.timezone ?? ''}`;
|
|
214
|
+
return v;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function hourDisplay(hours: number, format: TimeFormat): string {
|
|
218
|
+
if (format !== '12h') return pad2(hours);
|
|
219
|
+
const h12 = hours % 12;
|
|
220
|
+
return String(h12 === 0 ? 12 : h12);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let warnedNoIntlZones = false;
|
|
224
|
+
|
|
225
|
+
function buildZoneList(list: string[] | undefined): string[] {
|
|
226
|
+
if (Array.isArray(list) && list.length) return list.slice();
|
|
227
|
+
try {
|
|
228
|
+
if (typeof Intl !== 'undefined' && typeof Intl.supportedValuesOf === 'function') {
|
|
229
|
+
const zones = Intl.supportedValuesOf('timeZone');
|
|
230
|
+
if (Array.isArray(zones) && zones.length) return zones.slice();
|
|
231
|
+
}
|
|
232
|
+
} catch { /* fall through to the curated list */ }
|
|
233
|
+
if (!warnedNoIntlZones) {
|
|
234
|
+
warnedNoIntlZones = true;
|
|
235
|
+
console.warn('TimePicker: Intl.supportedValuesOf("timeZone") unavailable — falling back to the curated zone list.');
|
|
236
|
+
}
|
|
237
|
+
return FALLBACK_ZONES.slice();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Fallback zone picker (used until a TimezoneSelect is injected) ──
|
|
241
|
+
|
|
242
|
+
function FallbackTimezoneSelect({ value, onChange, timezones, disabled }: TimezoneSelectSlotProps) {
|
|
243
|
+
// `timezones` is normally undefined (referentially stable); when supplied
|
|
244
|
+
// the rebuild is a slice, so recomputing on identity change is free.
|
|
245
|
+
const zones = useMemo(() => buildZoneList(timezones), [timezones]);
|
|
246
|
+
const known = !value || zones.includes(value);
|
|
247
|
+
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
if (!known) {
|
|
250
|
+
console.warn(`TimePicker: timezone "${String(value)}" is not in the zone list — rendering it as a custom option.`);
|
|
251
|
+
}
|
|
252
|
+
}, [known, value]);
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<select
|
|
256
|
+
className="input mojo-time-tz-select"
|
|
257
|
+
aria-label="Timezone"
|
|
258
|
+
value={value ?? ''}
|
|
259
|
+
disabled={disabled}
|
|
260
|
+
onChange={(e) => onChange(e.currentTarget.value || null)}
|
|
261
|
+
>
|
|
262
|
+
<option value="">(no timezone)</option>
|
|
263
|
+
{/* A value the list doesn't carry still shows — never render nothing. */}
|
|
264
|
+
{!known && value ? <option value={value}>{value}</option> : null}
|
|
265
|
+
{zones.map((z) => <option key={z} value={z}>{z}</option>)}
|
|
266
|
+
</select>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Component ──────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
export function TimePicker(props: TimePickerProps) {
|
|
273
|
+
const format = normFormat(props.format);
|
|
274
|
+
const outputFormat = normOutputFormat(props.outputFormat);
|
|
275
|
+
const {
|
|
276
|
+
name, value = '', min = null, max = null,
|
|
277
|
+
disabled = false, readOnly = false, required = false, invalid = false,
|
|
278
|
+
className = '', inline = false,
|
|
279
|
+
renderTimezoneSelect, onChange,
|
|
280
|
+
} = props;
|
|
281
|
+
const showFooter = props.showFooter !== false;
|
|
282
|
+
const stepRaw = Number.parseInt(String(props.step ?? 1), 10);
|
|
283
|
+
const step = Math.max(1, Number.isFinite(stepRaw) ? stepRaw : 1);
|
|
284
|
+
const tzEnabled = props.timezone === true || Array.isArray(props.timezone);
|
|
285
|
+
const tzList = Array.isArray(props.timezone) ? props.timezone : (props.timezones ?? undefined);
|
|
286
|
+
const placeholder = props.placeholder ?? (format === '12h' ? 'h:mm AM/PM' : 'HH:MM');
|
|
287
|
+
const locked = disabled || readOnly;
|
|
288
|
+
|
|
289
|
+
const [state, setState] = useState<TimeState>(() => {
|
|
290
|
+
const init = parseInitial(value);
|
|
291
|
+
return { time: init.time, timezone: init.timezone ?? (tzEnabled ? detectLocalZone() : null) };
|
|
292
|
+
});
|
|
293
|
+
/** Typed-but-uncommitted column text. Never readable as the value. */
|
|
294
|
+
const [draft, setDraft] = useState<{ field: StepField; text: string } | null>(null);
|
|
295
|
+
const [open, setOpen] = useState(false);
|
|
296
|
+
|
|
297
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
298
|
+
const hourRef = useRef<HTMLInputElement>(null);
|
|
299
|
+
const minuteRef = useRef<HTMLInputElement>(null);
|
|
300
|
+
|
|
301
|
+
// ── Controlled sync (source `setValue` semantics) ─────────────
|
|
302
|
+
// Our own commits echo back as a `value` prop change; re-parsing that echo
|
|
303
|
+
// is exactly what would degrade an IANA zone to its offset, so skip it.
|
|
304
|
+
const prevKey = useRef(valueKey(value));
|
|
305
|
+
const echoKey = useRef<string | null>(null);
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
const key = valueKey(value);
|
|
308
|
+
if (key === prevKey.current) return;
|
|
309
|
+
prevKey.current = key;
|
|
310
|
+
const echo = echoKey.current !== null && echoKey.current === key;
|
|
311
|
+
echoKey.current = null;
|
|
312
|
+
if (echo) return;
|
|
313
|
+
const init = parseInitial(value);
|
|
314
|
+
// Source: a value without a zone LEAVES the current zone in place.
|
|
315
|
+
setState((s) => ({ time: init.time, timezone: init.timezone ?? s.timezone }));
|
|
316
|
+
setDraft(null);
|
|
317
|
+
}, [value]);
|
|
318
|
+
|
|
319
|
+
// Turning the zone picker on seeds the local zone (source constructor).
|
|
320
|
+
// Like the source this does NOT emit — the owner learns on the next commit.
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
if (!tzEnabled) return;
|
|
323
|
+
setState((s) => (s.timezone ? s : { ...s, timezone: detectLocalZone() }));
|
|
324
|
+
}, [tzEnabled]);
|
|
325
|
+
|
|
326
|
+
useEffect(() => { if (locked) setOpen(false); }, [locked]);
|
|
327
|
+
// Closing discards a typed draft — React unmounts the panel before the
|
|
328
|
+
// input's blur can fire, so the draft would otherwise survive to next open.
|
|
329
|
+
useEffect(() => { if (!open) setDraft(null); }, [open]);
|
|
330
|
+
|
|
331
|
+
// Arrow-key stepping re-selects the column text AFTER React writes the new
|
|
332
|
+
// value into the DOM (setting .value collapses the selection).
|
|
333
|
+
const [selectNonce, setSelectNonce] = useState(0);
|
|
334
|
+
const reselectField = useRef<StepField | null>(null);
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
if (selectNonce === 0) return;
|
|
337
|
+
const el = reselectField.current === 'hour' ? hourRef.current : minuteRef.current;
|
|
338
|
+
el?.focus();
|
|
339
|
+
el?.select();
|
|
340
|
+
}, [selectNonce]);
|
|
341
|
+
const reselect = (field: StepField) => {
|
|
342
|
+
reselectField.current = field;
|
|
343
|
+
setSelectNonce((n) => n + 1);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
// ── Bounds ────────────────────────────────────────────────────
|
|
347
|
+
const minT = useMemo(() => (min ? parseTime(min) : null), [min]);
|
|
348
|
+
const maxT = useMemo(() => (max ? parseTime(max) : null), [max]);
|
|
349
|
+
const clamp = (t: ParsedTime): ParsedTime => {
|
|
350
|
+
if (minT && compareTime(t, minT) < 0) return { ...minT };
|
|
351
|
+
if (maxT && compareTime(t, maxT) > 0) return { ...maxT };
|
|
352
|
+
return t;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// ── Commit ────────────────────────────────────────────────────
|
|
356
|
+
// The ONE value-mutation boundary — every interaction funnels here, so the
|
|
357
|
+
// disabled/readOnly guard lives here too (an ArrowUp inside a readOnly
|
|
358
|
+
// column would otherwise commit: the source's panel had no such guard
|
|
359
|
+
// because it could never be opened, but `inline` can be locked).
|
|
360
|
+
const commit = (next: TimeState) => {
|
|
361
|
+
if (locked) return;
|
|
362
|
+
const oldStored = serialize(state, tzEnabled, outputFormat);
|
|
363
|
+
const newStored = serialize(next, tzEnabled, outputFormat);
|
|
364
|
+
setState(next);
|
|
365
|
+
if (oldStored === newStored) return; // no-op edits stay silent
|
|
366
|
+
const emitted = readOut(next, tzEnabled, outputFormat);
|
|
367
|
+
echoKey.current = valueKey(emitted);
|
|
368
|
+
onChange?.({
|
|
369
|
+
value: emitted,
|
|
370
|
+
formatted: displayText(next, tzEnabled, format),
|
|
371
|
+
oldValue: oldStored,
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
const commitTime = (time: ParsedTime | null) => commit({ time, timezone: state.timezone });
|
|
375
|
+
|
|
376
|
+
// ── Panel interaction ─────────────────────────────────────────
|
|
377
|
+
const stepValue = (field: StepField, dir: 1 | -1) => {
|
|
378
|
+
const cur = state.time ?? { hours: 0, minutes: 0 };
|
|
379
|
+
let { hours, minutes } = cur;
|
|
380
|
+
if (field === 'hour') {
|
|
381
|
+
hours = ((hours + dir) % 24 + 24) % 24;
|
|
382
|
+
} else {
|
|
383
|
+
// Minute stepping honors `step` via total-minutes math, wrapping
|
|
384
|
+
// across midnight in BOTH directions.
|
|
385
|
+
const total = hours * 60 + minutes;
|
|
386
|
+
const next = ((total + dir * step) % 1440 + 1440) % 1440;
|
|
387
|
+
hours = Math.floor(next / 60);
|
|
388
|
+
minutes = next % 60;
|
|
389
|
+
}
|
|
390
|
+
setDraft(null);
|
|
391
|
+
commitTime(clamp({ hours, minutes }));
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const setAmPm = (which: 'am' | 'pm') => {
|
|
395
|
+
setDraft(null);
|
|
396
|
+
if (!state.time) {
|
|
397
|
+
commitTime(clamp({ hours: which === 'pm' ? 12 : 0, minutes: 0 }));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const { hours, minutes } = state.time;
|
|
401
|
+
let h = hours;
|
|
402
|
+
if (which === 'am' && h >= 12) h -= 12;
|
|
403
|
+
else if (which === 'pm' && h < 12) h += 12;
|
|
404
|
+
commitTime(clamp({ hours: h, minutes }));
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
const commitDraft = (field: StepField, text: string) => {
|
|
408
|
+
setDraft(null);
|
|
409
|
+
const raw = text.replace(/\D/g, '');
|
|
410
|
+
if (raw === '') return; // empty reverts to the display
|
|
411
|
+
let n = Number.parseInt(raw, 10);
|
|
412
|
+
const cur = state.time ?? { hours: 0, minutes: 0 };
|
|
413
|
+
let { hours, minutes } = cur;
|
|
414
|
+
if (field === 'hour') {
|
|
415
|
+
if (format === '12h') {
|
|
416
|
+
// 1–12 mapped through the CURRENT AM/PM state (12 AM = 0, 12 PM = 12).
|
|
417
|
+
if (n < 1) n = 1;
|
|
418
|
+
if (n > 12) n = 12;
|
|
419
|
+
const isPm = cur.hours >= 12;
|
|
420
|
+
if (n === 12) hours = isPm ? 12 : 0;
|
|
421
|
+
else hours = isPm ? n + 12 : n;
|
|
422
|
+
} else {
|
|
423
|
+
if (n < 0) n = 0;
|
|
424
|
+
if (n > 23) n = 23;
|
|
425
|
+
hours = n;
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
if (n < 0) n = 0;
|
|
429
|
+
if (n > 59) n = 59;
|
|
430
|
+
minutes = n;
|
|
431
|
+
}
|
|
432
|
+
commitTime(clamp({ hours, minutes }));
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const onValueKey = (field: StepField, event: ReactKeyboardEvent<HTMLInputElement>) => {
|
|
436
|
+
if (event.key === 'ArrowUp') {
|
|
437
|
+
event.preventDefault();
|
|
438
|
+
stepValue(field, 1);
|
|
439
|
+
reselect(field);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (event.key === 'ArrowDown') {
|
|
443
|
+
event.preventDefault();
|
|
444
|
+
stepValue(field, -1);
|
|
445
|
+
reselect(field);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (event.key === 'Enter') {
|
|
449
|
+
event.preventDefault();
|
|
450
|
+
event.currentTarget.blur(); // blur is the commit path
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
if (event.key === 'Tab') return; // let blur fire naturally
|
|
454
|
+
// Fix over source: shortcuts (⌘A / ⌘V / ^C …) are not "input".
|
|
455
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
456
|
+
if (!/^[0-9]$/.test(event.key)
|
|
457
|
+
&& !['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
|
|
458
|
+
event.preventDefault(); // digits only
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const commitNow = () => {
|
|
463
|
+
const now = new Date();
|
|
464
|
+
setDraft(null);
|
|
465
|
+
// Fix over source: Now is clamped like every other commit path.
|
|
466
|
+
commitTime(clamp({ hours: now.getHours(), minutes: now.getMinutes() }));
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const applyAndClose = () => {
|
|
470
|
+
setDraft(null);
|
|
471
|
+
if (!state.time) commitTime(clamp({ hours: 0, minutes: 0 }));
|
|
472
|
+
setOpen(false);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
const clearValue = () => {
|
|
476
|
+
if (!state.time) return; // nothing to clear
|
|
477
|
+
setDraft(null);
|
|
478
|
+
commitTime(null);
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
const setTimezone = (tz: string | null) => {
|
|
482
|
+
const next = tz || null;
|
|
483
|
+
if (next === state.timezone) return;
|
|
484
|
+
// Deviation from source, deliberate: web-mojo updated its hidden input
|
|
485
|
+
// but never emitted on a zone change (`_syncOutputs()` with no
|
|
486
|
+
// oldStored). onChange is the ONLY channel here — staying silent would
|
|
487
|
+
// strand the owner's value.
|
|
488
|
+
commit({ time: state.time, timezone: next });
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// ── Render ────────────────────────────────────────────────────
|
|
492
|
+
const shown = state.time ?? { hours: 0, minutes: 0 };
|
|
493
|
+
const hourText = draft?.field === 'hour' ? draft.text : hourDisplay(shown.hours, format);
|
|
494
|
+
const minuteText = draft?.field === 'minute' ? draft.text : pad2(shown.minutes);
|
|
495
|
+
const stored = serialize(state, tzEnabled, outputFormat);
|
|
496
|
+
const text = displayText(state, tzEnabled, format);
|
|
497
|
+
const clearable = !required && !disabled && !readOnly;
|
|
498
|
+
|
|
499
|
+
const renderStepper = (field: StepField, valueText: string, label: string, ref: RefObject<HTMLInputElement | null>) => (
|
|
500
|
+
<div className={`mojo-time-stepper mojo-time-stepper-${field}`}>
|
|
501
|
+
<button
|
|
502
|
+
type="button" className="mojo-time-stepper-btn" disabled={locked}
|
|
503
|
+
aria-label={`Increase ${label.toLowerCase()}`}
|
|
504
|
+
onClick={() => stepValue(field, 1)}
|
|
505
|
+
>
|
|
506
|
+
<i className="bi bi-chevron-up" aria-hidden="true" />
|
|
507
|
+
</button>
|
|
508
|
+
<input
|
|
509
|
+
ref={ref}
|
|
510
|
+
type="text"
|
|
511
|
+
className="mojo-time-stepper-value"
|
|
512
|
+
inputMode="numeric"
|
|
513
|
+
maxLength={2}
|
|
514
|
+
aria-label={label}
|
|
515
|
+
value={valueText}
|
|
516
|
+
disabled={disabled}
|
|
517
|
+
readOnly={readOnly}
|
|
518
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
519
|
+
onChange={(e) => setDraft({ field, text: e.currentTarget.value.replace(/\D/g, '').slice(0, 2) })}
|
|
520
|
+
onKeyDown={(e) => onValueKey(field, e)}
|
|
521
|
+
onBlur={(e) => commitDraft(field, e.currentTarget.value)}
|
|
522
|
+
/>
|
|
523
|
+
<button
|
|
524
|
+
type="button" className="mojo-time-stepper-btn" disabled={locked}
|
|
525
|
+
aria-label={`Decrease ${label.toLowerCase()}`}
|
|
526
|
+
onClick={() => stepValue(field, -1)}
|
|
527
|
+
>
|
|
528
|
+
<i className="bi bi-chevron-down" aria-hidden="true" />
|
|
529
|
+
</button>
|
|
530
|
+
<div className="mojo-time-stepper-label">{label}</div>
|
|
531
|
+
</div>
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
const isPm = shown.hours >= 12;
|
|
535
|
+
const zoneOffset = tzEnabled && state.timezone
|
|
536
|
+
? (OFFSET_ZONE.test(state.timezone) ? state.timezone : ianaOffset(state.timezone, new Date()))
|
|
537
|
+
: null;
|
|
538
|
+
|
|
539
|
+
const panel = (
|
|
540
|
+
<div className="mojo-time-popover-inner">
|
|
541
|
+
<div className="mojo-time-stepper-row">
|
|
542
|
+
{renderStepper('hour', hourText, 'Hour', hourRef)}
|
|
543
|
+
<div className="mojo-time-stepper-sep">:</div>
|
|
544
|
+
{renderStepper('minute', minuteText, 'Minute', minuteRef)}
|
|
545
|
+
{format === '12h' && (
|
|
546
|
+
<div className="mojo-time-ampm">
|
|
547
|
+
<button
|
|
548
|
+
type="button" disabled={locked} aria-pressed={!isPm}
|
|
549
|
+
className={`mojo-time-ampm-btn${!isPm ? ' is-active' : ''}`}
|
|
550
|
+
onClick={() => setAmPm('am')}
|
|
551
|
+
>AM</button>
|
|
552
|
+
<button
|
|
553
|
+
type="button" disabled={locked} aria-pressed={isPm}
|
|
554
|
+
className={`mojo-time-ampm-btn${isPm ? ' is-active' : ''}`}
|
|
555
|
+
onClick={() => setAmPm('pm')}
|
|
556
|
+
>PM</button>
|
|
557
|
+
</div>
|
|
558
|
+
)}
|
|
559
|
+
</div>
|
|
560
|
+
|
|
561
|
+
{tzEnabled && (
|
|
562
|
+
<div className="mojo-time-tz-host">
|
|
563
|
+
{renderTimezoneSelect
|
|
564
|
+
? renderTimezoneSelect({ value: state.timezone, onChange: setTimezone, timezones: tzList, disabled: locked })
|
|
565
|
+
: <FallbackTimezoneSelect value={state.timezone} onChange={setTimezone} timezones={tzList} disabled={locked} />}
|
|
566
|
+
{zoneOffset ? <div className="mojo-time-tz-offset">UTC{zoneOffset}</div> : null}
|
|
567
|
+
</div>
|
|
568
|
+
)}
|
|
569
|
+
|
|
570
|
+
{showFooter && (
|
|
571
|
+
<div className="mojo-time-foot">
|
|
572
|
+
<button type="button" className="btn btn-compact mojo-time-now" disabled={locked} onClick={commitNow}>Now</button>
|
|
573
|
+
<button type="button" className="btn btn-primary btn-compact mojo-time-apply" disabled={locked} onClick={applyAndClose}>Set</button>
|
|
574
|
+
</div>
|
|
575
|
+
)}
|
|
576
|
+
</div>
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
if (inline) {
|
|
580
|
+
return (
|
|
581
|
+
<div className={`mojo-time-picker${className ? ` ${className}` : ''}`}>
|
|
582
|
+
{name ? <input type="hidden" name={name} value={stored} readOnly /> : null}
|
|
583
|
+
<div className={`mojo-time-picker-inline${invalid ? ' is-invalid' : ''}`}>{panel}</div>
|
|
584
|
+
</div>
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return (
|
|
589
|
+
<div className={`mojo-time-picker${className ? ` ${className}` : ''}`}>
|
|
590
|
+
<button
|
|
591
|
+
type="button"
|
|
592
|
+
ref={triggerRef}
|
|
593
|
+
className={`mojo-time-trigger${invalid ? ' is-invalid' : ''}${clearable ? ' has-clear' : ''}`}
|
|
594
|
+
disabled={disabled}
|
|
595
|
+
aria-haspopup="dialog"
|
|
596
|
+
aria-expanded={open}
|
|
597
|
+
onClick={() => { if (!locked) setOpen((o) => !o); }}
|
|
598
|
+
>
|
|
599
|
+
<i className="bi bi-clock" aria-hidden="true" />
|
|
600
|
+
<span className={`mojo-time-trigger-text${!state.time ? ' is-empty' : ''}`}>
|
|
601
|
+
{text || placeholder}
|
|
602
|
+
</span>
|
|
603
|
+
</button>
|
|
604
|
+
{/* Sibling, not nested: the source put a <button> inside the trigger
|
|
605
|
+
<button> (invalid HTML). The wrapper carries the hover state. */}
|
|
606
|
+
{clearable ? (
|
|
607
|
+
<button
|
|
608
|
+
type="button" className="mojo-time-trigger-clear" aria-label="Clear"
|
|
609
|
+
tabIndex={-1} onClick={clearValue}
|
|
610
|
+
>✕</button>
|
|
611
|
+
) : null}
|
|
612
|
+
{name ? <input type="hidden" name={name} value={stored} readOnly /> : null}
|
|
613
|
+
<Popover
|
|
614
|
+
anchorRef={triggerRef}
|
|
615
|
+
open={open}
|
|
616
|
+
onClose={() => setOpen(false)}
|
|
617
|
+
className={`mojo-time-popover${tzEnabled ? ' has-tz' : ''}`}
|
|
618
|
+
aria-label="Choose time"
|
|
619
|
+
>
|
|
620
|
+
{panel}
|
|
621
|
+
</Popover>
|
|
622
|
+
</div>
|
|
623
|
+
);
|
|
624
|
+
}
|