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