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,91 @@
1
+ // ArmedButton — two-step confirm for destructive, NO-UNDO actions, ported
2
+ // from maestro workspaces/js/dom.js `armedButton()` (lines 238-264).
3
+ //
4
+ // The first click ARMS the button — danger styling plus a label that names
5
+ // the blast radius ("Click again to revoke") — and only the second click
6
+ // fires. It auto-disarms after `disarmMs` (source: 6s), so a stray click can
7
+ // never leave a live trigger sitting on screen; unmount disarms for free
8
+ // (React state dies with the node — the source's "re-render drops the node"
9
+ // fail-safe, same cost: one extra click, never an unintended destroy).
10
+ //
11
+ // This is the house idiom for IRREVERSIBLE actions. Reach for it instead of
12
+ // `modal.confirm` when the action needs no input and the confirm dialog
13
+ // would be pure friction (inline row actions, danger zones). Actions that
14
+ // collect input (a disable reason) keep their form modal; actions that can
15
+ // be UNDONE act immediately and offer `toast.undo` instead.
16
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
17
+
18
+ export interface ArmedButtonProps {
19
+ /** Resting label ("Revoke key"). */
20
+ label: ReactNode;
21
+ /** Armed label — MUST name the blast radius ("Click again to revoke"). */
22
+ armedLabel: ReactNode;
23
+ /** Fires on the second click only. Await-ed; re-clicks ignored while pending. */
24
+ onConfirm: () => void | Promise<void>;
25
+ title?: string;
26
+ /** Extra classes on top of `btn armed-btn` (e.g. 'btn-compact'). */
27
+ className?: string;
28
+ /** Auto-disarm window in ms. Source parity: 6000. */
29
+ disarmMs?: number;
30
+ disabled?: boolean;
31
+ /** Optional bootstrap-icon shown before the resting label. */
32
+ icon?: string;
33
+ }
34
+
35
+ export function ArmedButton({
36
+ label, armedLabel, onConfirm, title, className, disarmMs = 6000, disabled = false, icon,
37
+ }: ArmedButtonProps) {
38
+ const [armed, setArmed] = useState(false);
39
+ const [busy, setBusy] = useState(false);
40
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
41
+
42
+ const disarm = () => {
43
+ if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
44
+ setArmed(false);
45
+ };
46
+
47
+ // Unmount clears the timer; the armed state itself dies with the node.
48
+ useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
49
+
50
+ // Escape disarms — a keyboard user who armed by accident needs an exit
51
+ // that isn't "wait 6 seconds" or "click the thing you fear".
52
+ useEffect(() => {
53
+ if (!armed) return;
54
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') disarm(); };
55
+ document.addEventListener('keydown', onKey);
56
+ return () => document.removeEventListener('keydown', onKey);
57
+ }, [armed]);
58
+
59
+ const click = async (e: React.MouseEvent) => {
60
+ // Armed buttons live inside clickable rows — the arm/fire click must
61
+ // never double as a row open (source parity: e.stopPropagation()).
62
+ e.stopPropagation();
63
+ if (busy) return;
64
+ if (!armed) {
65
+ setArmed(true);
66
+ timerRef.current = setTimeout(() => { timerRef.current = null; setArmed(false); }, disarmMs);
67
+ return;
68
+ }
69
+ disarm();
70
+ try {
71
+ setBusy(true);
72
+ await onConfirm();
73
+ } finally {
74
+ setBusy(false);
75
+ }
76
+ };
77
+
78
+ return (
79
+ <button
80
+ type="button"
81
+ className={`btn armed-btn${armed ? ' is-armed' : ''}${className ? ` ${className}` : ''}`}
82
+ title={title ?? (typeof label === 'string' ? label : undefined)}
83
+ disabled={disabled || busy}
84
+ onClick={(e) => { void click(e); }}
85
+ >
86
+ {armed
87
+ ? <><i className="bi bi-exclamation-triangle-fill" /> {armedLabel}</>
88
+ : <>{icon && <i className={`bi ${icon}`} />} {label}</>}
89
+ </button>
90
+ );
91
+ }
@@ -0,0 +1,498 @@
1
+ // Calendar — the picker engine. Ported from web-mojo
2
+ // src/core/forms/inputs/calendar/Calendar.js (623 lines, read in full).
3
+ //
4
+ // Renders day-grid / month-grid / year-grid based on `precision` and the
5
+ // current view (zoom level). Supports single-value and range selection at
6
+ // every precision. Same engine, three precisions.
7
+ //
8
+ // Cross-page anchor persistence: once a range start is committed, paging
9
+ // with prev/next or keyboard does not clear it. Hover preview tints visible
10
+ // cells up to the cursor regardless of where the anchor sits.
11
+ //
12
+ // Drill-down zoom: clicking the header label moves up one level
13
+ // (day → month → year). Selecting a tile above the configured `precision`
14
+ // drills back down; a tile at precision commits.
15
+ //
16
+ // React shape: SELECTION IS CONTROLLED — `value` (single) / `startValue` +
17
+ // `endValue` (range) are props; commits arrive via callbacks and paint only
18
+ // once the owner writes them back. The component owns view / page / anchor /
19
+ // hover as internal state. External prop writes keep the source's
20
+ // setValue/setRange semantics: a new `value` re-pages to it; a new range
21
+ // clears any in-progress anchor. While an anchor is active the committed
22
+ // range is suppressed (source nulls it); Escape cancels the anchor and the
23
+ // committed props paint again — the one deviation from source, which left
24
+ // the calendar blank until the next commit (nothing consumed range:cancel).
25
+ import { useEffect, useMemo, useRef, useState } from 'react';
26
+ import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react';
27
+ import {
28
+ addMonths,
29
+ compareByPrecision, compareYm, compareYmd,
30
+ daysInMonth,
31
+ formatByPrecision, formatYm, formatYmd,
32
+ monthNames,
33
+ parseByPrecision, parseYmd,
34
+ today,
35
+ weekdayNames, weekdayWithFirstDay,
36
+ } from './fns';
37
+ import type { ParsedDate, ParsedYm, ParsedYmd, Precision } from './fns';
38
+
39
+ export type { ParsedDate, ParsedYm, ParsedYmd, Precision } from './fns';
40
+
41
+ export const PRECISION_RANK: Record<Precision, number> = { year: 0, month: 1, day: 2 };
42
+
43
+ export type CalendarMode = 'single' | 'range';
44
+
45
+ export interface CalendarSelectEvent { value: string; parsed: ParsedDate }
46
+ export interface CalendarRangeSelectEvent { start: string; end: string }
47
+ export interface CalendarRangeStartEvent { anchor: string }
48
+ export interface CalendarViewChangeEvent { view: Precision }
49
+ export interface CalendarNavigateEvent { delta: number }
50
+
51
+ export interface CalendarProps {
52
+ /** Grid the picker commits at: 'day' | 'month' | 'year'. Default 'day'. */
53
+ precision?: Precision;
54
+ /** 'single' | 'range'. Default 'single'. */
55
+ mode?: CalendarMode;
56
+ /** Day view only: 1 | 2 side-by-side month panes. Default 1. */
57
+ months?: 1 | 2;
58
+ /** Single mode — canonical string at precision (YYYY-MM-DD / YYYY-MM / YYYY). */
59
+ value?: string | null;
60
+ /** Range mode — canonical strings at precision. */
61
+ startValue?: string | null;
62
+ endValue?: string | null;
63
+ /** Bounds, compared at the PANE precision being painted (a year tile is
64
+ * disabled only when the whole year is out of bounds). */
65
+ min?: string | null;
66
+ max?: string | null;
67
+ /** Individually disabled days, "YYYY-MM-DD" — day grids only. */
68
+ disabledDates?: string[];
69
+ /** First weekday: 0=Sun, 1=Mon, … Default 1. */
70
+ firstDay?: number;
71
+ /** BCP-47 locale for month/weekday names. Default 'en-US'. */
72
+ locale?: string;
73
+ /** Initial page (first visible month / decade seed). Defaults to the
74
+ * selection, else today. */
75
+ year?: number | null;
76
+ month?: number | null;
77
+ className?: string;
78
+ /** Single mode: a value was committed at picker precision. */
79
+ onSelect?: (event: CalendarSelectEvent) => void;
80
+ /** Range mode: both ends committed (ordered). */
81
+ onRangeSelect?: (event: CalendarRangeSelectEvent) => void;
82
+ /** Range mode: first anchor placed (no commit yet). */
83
+ onRangeStart?: (event: CalendarRangeStartEvent) => void;
84
+ /** Range mode: in-progress anchor cancelled (Escape). */
85
+ onRangeCancel?: () => void;
86
+ /** Zoom level changed (drill-down or header zoom-out). */
87
+ onViewChange?: (event: CalendarViewChangeEvent) => void;
88
+ /** Page changed (prev/next/PageUp/PageDown). */
89
+ onNavigate?: (event: CalendarNavigateEvent) => void;
90
+ }
91
+
92
+ const CELL = 'mojo-calendar-cell';
93
+
94
+ function decadeStart(year: number): number { return Math.floor(year / 10) * 10; }
95
+
96
+ /** Unknown enum-ish prop values fall back WITH a warn — never render nothing. */
97
+ function normPrecision(p: Precision | undefined): Precision {
98
+ if (p == null || p === 'day' || p === 'month' || p === 'year') return p ?? 'day';
99
+ console.warn(`Calendar: unknown precision "${String(p)}" — falling back to "day"`);
100
+ return 'day';
101
+ }
102
+ function normMode(m: CalendarMode | undefined): CalendarMode {
103
+ if (m == null || m === 'single' || m === 'range') return m ?? 'single';
104
+ console.warn(`Calendar: unknown mode "${String(m)}" — falling back to "single"`);
105
+ return 'single';
106
+ }
107
+
108
+ export function Calendar(props: CalendarProps) {
109
+ const precision = normPrecision(props.precision);
110
+ const mode = normMode(props.mode);
111
+ const {
112
+ value = null, startValue = null, endValue = null,
113
+ min = null, max = null, disabledDates,
114
+ firstDay = 1, locale = 'en-US',
115
+ year = null, month = null, className = '',
116
+ onSelect, onRangeSelect, onRangeStart, onRangeCancel, onViewChange, onNavigate,
117
+ } = props;
118
+ const panes = Math.max(1, Math.min(2, (props.months ?? 1) | 0));
119
+
120
+ // ── Internal state: view / page / anchor / hover ──────────────
121
+ const [view, setView] = useState<Precision>(precision);
122
+ const [page, setPage] = useState<{ y: number; m: number }>(() => {
123
+ const t = today();
124
+ const seed = (mode === 'single'
125
+ ? parseByPrecision(value, precision)
126
+ : (parseByPrecision(startValue, precision) || parseByPrecision(endValue, precision))) || t;
127
+ return {
128
+ y: year != null ? Number(year) : seed.y,
129
+ m: month != null ? Number(month) : (seed.m ?? t.m),
130
+ };
131
+ });
132
+ const [anchor, setAnchor] = useState<ParsedDate | null>(null);
133
+ const [hover, setHover] = useState<ParsedDate | null>(null);
134
+
135
+ // Precision prop change = a different picker: reset zoom + interaction.
136
+ const prevPrecision = useRef(precision);
137
+ useEffect(() => {
138
+ if (prevPrecision.current === precision) return;
139
+ prevPrecision.current = precision;
140
+ setView(precision);
141
+ setAnchor(null);
142
+ setHover(null);
143
+ }, [precision]);
144
+
145
+ // ── Controlled-selection sync (setValue / setRange semantics) ─
146
+ // Our own commits echo back as prop changes — those must not re-page or
147
+ // cancel anything, so remember what we emitted and skip the echo.
148
+ const lastEmittedValue = useRef<string | null>(null);
149
+ const lastEmittedRange = useRef<{ start: string; end: string } | null>(null);
150
+ const prevValueProp = useRef<string | null>(value);
151
+ const prevStartProp = useRef<string | null>(startValue);
152
+ const prevEndProp = useRef<string | null>(endValue);
153
+
154
+ useEffect(() => {
155
+ if (value === prevValueProp.current) return;
156
+ prevValueProp.current = value;
157
+ const echo = value !== null && value === lastEmittedValue.current;
158
+ lastEmittedValue.current = null;
159
+ if (echo || mode !== 'single') return;
160
+ // setValue semantics: page over to the new value (when it parses).
161
+ const parsed = parseByPrecision(value, precision);
162
+ if (parsed) setPage((p) => ({ y: parsed.y, m: parsed.m ?? p.m }));
163
+ }, [value, mode, precision]);
164
+
165
+ useEffect(() => {
166
+ if (startValue === prevStartProp.current && endValue === prevEndProp.current) return;
167
+ prevStartProp.current = startValue;
168
+ prevEndProp.current = endValue;
169
+ const emitted = lastEmittedRange.current;
170
+ lastEmittedRange.current = null;
171
+ if (emitted && emitted.start === startValue && emitted.end === endValue) return;
172
+ // setRange semantics: a programmatic range write cancels any
173
+ // in-progress anchor (do NOT write range props from onRangeStart).
174
+ setAnchor(null);
175
+ setHover(null);
176
+ }, [startValue, endValue]);
177
+
178
+ // ── Derived selection / constraints ───────────────────────────
179
+ const selected = useMemo(
180
+ () => (mode === 'single' ? parseByPrecision(value, precision) : null),
181
+ [mode, value, precision],
182
+ );
183
+ const startP = useMemo(
184
+ () => (mode === 'range' ? parseByPrecision(startValue, precision) : null),
185
+ [mode, startValue, precision],
186
+ );
187
+ const endP = useMemo(
188
+ () => (mode === 'range' ? parseByPrecision(endValue, precision) : null),
189
+ [mode, endValue, precision],
190
+ );
191
+ const minP = useMemo(
192
+ () => parseByPrecision(min, precision) || (min ? parseYmd(min) : null),
193
+ [min, precision],
194
+ );
195
+ const maxP = useMemo(
196
+ () => parseByPrecision(max, precision) || (max ? parseYmd(max) : null),
197
+ [max, precision],
198
+ );
199
+ const disabledSet = useMemo(
200
+ () => new Set((disabledDates ?? []).map((s) => formatYmd(parseYmd(s))).filter(Boolean)),
201
+ [disabledDates],
202
+ );
203
+
204
+ const isDisabledAt = (cur: ParsedDate, level: Precision): boolean => {
205
+ if (minP && compareByPrecision(cur, minP, level) < 0) return true;
206
+ if (maxP && compareByPrecision(cur, maxP, level) > 0) return true;
207
+ if (level === 'day' && disabledSet.size) {
208
+ if (disabledSet.has(formatYmd(cur as ParsedYmd))) return true;
209
+ }
210
+ return false;
211
+ };
212
+
213
+ // ── Range visuals ─────────────────────────────────────────────
214
+ // While an anchor is active it (plus hover) defines the painted span and
215
+ // the committed range is suppressed — in-PREVIEW classes, distinct from
216
+ // the committed in-RANGE classes.
217
+ const rangeClasses = (cur: ParsedDate, level: Precision): string[] => {
218
+ const out: string[] = [];
219
+ let start = startP;
220
+ let end = endP;
221
+ if (anchor) {
222
+ const other = hover || anchor;
223
+ const cmp = compareByPrecision(anchor, other, level);
224
+ start = cmp <= 0 ? anchor : other;
225
+ end = cmp <= 0 ? other : anchor;
226
+ }
227
+ if (!start || !end) return out;
228
+
229
+ const inClass = anchor ? `${CELL}-in-preview` : `${CELL}-in-range`;
230
+ const cmpS = compareByPrecision(cur, start, level);
231
+ const cmpE = compareByPrecision(cur, end, level);
232
+ if (cmpS > 0 && cmpE < 0) out.push(inClass);
233
+ if (cmpS === 0) {
234
+ out.push(`${CELL}-anchor`, `${CELL}-anchor-start`);
235
+ if (compareByPrecision(start, end, level) === 0) out.push(`${CELL}-anchor-solo`);
236
+ else out.push(inClass);
237
+ }
238
+ if (cmpE === 0 && compareByPrecision(start, end, level) !== 0) {
239
+ out.push(`${CELL}-anchor`, `${CELL}-anchor-end`, inClass);
240
+ }
241
+ return out;
242
+ };
243
+
244
+ // ── Commit / interaction ──────────────────────────────────────
245
+ const commitValue = (val: ParsedDate) => {
246
+ if (mode === 'single') {
247
+ const str = formatByPrecision(val, precision);
248
+ lastEmittedValue.current = str;
249
+ onSelect?.({ value: str, parsed: val });
250
+ return;
251
+ }
252
+ if (!anchor) {
253
+ setAnchor(val);
254
+ setHover(null);
255
+ onRangeStart?.({ anchor: formatByPrecision(val, precision) });
256
+ return;
257
+ }
258
+ // Second click — commit range, swapping if needed.
259
+ const cmp = compareByPrecision(anchor, val, precision);
260
+ const start = cmp <= 0 ? anchor : val;
261
+ const end = cmp <= 0 ? val : anchor;
262
+ setAnchor(null);
263
+ setHover(null);
264
+ const startStr = formatByPrecision(start, precision);
265
+ const endStr = formatByPrecision(end, precision);
266
+ lastEmittedRange.current = { start: startStr, end: endStr };
267
+ onRangeSelect?.({ start: startStr, end: endStr });
268
+ };
269
+
270
+ const clickYear = (yr: number) => {
271
+ if (precision === 'year') { commitValue({ y: yr }); return; }
272
+ // Drill down into that year.
273
+ setPage((p) => ({ ...p, y: yr }));
274
+ setView('month');
275
+ onViewChange?.({ view: 'month' });
276
+ };
277
+
278
+ const clickMonth = (ym: ParsedYm) => {
279
+ if (precision === 'month') { commitValue(ym); return; }
280
+ // Drill down into that month.
281
+ setPage({ y: ym.y, m: ym.m });
282
+ setView('day');
283
+ onViewChange?.({ view: 'day' });
284
+ };
285
+
286
+ const clickDay = (ymd: ParsedYmd) => { commitValue(ymd); };
287
+
288
+ const navigate = (delta: number) => {
289
+ setPage((p) => {
290
+ if (view === 'day') {
291
+ const next = addMonths({ y: p.y, m: p.m }, delta);
292
+ return { y: next.y, m: next.m };
293
+ }
294
+ if (view === 'month') return { ...p, y: p.y + delta };
295
+ return { ...p, y: decadeStart(p.y) + delta * 10 };
296
+ });
297
+ onNavigate?.({ delta });
298
+ };
299
+
300
+ const zoomOut = (level: Precision) => {
301
+ if (level === 'day') { setView('month'); onViewChange?.({ view: 'month' }); return; }
302
+ if (level === 'month') { setView('year'); onViewChange?.({ view: 'year' }); }
303
+ // year — already at top
304
+ };
305
+
306
+ const hoverCell = (parsed: ParsedDate, disabled: boolean) => {
307
+ if (mode !== 'range' || !anchor || disabled) return;
308
+ // Same functional-set trick as source's same-hover check: returning
309
+ // the identical reference skips the re-render.
310
+ setHover((h) => (h && compareByPrecision(h, parsed, view) === 0 ? h : parsed));
311
+ };
312
+
313
+ const rootMouseLeave = () => {
314
+ if (mode !== 'range' || !anchor) return;
315
+ setHover((h) => (h ? null : h));
316
+ };
317
+
318
+ const rootKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
319
+ const key = event.key;
320
+ if (key === 'Escape') {
321
+ if (anchor) {
322
+ setAnchor(null);
323
+ setHover(null);
324
+ onRangeCancel?.();
325
+ event.preventDefault();
326
+ }
327
+ return;
328
+ }
329
+ if (key === 'PageUp') { navigate(-1); event.preventDefault(); return; }
330
+ if (key === 'PageDown') { navigate(1); event.preventDefault(); }
331
+ };
332
+
333
+ // ── Panes ─────────────────────────────────────────────────────
334
+ const renderHead = (label: ReactNode, level: Precision, showPrev: boolean, showNext: boolean) => (
335
+ <div className="mojo-calendar-head">
336
+ <button
337
+ type="button"
338
+ className="mojo-calendar-head-label"
339
+ data-level={level}
340
+ onClick={() => zoomOut(level)}
341
+ >
342
+ {label}
343
+ </button>
344
+ <div className="mojo-calendar-nav">
345
+ {showPrev && (
346
+ <button type="button" className="mojo-calendar-nav-btn" aria-label="Previous"
347
+ onClick={() => navigate(-1)}>{'‹'}</button>
348
+ )}
349
+ {showNext && (
350
+ <button type="button" className="mojo-calendar-nav-btn" aria-label="Next"
351
+ onClick={() => navigate(1)}>{'›'}</button>
352
+ )}
353
+ </div>
354
+ </div>
355
+ );
356
+
357
+ const renderDayPane = (y: number, m: number, showPrev: boolean, showNext: boolean) => {
358
+ const firstWeekday = weekdayWithFirstDay(y, m, 1, firstDay);
359
+ const total = daysInMonth(y, m);
360
+ const todayP = today();
361
+
362
+ const cells: ReactNode[] = [];
363
+ for (let i = 0; i < firstWeekday; i++) {
364
+ cells.push(<div key={`b${i}`} className={`${CELL} ${CELL}-blank`} />);
365
+ }
366
+ for (let d = 1; d <= total; d++) {
367
+ const cur: ParsedYmd = { y, m, d };
368
+ const disabled = isDisabledAt(cur, 'day');
369
+ const classes = [CELL];
370
+ if (cur.y === todayP.y && cur.m === todayP.m && cur.d === todayP.d) classes.push(`${CELL}-today`);
371
+ if (disabled) classes.push(`${CELL}-disabled`);
372
+ if (mode === 'range') classes.push(...rangeClasses(cur, 'day'));
373
+ else if (selected && compareYmd(cur, selected) === 0) classes.push(`${CELL}-selected`);
374
+ cells.push(
375
+ <button
376
+ key={d}
377
+ type="button"
378
+ className={classes.join(' ')}
379
+ disabled={disabled}
380
+ data-ymd={formatYmd(cur)}
381
+ onClick={() => clickDay(cur)}
382
+ onMouseEnter={() => hoverCell(cur, disabled)}
383
+ >
384
+ <span className="mojo-calendar-cell-inner">{d}</span>
385
+ </button>,
386
+ );
387
+ }
388
+
389
+ const names = monthNames(locale, 'long');
390
+ return (
391
+ <div key={`${y}-${m}`} className="mojo-calendar-pane">
392
+ {renderHead(
393
+ <>{names[m - 1]} <span className="mojo-calendar-year">{y}</span></>,
394
+ 'day', showPrev, showNext,
395
+ )}
396
+ <div className="mojo-calendar-grid mojo-calendar-grid-day">
397
+ {weekdayNames(locale, firstDay, 'short').map((w, i) => (
398
+ <div key={i} className="mojo-calendar-weekday">{w}</div>
399
+ ))}
400
+ </div>
401
+ <div className="mojo-calendar-grid mojo-calendar-grid-day">{cells}</div>
402
+ </div>
403
+ );
404
+ };
405
+
406
+ const renderMonthPane = (y: number) => {
407
+ const names = monthNames(locale, 'short');
408
+ const cells: ReactNode[] = [];
409
+ for (let m = 1; m <= 12; m++) {
410
+ const cur: ParsedYm = { y, m };
411
+ const disabled = isDisabledAt(cur, 'month');
412
+ const classes = [CELL];
413
+ if (disabled) classes.push(`${CELL}-disabled`);
414
+ if (precision === 'month' && mode === 'range') classes.push(...rangeClasses(cur, 'month'));
415
+ else if (precision === 'month' && mode === 'single' && selected && compareYm(cur, selected) === 0) {
416
+ classes.push(`${CELL}-selected`);
417
+ }
418
+ cells.push(
419
+ <button
420
+ key={m}
421
+ type="button"
422
+ className={classes.join(' ')}
423
+ disabled={disabled}
424
+ data-ym={formatYm(cur)}
425
+ onClick={() => clickMonth(cur)}
426
+ onMouseEnter={() => hoverCell(cur, disabled)}
427
+ >
428
+ <span className="mojo-calendar-cell-inner">{names[m - 1]}</span>
429
+ </button>,
430
+ );
431
+ }
432
+ return (
433
+ <div className="mojo-calendar-pane">
434
+ {renderHead(String(y), 'month', true, true)}
435
+ <div className="mojo-calendar-grid mojo-calendar-grid-month">{cells}</div>
436
+ </div>
437
+ );
438
+ };
439
+
440
+ const renderYearPane = (decade: number) => {
441
+ const cells: ReactNode[] = [];
442
+ for (let i = 0; i < 12; i++) {
443
+ const yr = decade + i;
444
+ const cur: ParsedDate = { y: yr };
445
+ const disabled = isDisabledAt(cur, 'year');
446
+ const classes = [CELL];
447
+ if (disabled) classes.push(`${CELL}-disabled`);
448
+ if (precision === 'year' && mode === 'range') classes.push(...rangeClasses(cur, 'year'));
449
+ else if (precision === 'year' && mode === 'single' && selected && selected.y === yr) {
450
+ classes.push(`${CELL}-selected`);
451
+ }
452
+ cells.push(
453
+ <button
454
+ key={yr}
455
+ type="button"
456
+ className={classes.join(' ')}
457
+ disabled={disabled}
458
+ data-year={String(yr)}
459
+ onClick={() => clickYear(yr)}
460
+ onMouseEnter={() => hoverCell(cur, disabled)}
461
+ >
462
+ <span className="mojo-calendar-cell-inner">{yr}</span>
463
+ </button>,
464
+ );
465
+ }
466
+ return (
467
+ <div className="mojo-calendar-pane">
468
+ {renderHead(`${decade} – ${decade + 11}`, 'year', true, true)}
469
+ <div className="mojo-calendar-grid mojo-calendar-grid-year">{cells}</div>
470
+ </div>
471
+ );
472
+ };
473
+
474
+ // ── Render ────────────────────────────────────────────────────
475
+ const dayPanes: ReactNode[] = [];
476
+ if (view === 'day') {
477
+ for (let i = 0; i < panes; i++) {
478
+ let y = page.y;
479
+ let m = page.m + i;
480
+ while (m > 12) { m -= 12; y += 1; }
481
+ dayPanes.push(renderDayPane(y, m, i === 0, i === panes - 1));
482
+ }
483
+ }
484
+
485
+ return (
486
+ <div
487
+ className={`mojo-calendar mojo-calendar-${precision}${className ? ` ${className}` : ''}`}
488
+ onMouseLeave={rootMouseLeave}
489
+ onKeyDown={rootKeyDown}
490
+ >
491
+ <div className={panes > 1 && view === 'day' ? 'mojo-calendar-multi' : 'mojo-calendar-single'}>
492
+ {view === 'day' && dayPanes}
493
+ {view === 'month' && renderMonthPane(page.y)}
494
+ {view === 'year' && renderYearPane(decadeStart(page.y))}
495
+ </div>
496
+ </div>
497
+ );
498
+ }