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.
Files changed (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. 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
+ }