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,132 @@
1
+ # CollectionSelect — the single-record server picker
2
+
3
+ ```tsx
4
+ import { CollectionSelect, type CollectionSelectValue } from 'portal-mojo/ui';
5
+ ```
6
+
7
+ Search-as-you-type against any model or endpoint, pick ONE row, and hydrate
8
+ a bare stored id into its display label. The value is **controlled** in both
9
+ directions. Ported from web-mojo `CollectionSelect.js`; sibling of
10
+ `CollectionMultiSelect` in the server-data selects family (epic #1275) —
11
+ the two share the model-binding prop contract.
12
+
13
+ ```tsx
14
+ const [ownerId, setOwnerId] = useState<string | number | null>(42);
15
+
16
+ <CollectionSelect<User>
17
+ model={UserModel} // or endpoint="/api/user"
18
+ labelField="display_name"
19
+ value={ownerId} // bare id → renders "#42", then the label
20
+ onChange={(id, row) => setOwnerId(id)}
21
+ label="Owner"
22
+ required
23
+ />
24
+ ```
25
+
26
+ ## Props
27
+
28
+ | Prop | Behavior |
29
+ |---|---|
30
+ | `model` / `endpoint` | One is required. `model` is a `defineModel` def (supplies endpoint + row type); `endpoint` is the bare path. |
31
+ | `value` | Controlled: `string \| number \| null`, **or a row-like object** (a record with the relation expanded) — id and label extract via the `valueField`/`labelField` dot paths, no fetch. `0`/`'0'` (web-mojo's no-selection sentinel) and `''` normalize to "no selection" on the way IN and never leak OUT. |
32
+ | `onChange` | `(id \| null, row?)` — fires on **commit only**: picking a row → `(id, row)`; clearing (✕, or typing away from the committed label) → `(null)`. Re-picking the already-committed row does not re-fire. Never fires per keystroke; never emits `'0'` — `null` is the empty value. |
33
+ | `labelField` / `valueField` | Row fields for label and id, **dot notation** reaches nested fields (`'user.email'`). Defaults `'name'` / `'id'`. A missing field warns once and falls back (label → the id; missing id → row dropped / object value → no selection) — never renders nothing silently. |
34
+ | `maxItems` | Rows fetched per search — the `size` wire param. Default 10. |
35
+ | `placeholder` | Input placeholder. Default `'Search…'`. |
36
+ | `debounceMs` | Keystroke → `search` wire param delay. Default 400 (source parity — deliberately not ModelTable's 300). |
37
+ | `emptyFetch` | Default true: opening the dropdown (focus/click) fetches the initial unsearched page so options exist before typing. `false`: nothing is fetched until a term exists — the dropdown shows "Start typing to search…". |
38
+ | `defaultParams` | Extra wire params — a dict, or a **callback re-evaluated per render** (each fetch uses the freshest result; a changed result is a new query key → refetch). Merge order: `{size: maxItems} → defaultParams → group → search` (later wins). |
39
+ | `requiresActiveGroup` | Folds the active group id in as `group`. With NO active group the fetch is **held** (the dropdown says "Select an active group first") — never an unscoped list where a scoped one was demanded. Needs a `<GroupProvider>` above; missing one warns. |
40
+ | `label` / `required` / `help` / `error` | Field wrapper (house `.field-label` / `.field-help` / `.field-error`; `error` replaces `help` and paints the input `.input-invalid`). |
41
+ | `disabled` / `readOnly` | Both freeze the pipeline (no open, no typing, no ✕). `readOnly` still shows the committed label at input contrast. |
42
+
43
+ ## Selection semantics (the contract)
44
+
45
+ - **Picking commits**: the input fills with the row's label, the dropdown
46
+ closes, `onChange(id, row)` fires (only when the id actually changed).
47
+ - **Typing that diverges from the committed label clears the selection** —
48
+ `onChange(null)` fires ONCE and the typed text becomes the live search
49
+ (web-mojo re-emitted the clear on every keystroke; fixed here).
50
+ - **Clear ✕** shows while a selection exists: resets to empty, fires
51
+ `onChange(null)`, refocuses the input — which reopens the dropdown on
52
+ the base page (the source's emergent behavior, kept on purpose).
53
+ - **Escape** closes the dropdown and restores the committed label (the
54
+ in-progress search text is dropped). Inside a native-`<dialog>` modal the
55
+ first Escape closes only the dropdown, the second the modal (Popover's
56
+ capture-phase handling). **Outside click and Tab-away do the same
57
+ revert** — deliberate deviation from the source, which left typed text
58
+ visible in the input while its hidden value said "no selection" (the
59
+ display-diverges-from-state bug class ends here).
60
+ - **Keyboard**: ArrowDown/ArrowUp walk the options (clamped at the ends, no
61
+ wrap — source parity; ArrowDown also reopens a closed dropdown), Enter
62
+ commits the focused option (and never submits an enclosing form while the
63
+ dropdown is open). Focus never leaves the input — options are
64
+ `tabIndex={-1}` and mousedown-prevented, and the input is never
65
+ remounted, so it stays focus-stable across result updates.
66
+
67
+ ## id → label hydration
68
+
69
+ A `value` whose label is unknown renders `#<id>` immediately, then resolves
70
+ in this order:
71
+
72
+ 1. **Object value** — label extracted via the dot path, no fetch.
73
+ 2. **Label learned at pick time** (kept per instance — survives list-cache
74
+ garbage collection).
75
+ 3. **Any cached LIST row** under `[endpoint, …]` — ModelTable pages, other
76
+ pickers and this picker's own fetches all share those keys.
77
+ 4. **Fetch the one record** through the generic `[endpoint, 'one', id]` key
78
+ — the same cache `useOne`/DetailView read, so the fetch dedupes with
79
+ them. Numeric-string ids fetch under the **numeric** key (django-mojo
80
+ pks are ints; uuid/slug ids pass through).
81
+
82
+ A failed hydration keeps `#<id>` on screen and warns once per id (mojo 4xx
83
+ errors never retry, per `mojoQueryDefaults`).
84
+
85
+ ## Wire contract
86
+
87
+ GETs to the list endpoint through `useModelList` — cache key
88
+ `[endpoint, params]`, `placeholderData: keepPreviousData`. Params sent:
89
+ `size: maxItems`, merged with `defaultParams`, plus `group` (when
90
+ `requiresActiveGroup`) and `search` (trimmed; **dropped entirely when
91
+ empty** — an empty search is the plain first page, not `search=`).
92
+ Fetches run only while the dropdown is open; with `emptyFetch: false`,
93
+ only once a term exists. `model.invalidate(qc)` refreshes an open picker
94
+ too — there is no parallel fetch path.
95
+
96
+ ## Invariants
97
+
98
+ 1. Controlled value, one pipeline: the input displays either the committed
99
+ label (or `#<id>` while resolving) or the live draft — never a mix, and
100
+ never text the state doesn't hold. Commit fires on selection/clear only.
101
+ 2. Every id comparison goes through one `String()` normalization; `'0'`/`0`
102
+ normalize to null at the boundary and `null` is the only empty value on
103
+ the way out.
104
+ 3. Rows the server returns are shown as-is — every narrowing (kind, group,
105
+ search…) is a wire param, never a client-side filter.
106
+ 4. Dropdown states: spinner row while the current key's rows aren't on
107
+ screen; "No results found" (searched) vs "Start typing to search…"
108
+ (no term); fetch errors show the server's message + Retry; a held
109
+ group-scoped fetch says "Select an active group first".
110
+ 5. The dropdown lives in the shared `<Popover>` (top layer): it stacks
111
+ above native-`<dialog>` modals, escapes overflow containers, and
112
+ matches the field's width.
113
+
114
+ ## Pitfalls
115
+
116
+ - `maxItems` is the fetch size — there is no paging in the dropdown. If the
117
+ target row isn't in the first page, the user searches; search is
118
+ server-side, so it can find anything.
119
+ - The picker never invents rows: a `value` id that doesn't exist
120
+ server-side stays `#<id>` (with one console warning). Feed it real ids
121
+ or a row object.
122
+ - `labelField` must exist on the **list** graph rows for the dropdown to
123
+ read well; hydration can additionally use one-record-graph fields (the
124
+ detail graph is often wider), but options come from list rows.
125
+ - An inline `defaultParams` object/callback re-evaluates per render; that
126
+ is fine (TanStack hashes keys structurally). Only a changed RESULT
127
+ refetches.
128
+ - `requiresActiveGroup` holds the fetch until a group is active; pair the
129
+ control with `RequiresGroup` or group-scoped screens so users aren't
130
+ handed a picker that says to pick a group they can't pick there.
131
+ - Don't wire `onChange` to a per-keystroke autosave expectation — it fires
132
+ on commit (that is the point; see the B3 autosave contract).
@@ -0,0 +1,105 @@
1
+ # ComboBox — the house autocomplete
2
+
3
+ ```ts
4
+ import { ComboBox, type ComboOption, type ComboValue } from 'portal-mojo/ui';
5
+ ```
6
+
7
+ One editable select: a text input over a filtered suggestion list. Ported
8
+ from web-mojo's `ComboInput.js` (the feature spec — descriptions, meta,
9
+ highlight, ARIA, relevance ranking) plus `ComboBox.js`'s open-on-focus.
10
+ This is the base for TimezoneSelect (#1274) and rule-builder field pickers;
11
+ there is exactly ONE combobox in this toolkit.
12
+
13
+ ## API
14
+
15
+ ```ts
16
+ type ComboValue = string | number;
17
+
18
+ interface ComboOption {
19
+ value: ComboValue;
20
+ label: string; // what the input displays
21
+ description?: string; // muted second line in the list
22
+ meta?: Record<string, unknown>; // opaque; returned via onSelect
23
+ }
24
+
25
+ <ComboBox
26
+ value={v} // committed value (string | number | null)
27
+ options={opts} // ComboOption[] — bare strings normalize to {value:s,label:s}
28
+ onChange={(value, option?) => …} // COMMIT only, and only when the value changed
29
+ onSelect={(option) => …} // any commit that resolves to an option — carries meta
30
+ allowCustom // default true: unmatched text commits as-is
31
+ showDescription // default true
32
+ minChars={0} // typing auto-opens the list only at ≥ N chars
33
+ maxSuggestions={10} // caps the rendered list AND keyboard wraparound
34
+ placeholder disabled readOnly required id className
35
+ />
36
+ ```
37
+
38
+ ## The commit pipeline (the invariant)
39
+
40
+ The input's text is a private **draft**; the committed value lives in the
41
+ `value` prop. `onChange` fires ONLY on:
42
+
43
+ | Gesture | Result |
44
+ |---|---|
45
+ | Click option / Enter on highlighted | commit that option |
46
+ | Enter on typed text (`allowCustom`) | resolve → option, else commit text as custom |
47
+ | Blur / outside click / Tab / chevron-close | **settle**: exact label or `String(value)` match → that option; else custom when allowed (`''` commits a clear); else **revert** — the display falls back to the committed label |
48
+ | Escape while list is open | Popover dismisses → revert, keep focus — never commits |
49
+
50
+ Never per keystroke — web-mojo's ComboBox emitted per keystroke and
51
+ FormView's 300ms autosave saved partial text. Typed text equal to an
52
+ option's **label** commits the option's **value** (display shows labels;
53
+ labels must never leak into state). `onChange` is skipped when the resolved
54
+ value equals the current `value`; `onSelect` still fires on an explicit
55
+ option commit (rule builders may want the meta again).
56
+
57
+ ## Filtering
58
+
59
+ Case-insensitive substring across label + value + description; ranked exact
60
+ label match, then label starts-with, then input order (stable). `minChars`
61
+ gates only the auto-open while typing (below it the list closes); explicit
62
+ opens — focus, ArrowDown, chevron — always show the full (capped) list.
63
+ The chevron reopens with the filter cleared and the input text selected, so
64
+ the next keystroke starts a fresh query. Empty states (verbatim):
65
+ with `allowCustom` — "No matches found. Press Enter to use custom value." /
66
+ "Start typing to see suggestions..."; without — "No matching options found."
67
+
68
+ ## Keyboard + ARIA
69
+
70
+ ArrowDown opens, then moves next; ArrowUp previous; both wrap over the
71
+ *visible* (maxSuggestions-capped) list. Enter selects the highlighted
72
+ option, else commits custom text. Escape reverts through the shared Popover
73
+ (and reaches a parent `<dialog>` once the list is closed). Tab settles
74
+ and passes through. The highlighted option scrolls into view
75
+ (`block:'nearest'`). Full combobox pattern: `role=combobox`,
76
+ `aria-expanded`, `aria-autocomplete=list`, `aria-controls`,
77
+ `aria-activedescendant`, `role=listbox/option`, `aria-selected` + check
78
+ icon on the selected option. Match highlighting renders as split React
79
+ nodes around `<mark class="combo-mark">` — regex-escaped query, no HTML
80
+ strings.
81
+
82
+ ## Invariants / fixed web-mojo bugs (do not reintroduce)
83
+
84
+ - **Commit-only `onChange`** — the draft/value split is the fix; there is no
85
+ code path that emits while typing.
86
+ - **`allowCustom:false` reverts for real** — web-mojo overwrote its
87
+ committed value per keystroke, so its revert restored the typed text.
88
+ - **Numeric values survive** — selection passes option objects in closures;
89
+ web-mojo strict-equalled stringified `data-value` DOM attributes.
90
+ - **One dismissal owner** — shared Popover alone listens for outside
91
+ mousedown/Escape; ComboBox only chooses settle-vs-revert.
92
+ - Unknown `value` under `allowCustom:false` warns once and displays
93
+ verbatim — never renders nothing (rule 4).
94
+
95
+ ## Pitfalls
96
+
97
+ - Value matching is strict `===`: `5` and `'5'` are different values. Keep
98
+ option values and the `value` prop the same type (URL params are strings —
99
+ convert before binding).
100
+ - `description` renders only when provided (web-mojo silently defaulted it
101
+ to the label, duplicating every row — not carried).
102
+ - The dropdown is mounted in the shared top-layer Popover, so overflow
103
+ containers and native dialogs do not clip it.
104
+ - Styles live in `apps/portal/src/theme/combobox.css` (tokens only, both
105
+ themes); consuming apps must include it.
@@ -0,0 +1,226 @@
1
+ # DataView — the inferred record grid
2
+
3
+ ```tsx
4
+ import { DataView, JsonBlock, inferFieldType, type DataViewField } from 'portal-mojo/ui';
5
+
6
+ <DataView data={user} /> // schemaless: infer everything
7
+ <DataView data={user} fields={SCHEMA} /> // explicit schema
8
+ ```
9
+
10
+ Point it at any record and get a sensible detail grid. Field **names** and
11
+ **values** together pick the renderer, nested objects become nested grids,
12
+ and anything that isn't a scalar lands in a JSON block with copy and
13
+ collapse. Port of web-mojo `src/core/views/data/DataView.js` (1,153 lines).
14
+ Styles: `apps/portal/src/theme/dataview.css`. Demo: **Develop → Components →
15
+ DataView**.
16
+
17
+ ## DataView vs KnownFieldsCard
18
+
19
+ They look similar and solve opposite problems. Both often appear on the
20
+ same page.
21
+
22
+ | | Input | Which keys | Unknown keys |
23
+ |---|---|---|---|
24
+ | **`DataView`** | a whole **record** (`user`, `job`, `incident`) | **all of them**, inferred | inferred too — that is the point |
25
+ | **`KnownFieldsCard`** | one **blob** column (`metadata`, `ip_info`, `payload`) | only the **curated** `known` list you pass | left in the raw `<details>` below |
26
+
27
+ Rule of thumb: `DataView` when you don't know (or don't want to enumerate)
28
+ the shape; `KnownFieldsCard` when you know exactly which few keys matter
29
+ and the rest is an open bag. `<DataView data={record} />` +
30
+ `<KnownFieldsCard data={record.metadata} known={…} />` is a normal pairing.
31
+
32
+ ## Props
33
+
34
+ | Prop | Default | Effect |
35
+ |---|---|---|
36
+ | `data` | — | the record. `null`/`undefined` is safe (renders `emptyText`) |
37
+ | `fields` | inferred | explicit schema; omit for full inference over `Object.keys(data)` |
38
+ | `exclude` | `[]` | keys to drop. Applies in **both** modes |
39
+ | `columns` | `2` | grid columns at full width |
40
+ | `showEmptyValues` | `true` | `false` restores the source behavior: an empty value drops its row |
41
+ | `emptyValueText` | `'—'` | rendered for an empty value |
42
+ | `emptyText` | `'No data.'` | shown when nothing is renderable |
43
+ | `maxDepth` | `2` | levels of **nested** DataView below this one; deeper → JSON block |
44
+ | `depth` | `0` | internal recursion counter — callers leave it alone |
45
+
46
+ **Ordering is stable**: the schema's order, or the record's own key order
47
+ (`Object.keys`, i.e. insertion order).
48
+
49
+ ### `DataViewField`
50
+
51
+ ```tsx
52
+ const SCHEMA: DataViewField[] = [
53
+ { name: 'display_name', label: 'Name' },
54
+ { name: 'settings.theme', label: 'Theme' }, // dotted path
55
+ { name: 'plan_amount', label: 'Plan', type: 'currency' }, // declared renderer
56
+ { name: 'trust_score', format: (v) => <b>{Number(v).toFixed(1)} / 10</b> },
57
+ { name: 'bio', span: 'full' },
58
+ { name: 'last_error', hideEmpty: true },
59
+ ];
60
+ ```
61
+
62
+ - `name` — a **dotted path** (`settings.theme`); an own property of that
63
+ literal name wins over traversal (KnownFieldsCard's rule).
64
+ - `label` — defaults to the humanized last path segment (`created_at` →
65
+ `Created At`, `lastLogin` → `Last Login`).
66
+ - `type` — names a built-in renderer, skipping inference. An unknown value
67
+ **warns and infers** — never renders nothing (rule 4).
68
+ - `format(value, name, data)` — replaces the renderer entirely; returns any
69
+ `ReactNode`. Same signature as `KnownFieldsCard`'s.
70
+ - `span` — `'full'` spans the grid; `'auto'` (default) is full only for
71
+ arrays / objects / nested grids.
72
+ - `hideEmpty` — drop the row instead of showing `—`.
73
+
74
+ ## Inference — the heuristics
75
+
76
+ Two exported pure functions carry it, usable on their own:
77
+ `inferFieldType(value, key)` and `shouldUseDataView(value, keyLower)`.
78
+
79
+ **Order matters** — the first match wins:
80
+
81
+ | # | Test | Type |
82
+ |---|---|---|
83
+ | 1 | `value == null` | `text` |
84
+ | 2 | `typeof value === 'boolean'` | `boolean` |
85
+ | 3 | key starts `is_`/`has_`/`can_`/`should_` **and** value is boolean-ish (`0`/`1`/`"true"`/`"yes"`…) | `boolean` |
86
+ | 4 | key contains `date`, `time`, `created`, `updated`, `modified`, `last_login`, `expires`, `last_activity`, or **ends in `_at`** | `datetime` |
87
+ | 5 | key contains `email` / `mail` | `email` |
88
+ | 6 | key contains `url` / `website` / `homepage`, or has the word `link` | `url` |
89
+ | 7 | key contains `phone` / `mobile`, or has the word `tel` / `cell` | `phone` |
90
+ | 8 | key contains `price` / `amount` / `salary` / `revenue`, or has the word `cost` / `fee` | `currency` |
91
+ | 9 | key has the word `size` / `bytes` / `filesize` | `filesize` |
92
+ | 10 | (key contains `percent` or has the word `rate` / `ratio`) **and** value is a number | `percent` |
93
+ | 11 | `typeof value === 'number'` | `number` |
94
+ | 12 | array | `array` |
95
+ | 13 | object with `renditions` | `file` |
96
+ | 14 | object passing `shouldUseDataView` | `dataview` |
97
+ | 15 | any other object | `object` |
98
+ | 16 | string `"…@….…"` / `^\d{4}-\d{2}-\d{2}` / `^https?://` / phone-shaped **with ≥7 digits** | `email` / `date` / `url` / `phone` |
99
+ | 17 | anything else | `text` |
100
+
101
+ "contains" is a substring test (the source's); "has the word X" is an exact
102
+ **token** match after splitting the key on `_`, `-` and camelCase humps —
103
+ see *Deviations* for why.
104
+
105
+ ### `shouldUseDataView` — nested grid or JSON?
106
+
107
+ An object gets a nested `DataView` when **either**:
108
+
109
+ 1. it carries an `id` — it's a related record; or
110
+ 2. its key contains one of `permissions perms access rights settings config
111
+ configuration options profile info details data metadata meta attributes
112
+ props preferences prefs user_data contact address location stats
113
+ statistics metrics counts`, **and** it has 2–20 keys, **and** no child of
114
+ it is an object with more than 3 keys.
115
+
116
+ Everything else is a JSON block. That last clause is the source's: one
117
+ deep child disqualifies the whole object, on the theory that a grid of
118
+ grids stops being readable.
119
+
120
+ ### What each type renders
121
+
122
+ | Type | Rendering |
123
+ |---|---|
124
+ | `datetime` | `fmt.relative` for `last_*`/`*ago*`/`*relative*`; `fmt.datetime` for a `time`-not-`date` key; else `fmt.date` |
125
+ | `date` | `fmt.date` |
126
+ | `email` | `<a href="mailto:…">` |
127
+ | `url` / `file` | `<a target="_blank" rel="noopener noreferrer">` + external-link icon |
128
+ | `phone` | `<a href="tel:…">` around `fmt.phone` (digits + `+` only in the href) |
129
+ | `currency` | `fmt.currency`; `EUR`/`GBP` from the key, else `USD`. **Integer → cents, fractional → major units** |
130
+ | `filesize` | `fmt.filesize` (decimal KB/MB/GB) |
131
+ | `percent` | `fmt.percent`; **`\|v\| ≤ 1` is scaled ×100**, anything larger is already a percentage |
132
+ | `number` | `count`/`total`/`followers`/`views` → `fmt.compact` at ≥1000 else `fmt.number`; `score`/`rating` → `fmt.number(v, 1)` when fractional; `version` / the word `id` → verbatim; else `fmt.number` |
133
+ | `boolean` | `<Badge tone="success\|muted">Yes/No</Badge>` — the house treatment, via `fmt.yesNo` |
134
+ | `text` | `description`/`content`/`body` → truncate 200/100; `summary`/`excerpt` → 150; `username`/`slug`/`handle`/`login` → verbatim monospace; `name`/`title`/`label` → `.cap` + truncate 50; `code`/`token`/`key`/`secret` → monospace, `fmt.mask` over 20 chars; else truncate 100 |
135
+ | `array` | scalars join with `, `; objects → `Array · N items` in a collapsed `JsonBlock` |
136
+ | `object` | `JsonBlock` |
137
+ | `dataview` | nested `<DataView>` (tinted inset) until `maxDepth`, then `JsonBlock` |
138
+
139
+ Truncated text keeps the full value on a `title` attribute.
140
+
141
+ ## JsonBlock
142
+
143
+ ```tsx
144
+ <JsonBlock value={payload} /> // auto
145
+ <JsonBlock value={payload} label="Request trace" collapsible defaultOpen />
146
+ ```
147
+
148
+ | Prop | Default | Effect |
149
+ |---|---|---|
150
+ | `value` | — | anything JSON-serializable |
151
+ | `label` | `Object` / `Array · N lines` | caption override |
152
+ | `collapsible` | `>10 lines \|\| >500 chars` (source rule) | force the Show/Hide affordance |
153
+ | `defaultOpen` | `false` | start expanded when collapsible |
154
+
155
+ - **Syntax highlighting is real tokenization**, not regex-over-HTML:
156
+ `tokenizeJson()` walks the string emitting `key` / `string` / `number` /
157
+ `boolean` / `null` / `plain` tokens, rendered as React `<span>`s. **No
158
+ `dangerouslySetInnerHTML`.** The token stream reassembles to the exact
159
+ input, so a value like `"has a colon: and \"q\""` colors correctly (the
160
+ source's five regexes did not).
161
+ - Copy writes with the async Clipboard API, toasts on success, flips the
162
+ icon to a check for 1.2s, and toasts an error if the write rejects.
163
+ Outside a secure context `navigator.clipboard` is absent — it says so
164
+ rather than silently no-op'ing through `document.execCommand`.
165
+ - A circular payload renders a muted `[Object] — cannot display as JSON`.
166
+ Nothing here throws.
167
+
168
+ ## Invariants
169
+
170
+ 1. **A renderer is the last thing allowed to take a record down.** Every
171
+ per-field render is wrapped: a throw becomes a `console.warn` plus the
172
+ plain `String(value)`. `fmt.*` never throws either.
173
+ 2. **Empty is `—`, not absent** (`emptyValueText`). `null`, `''`, `[]` and
174
+ `{}` all read empty. `showEmptyValues={false}` or `hideEmpty` opt out.
175
+ 3. **Unknown `type` warns and infers** (rule 4) — never renders nothing.
176
+ 4. **`ReactNode` in, `ReactNode` out.** No HTML strings anywhere (rule 6).
177
+ 5. **Epoch SECONDS** are handled by `fmt.*`; don't pre-convert.
178
+ 6. **Depth is capped.** `maxDepth` (default 2) bounds nesting, so a
179
+ pathological payload degrades to JSON instead of recursing.
180
+
181
+ ## Pitfalls
182
+
183
+ - **Money is CENTS when it's an integer.** django-mojo stores minor units,
184
+ so `plan_amount: 129900` → `$1,299.00` while `overage_price: 4.75` →
185
+ `$4.75`. A record that breaks that convention needs an explicit
186
+ `format`.
187
+ - **Percent auto-scales below 1.** `error_rate: 0.0182` → `2%`;
188
+ `error_rate: 12` → `12%`. A ratio that legitimately exceeds 1 needs an
189
+ explicit `format`.
190
+ - **The name wins over the value.** `contact_email: 1234` still renders a
191
+ `mailto:` link — that is the source's contract, and it is what makes
192
+ schemaless inference useful on sparse rows. Declare a `type` (or a
193
+ `format`) on any field whose name lies.
194
+ - **No pipe strings.** `format: "date('MMM D')|capitalize"` does not exist
195
+ here (do-not-recreate); nor does the source's inline `name: 'is_tor|
196
+ status_text'` syntax. Use `type` or a function.
197
+ - **`fmt.capitalize` doesn't exist**, so `name`-ish fields get the `.cap`
198
+ class — CSS capitalization, which leaves the stored value intact.
199
+ - Container queries do the reflow off the grid's **own** width, so a
200
+ DataView inside a modal or a detail rail stacks correctly without
201
+ knowing the viewport.
202
+
203
+ ## Deviations from web-mojo
204
+
205
+ Beyond the pipe engine and trusted HTML (both architecture rules), these
206
+ are behavior changes, each a source bug:
207
+
208
+ | Source | Here |
209
+ |---|---|
210
+ | `percent \|\| rate \|\| ratio && number` — `&&` binds tighter, so `rate` matched any type, and `includes()` matched **inside words**: `duration` contains "ratio" (8040 → `804,000%`), `generated` contains "rate" | short needles (`rate` `ratio` `size` `bytes` `tel` `cell` `cost` `fee` `id` `key` `code` `link` `label` `body`) match on **word boundaries**, and the number guard covers the whole percent branch |
211
+ | `typeof value === 'boolean'` tested **after** the name chain, so `is_email_verified: true` → `mailto:true` | booleans are typed first, before any name pattern |
212
+ | `/^\+?[\d\s\-()]+$/` made `"42"` and `"2024"` tel: links | a phone string needs ≥7 digits |
213
+ | `username` contains `name`, so it hit the capitalize branch and the `slug` branch below was unreachable for it — and `slug` rewrote `ian.starnes` to `ianstarnes` | identifiers are checked first and render **verbatim** in monospace |
214
+ | an empty value **dropped its row**, so a record's shape changed with its data | `showEmptyValues` defaults `true`; `—` is the house empty |
215
+ | `shouldUseDataView`'s `id` rule was gated on `window.utils`, a global absent in half of web-mojo's entry points | the rule always applies |
216
+ | no `file` renderer — `{renditions}` objects stringified to `[object Object]` | anchor to `url` with `name`/`filename`, else a JSON block |
217
+ | unbounded nesting | `maxDepth`, then JSON |
218
+ | JSON "highlighting" = 5 regexes over escaped HTML with hardcoded light-mode hexes (`#0969da`, `#0a3069`, …) injected as `innerHTML` | a real tokenizer → React spans over theme tokens, correct in both themes |
219
+
220
+ Not ported (deliberately): `model` binding and the `change` re-render
221
+ listener (TanStack Query owns reactivity — pass `data` from a hook),
222
+ `updateData`/`updateFields`/`updateConfig`/`refresh` (props, not
223
+ imperative setters), `setFieldFormat`/`addFormatPipe`/`clearFieldFormat`/
224
+ `getFieldFormats` (the fluent pipe-string API), `{{key}}` template
225
+ strings, and the `field:click` event (`data-field` is still on each item
226
+ for tests and CSS).
@@ -0,0 +1,151 @@
1
+ # DatePicker — the single-value picker shell
2
+
3
+ ```ts
4
+ import { DatePicker, type DatePickerChangeEvent } from 'portal-mojo/ui';
5
+ ```
6
+
7
+ The trigger-and-popover shell over the [Calendar engine](calendar.md), ported
8
+ from web-mojo's `forms/inputs/DatePicker.js`. One component covers all three
9
+ precisions — day, month-only, year-only — and renders either as a form
10
+ control that opens a [Popover](popover.md), or `inline` as a bare calendar.
11
+
12
+ It owns exactly three things the engine does not: the display text, the
13
+ open/closed popover, and the clear affordance. Everything else is
14
+ pass-through.
15
+
16
+ ```tsx
17
+ const [due, setDue] = useState<string | null>(null);
18
+
19
+ <DatePicker
20
+ value={due}
21
+ min="2026-01-01"
22
+ disabledDates={['2026-03-12']}
23
+ onChange={(e) => setDue(e.value)}
24
+ />
25
+ ```
26
+
27
+ ## Value shape (the contract)
28
+
29
+ - `value` is the **canonical string at precision** — `YYYY-MM-DD` /
30
+ `YYYY-MM` / `YYYY` — or `null` for empty. Same strings the engine speaks;
31
+ no `Date` objects, no epochs. (django-mojo datetime *fields* are epoch
32
+ seconds — convert at the model/form boundary, not here.)
33
+ - **Controlled.** A commit fires `onChange`; the trigger repaints only once
34
+ the owner writes the value back. The picker holds no shadow copy.
35
+ - A loose-but-valid prop is canonicalized before anything reads it
36
+ (`"2026-3-4"` → `"2026-03-04"`), so `onChange().oldValue` and the calendar
37
+ always see the same normal form.
38
+
39
+ ```ts
40
+ interface DatePickerChangeEvent {
41
+ value: string | null; // canonical, or null when cleared
42
+ formatted: string; // the trigger's display text ('' when null)
43
+ oldValue: string | null; // canonical value being replaced
44
+ }
45
+ ```
46
+
47
+ `onChange` fires on **commit only** — a calendar pick or the clear ✕ — and
48
+ only when the canonical value actually changes. Re-picking the selected day
49
+ closes the popover without an event.
50
+
51
+ ## API
52
+
53
+ | Prop | Notes |
54
+ |---|---|
55
+ | `value` | controlled canonical string, or `null` |
56
+ | `precision` | `'day' \| 'month' \| 'year'` (default `day`). Unknown values warn + fall back to `day` |
57
+ | `onChange` | `(e: DatePickerChangeEvent) => void` — commit only |
58
+ | `displayFormat` | trigger text format (`formatForDisplay` tokens). Default per precision: `MMM DD, YYYY` / `MMM YYYY` / `YYYY` |
59
+ | `placeholder` | empty-state text. Default `Select date/month/year...` |
60
+ | `min` / `max` | pass-through bounds (compared at the pane precision being painted) |
61
+ | `disabledDates` | `YYYY-MM-DD[]`, day grids only |
62
+ | `firstDay` | 0=Sun … default 1=Mon |
63
+ | `locale` | BCP-47 for month/weekday names, default `en-US` |
64
+ | `autoApply` | close the popover on commit. Default `true`; `false` commits and stays open |
65
+ | `inline` | render the calendar in place — no trigger, no popover, no ✕ |
66
+ | `disabled` / `readOnly` | both make the picker inert (no open, no commits); both hide the ✕ |
67
+ | `required` | hides the ✕ — a required field cannot return to empty |
68
+ | `invalid` | paints the error border (`is-invalid`) |
69
+ | `name` | renders a hidden input with the canonical value (native form posts only) |
70
+ | `id` / `className` | trigger id (for a label's `htmlFor`) / extra class on the root |
71
+
72
+ The popover is the shared `Popover` at `bottom-start`: outside-mousedown and
73
+ Escape close it, it repositions on scroll/resize, and it joins the top layer
74
+ so a picker inside a `<dialog>` modal paints above it.
75
+
76
+ ## Display formatting
77
+
78
+ `displayFormat` is filtered to the tokens the precision can actually fill,
79
+ then trailing separators are trimmed (ported verbatim as
80
+ `_stripIncompatibleTokens`):
81
+
82
+ | precision | behavior |
83
+ |---|---|
84
+ | `day` | passes through untouched |
85
+ | `month` | `\bDD\b` / `\bD\b` removed |
86
+ | `year` | all month + day tokens removed; empties fall back to `'YYYY'` |
87
+
88
+ ```
89
+ month "D MMMM YYYY" → "MMMM YYYY" → "August 2026"
90
+ month "YYYY-MM-DD" → "YYYY-MM" → "2026-08"
91
+ year "MMM DD" → "YYYY" → "2026"
92
+ month "MMM DD, YYYY" → "MMM , YYYY" → "Aug , 2026" ← the wart
93
+ year "DD/MM/YYYY" → "//YYYY" → "//2026" ← the wart
94
+ ```
95
+
96
+ The strip removes the **token**, not the punctuation around it, and only
97
+ trailing separators are cleaned. Pass a format that suits the precision, or
98
+ lean on the per-precision default.
99
+
100
+ ## Invariants (keep them true)
101
+
102
+ - **One value pipeline.** The prop is parsed + re-formatted once into
103
+ `canonical`; the trigger text, the calendar's `value`, the hidden input and
104
+ every emitted event read that single derivation. The control can never
105
+ display something the owner's state does not hold.
106
+ - **A value that does not parse renders as empty and logs one
107
+ `console.warn`** naming the expected shape — once per distinct bad value,
108
+ from an effect (never during render), never silently blank.
109
+ - **The clear ✕ is hidden when `required || disabled || readOnly`.** It is a
110
+ SIBLING of the trigger button, absolutely positioned over it — clicking it
111
+ can never open the popover.
112
+ - **`disabled`/`readOnly` are inert in both modes** — the trigger opens
113
+ nothing and, inline, the calendar takes no commits.
114
+ - The ✕ shows whenever clearing is allowed, even with nothing selected
115
+ (source parity); clicking it then is a no-op — no `onChange`.
116
+ - The trigger is a real `<button>`: Enter/Space open it, Escape closes.
117
+
118
+ ## Deviations from web-mojo (deliberate)
119
+
120
+ | Source | Here | Why |
121
+ |---|---|---|
122
+ | clear `<button>` nested inside the trigger `<button>` | sibling, absolutely positioned | button-in-button is invalid DOM; React logs a `validateDOMNesting` error |
123
+ | `_setValue` patched `textContent` + a hidden input in place | value is a controlled prop | one pipeline; no shadow state to drift |
124
+ | `change` emitted `value: ''` when cleared | `value: null` | matches the `string \| null` prop type end to end |
125
+ | calendar instance persisted across opens (page position too) | popover content unmounts on close | React idiom; reopening always re-pages to the value, else today |
126
+ | inline mode ignored `disabled` | inline honors `disabled`/`readOnly` | the props have to mean something |
127
+ | imperative `setValue/getValue/clear/setMin/setMax/show/hide/focus` | props | nothing to call — the owner holds the value |
128
+
129
+ ## Pitfalls
130
+
131
+ - The ✕ is `tabIndex={-1}` (source parity — a second tab stop per date field
132
+ was not wanted). Keyboard users clear the field through the owning form,
133
+ not the trigger.
134
+ - `min`/`max` gate at the **pane's** precision: with `min="2026-03-15"` the
135
+ March tile and the 2026 tile stay enabled, only out-of-bounds days
136
+ disable. Day-level rules belong in `disabledDates`.
137
+ - `inline` fills its container — give it a sized wrapper (~280px), same as a
138
+ bare `<Calendar>`.
139
+ - Don't feed it `new Date().toISOString().slice(0,10)` if you care about the
140
+ local day — that's UTC. Build canonical strings with `dateFns.formatYmd`.
141
+ - `autoApply={false}` keeps the popover open after a commit; there is no
142
+ Apply button (there wasn't one in the source either) — the user closes it
143
+ by clicking out, pressing Escape, or clicking the trigger.
144
+
145
+ ## Styling
146
+
147
+ `apps/portal/src/theme/datepicker.css` — trigger, clear ✕, popover content
148
+ box, inline host. The grid itself is `theme/calendar.css` and the floating
149
+ surface is `theme/popover.css`; this file adds nothing they already cover.
150
+ Trigger metrics follow the house control (`.input`): `--surface2` fill,
151
+ `--line` border, 8px radius, accent focus ring. Tokens only, both themes.