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,203 @@
1
+ # markdown — server-rendered markdown + the trust model
2
+
3
+ ```tsx
4
+ import { MarkdownView } from 'portal-mojo/ui';
5
+ import {
6
+ renderMarkdown, peekMarkdown, clearMarkdownCache,
7
+ sanitizeMarkdownHtml, safeLinkUrl, safeImgUrl,
8
+ MarkdownFallback, markdownToNodes, markdownToHtml,
9
+ MARKDOWN_RENDER_ENDPOINT, MAX_MARKDOWN_BYTES, type SanitizeDrop,
10
+ } from 'portal-mojo/client';
11
+ ```
12
+
13
+ django-mojo owns the markdown renderer. The portal asks it instead of
14
+ shipping a second one that drifts from the docs system — and keeps a
15
+ dependency-free client renderer behind it so a missing endpoint, an offline
16
+ tab or a cold first frame still shows the document.
17
+
18
+ ## `<MarkdownView>`
19
+
20
+ | Prop | Default | Meaning |
21
+ |---|---|---|
22
+ | `source` | — | The markdown text. |
23
+ | `renderer` | `'server'` | `'server'` calls `/api/docit/render` and sanitizes the answer; `'client'` never touches the wire (previews of text being typed; feeds that must not fan out one request per item). |
24
+ | `debounceMs` | `0` | Wait this long after `source` stops changing before calling the server. Set it for text that grows token by token (a streaming assistant reply) so one reply is one request. |
25
+ | `className` | — | Extra classes on the wrapper. `md` is always present. |
26
+
27
+ Renders one `<div class="md">`. Styling lives in the consuming app —
28
+ `apps/portal/src/theme/markdown.css`, tokens only, both themes; both render
29
+ paths emit the same element set, so one stylesheet dresses both.
30
+
31
+ ### No loading jump, by construction
32
+
33
+ 1. Source already rendered? The module cache answers **synchronously** — the
34
+ server HTML is on the first frame, no request, no transition.
35
+ 2. Otherwise the **client fallback paints immediately** (real content at
36
+ roughly the right height, `aria-busy`, class `md-pending`), and the server
37
+ HTML swaps in under the same wrapper when it lands.
38
+ 3. If the render fails, the fallback simply stays, with one `console.warn`
39
+ saying why.
40
+
41
+ There is no spinner and no empty state at any point. `md-pending` fades in
42
+ after a 250 ms delay, so a fast answer never flickers and a slow one never
43
+ moves.
44
+
45
+ ## Wire contract
46
+
47
+ `mojo/apps/docit/rest/render.py` — read, not assumed:
48
+
49
+ ```
50
+ POST /api/docit/render {markdown: string} @requires_auth
51
+ → {status: true, code: 200, data: {html: string}}
52
+ ```
53
+
54
+ The handler returns a bare dict, so `mojo/decorators/http.py` wraps it into
55
+ the standard envelope — unwrapped at the **one boundary** (`mojoCall`). Do
56
+ not reintroduce web-mojo's `resp.data.data.html || resp.data.html` sniff; it
57
+ existed in three copies there and is on the do-not-recreate list.
58
+
59
+ Errors: `400` markdown field is required · `405` non-POST · `413` over
60
+ `MAX_MARKDOWN_BYTES` (400 000 bytes; `renderMarkdown` rejects locally rather
61
+ than spend a round trip on a guaranteed 413).
62
+
63
+ The server runs mistune with `hard_wrap=True` (a single newline is a `<br>`)
64
+ and `escape=True` (raw HTML in the SOURCE becomes text). Its plugin set adds
65
+ tables, task lists, strikethrough, footnotes, abbr, mark, math, spoiler and
66
+ pygments syntax highlighting.
67
+
68
+ The mock (`client/mock.ts`) answers the same route with the same envelope and
69
+ the same error codes, running the client parser as its renderer — so mock and
70
+ live differ in fidelity, never in shape. **The mock's HTML goes through the
71
+ sanitizer exactly like a real backend's. There is no trusted lane.**
72
+
73
+ ## THE TRUST MODEL
74
+
75
+ > **Server HTML is untrusted.** django-mojo escapes raw HTML in the markdown
76
+ > source, but a portal must not be one backend regression away from stored
77
+ > XSS. Nothing is trusted because of where it came from.
78
+
79
+ Two render paths, two different guarantees:
80
+
81
+ ### Path A — server HTML → `sanitizeMarkdownHtml` → `dangerouslySetInnerHTML`
82
+
83
+ `MarkdownView` is the **only** sanctioned `dangerouslySetInnerHTML` in
84
+ portal-mojo, and the only string it can ever receive is the return value of
85
+ `renderMarkdown`, which sanitizes *before it resolves*. Unsanitized server
86
+ HTML never leaves `client/markdown.ts`, so no caller can hold it.
87
+
88
+ The sanitizer (`client/markdown-sanitize.ts`):
89
+
90
+ 1. Parses with `DOMParser('text/html')` — an **inert** document: no script
91
+ runs, no image loads, no `onerror` fires. (Assigning to a live element's
92
+ `innerHTML` would already have fetched `<img src=x onerror=…>`.)
93
+ 2. Walks the tree. Every element is one of three things:
94
+ - **allowlisted** → kept, attributes then scrubbed to an allowlist
95
+ - **dangerous** → removed **with its whole subtree**
96
+ - **anything else** → **unwrapped**: the element goes, its (already
97
+ sanitized) children stay. Text is never lost — which is what keeps a
98
+ pygments `<div class=highlight><span class=k>` block readable after its
99
+ markup is stripped.
100
+ 3. Serializes with the browser's own serializer (`body.innerHTML`).
101
+
102
+ | | Set |
103
+ |---|---|
104
+ | **Kept** | `p br hr h1-h6 ul ol li strong em del code pre blockquote a img table thead tbody tr th td input mark sup sub` |
105
+ | **Removed with subtree** | `script style iframe object embed applet noscript template link meta base title frame frameset form button select textarea svg math` — plus any element outside the XHTML namespace |
106
+ | **Unwrapped** | everything else (`div span section sup-wrappers …`) |
107
+ | **Attributes kept** | `a[href]`, `img[src alt]`, `input[type checked disabled]`, and `class` — **`md-` prefixed values only** |
108
+ | **Attributes dropped** | every other attribute, which is how `on*` handlers, `style`, `srcset`, `formaction`, `xlink:href`, `id` and `data-*` all go |
109
+
110
+ Value rules on top of the attribute allowlist:
111
+
112
+ - `a[href]` — `http:` / `https:` / `mailto:` / `#fragment` only. Rejected
113
+ hrefs are removed but the **link text stays** (never render nothing).
114
+ `http(s)` links are rewritten with `target="_blank" rel="noopener noreferrer"`.
115
+ - `img[src]` — `http:` / `https:` only. `data:image/svg+xml` is a script
116
+ vector, not an image. An image with no usable `src` is dropped entirely.
117
+ - `input` — must be `type=checkbox` (task lists); anything else is a phishing
118
+ surface and is dropped. `disabled` is forced on, `checked` is preserved.
119
+ - URL vetting **normalizes first, then allowlists**: control and zero-width
120
+ characters are stripped (that is how `java&#9;script:` is smuggled) and
121
+ only then is the scheme matched. Unrecognized or scheme-less → dropped.
122
+
123
+ ### Path B — the client fallback → React elements
124
+
125
+ `MarkdownFallback` / `markdownToNodes` emit React elements directly. No HTML
126
+ string is produced and `innerHTML` is never touched, so there is nothing for
127
+ a sanitizer to do: the only way a `<script>` in the source could become a
128
+ script is if React rendered one, and React only renders the elements that
129
+ file names. Text is escaped by React itself.
130
+
131
+ **The one thing React does not make safe is URLs** — it will happily render
132
+ `href="javascript:…"`. Both link and image URLs on this path go through the
133
+ *same* `safeLinkUrl` / `safeImgUrl` used by the sanitizer; a rejected link
134
+ degrades to plain text, a rejected image to its alt text.
135
+
136
+ ### Consequences worth knowing
137
+
138
+ - Server syntax highlighting **survives as text, not colour**: pygments
139
+ classes are not `md-` prefixed, so they are stripped and the wrappers
140
+ unwrapped. Shipping a monokai stylesheet would fight both themes.
141
+ - Table alignment from the server is lost (`style="text-align:…"` is an
142
+ attribute drop).
143
+ - **Relative links and images are dropped** — inside a hash-routed portal
144
+ they resolve against the current route, which is never what the author
145
+ meant. Use absolute `https://` URLs in markdown that the portal renders.
146
+ - `mark`, `sup` and `sub` are on the allowlist beyond the base set because
147
+ the backend's plugin list emits them and all three are inert.
148
+
149
+ ## `renderMarkdown(source)`
150
+
151
+ Returns the **sanitized** HTML for `source`.
152
+
153
+ - **REJECTS on any failure** — endpoint missing, offline, 401, oversized
154
+ body, a response without `html`, no DOM to sanitize with. It never resolves
155
+ a fallback as success: the fallback path renders React *elements*, so a
156
+ string could not say which renderer produced it. The fallback decision
157
+ belongs to the component.
158
+ - **Cached** in a module-level `Map` keyed by an FNV-1a hash of the source
159
+ plus its length. Every entry stores its source and is verified on hit, so a
160
+ hash collision costs a re-render, never a wrong document. 200 entries,
161
+ oldest evicted.
162
+ - **Single-flight**: two mounts of the same message are one request. A chat
163
+ feed re-rendering 50 cached messages is zero.
164
+
165
+ `peekMarkdown(source)` reads the cache synchronously (`null` on a miss) —
166
+ that is what lets a cache hit paint on the first frame.
167
+ `clearMarkdownCache()` drops it.
168
+
169
+ ## Files
170
+
171
+ | File | Job |
172
+ |---|---|
173
+ | `ui/MarkdownView.tsx` | the component; the one `dangerouslySetInnerHTML` |
174
+ | `client/markdown.ts` | the wire call, cache, single-flight, size guard; public re-exports |
175
+ | `client/markdown-sanitize.ts` | the allowlist sanitizer — the security boundary |
176
+ | `client/markdown-parse.ts` | markdown → block AST, plus the HTML emitter the **mock** uses |
177
+ | `client/markdown-fallback.tsx` | AST → React elements (the fallback path) |
178
+ | `apps/portal/src/theme/markdown.css` | `.md` styling, tokens only, both themes |
179
+
180
+ ## Fallback coverage
181
+
182
+ One parser feeds both the React emitter and the mock's HTML emitter, so what
183
+ you see under the mock is what the fallback renders live: headings,
184
+ paragraphs (hard-wrap `<br>`), `hr`, fenced code, blockquotes, nested and
185
+ task lists, GFM tables (including escaped `\|`), bold/italic/bold-italic
186
+ (`*` and `_`, with `snake_case_words` left alone), inline code,
187
+ strikethrough, links, images, `<autolinks>` and bare URLs.
188
+
189
+ Server-only, and degrading to their literal text on the fallback path:
190
+ footnotes, math, abbr, spoiler, syntax highlighting.
191
+
192
+ ## Pitfalls
193
+
194
+ - **Do not add a second `dangerouslySetInnerHTML`.** If you need to show
195
+ rendered markdown, use `MarkdownView`; if you need to show what the
196
+ sanitizer produced, show it as text (the playground demo does).
197
+ - **Do not pass user HTML to `MarkdownView`** — pass *markdown*. Raw HTML in
198
+ the source is escaped to text by both renderers, deliberately.
199
+ - Don't call `renderMarkdown` in a render body; it is a promise. The
200
+ component already owns the lifecycle.
201
+ - One request per distinct source, not per component: with a streaming
202
+ source set `debounceMs`, or render `renderer="client"` while it streams and
203
+ switch to `'server'` once it settles.
@@ -0,0 +1,89 @@
1
+ # menus & access — global Admin, product menus, permissions, group context
2
+
3
+ ```ts
4
+ import { registerMenus, setDefaultMenu, SidebarNav, Guarded, RequiresGroup, GroupSwitcher } from 'portal-mojo/ui';
5
+ import { useMe, useCan, useAuthSnapshot, useActiveGroup, GroupProvider, hasPermission, registerPermissionCategories } from 'portal-mojo/client';
6
+ ```
7
+
8
+ ## Menu registry + SidebarNav (A4)
9
+
10
+ Menus are DATA registered once (`registerMenus([...])`); the active menu is
11
+ **derived** from (route, active group, me) — no mutable menu state.
12
+
13
+ ```ts
14
+ registerMenus([
15
+ { name: 'admin', scope: 'global', presentation: 'accordion', items: [
16
+ { divider: 'Main' },
17
+ { id: 'identity', label: 'Identity & Access', icon: 'bi-people', children: [
18
+ { label: 'Users', route: '/users', permissions: 'view_users' },
19
+ ]},
20
+ ]},
21
+ { name: 'project', scope: 'group', groupKind: 'any', items: [ /* product menu */ ] },
22
+ ]);
23
+ setDefaultMenu('admin');
24
+ ```
25
+
26
+ Resolution: route containment in registration order (group menus eligible
27
+ only under a kind-matched active group) → defaultMenu → first non-group
28
+ visible. Items support `permissions` (any-of) ∧ `requiresGroupKind`;
29
+ dividers with no visible children drop; a parent lights when a child route
30
+ is active. `scope: 'admin' | 'group' | 'account' | 'global'` makes ownership
31
+ explicit; legacy `groupKind` menus still infer group scope. Static rendering
32
+ remains the default for compatibility. `presentation: 'accordion'` enables
33
+ searchable, single-open-section navigation. Give accordion parents stable
34
+ `id` values; `keywords` add non-rendered search terms, and
35
+ `permissionClauses` express AND-across / any-within checks when both a section
36
+ and its route have gates.
37
+
38
+ The standalone Admin app registers one global accordion menu and does not
39
+ mount `GroupProvider` or `GroupSwitcher`. Admin is system-wide: selecting a
40
+ group must never change which Admin pages or grants are visible. A product
41
+ portal owns its group-scoped menus and switcher. When it embeds Admin sections,
42
+ those routes still run inside a global-scope boundary so active-member grants
43
+ cannot satisfy `sys.*` or Admin section permission checks.
44
+
45
+ Until the identity bundle is fully consolidated, the app-local `/users` and
46
+ `/groups` routes must carry their own system-pinned route and menu clauses in
47
+ addition to nested control gates. A Group opened from `/groups` is inspected
48
+ route data only: it must not call `setActiveGroup`, add `?group=`, or treat an
49
+ active product membership as Admin authority.
50
+
51
+ `<SidebarNav collapsed onRequestExpand>` provides the compact icon rail.
52
+ Collapsed category buttons expose CSS tooltips and expand on click, Enter, or
53
+ Space. Accordion search matches labels plus `keywords` and reports its result
54
+ count through a live status region.
55
+
56
+ Admin section bundles contribute through `adminSectionsMenu`. Use
57
+ `{grouped: true, presentation: 'accordion'}` for the domain-grouped Admin
58
+ experience. Routes remain mount-relative; multiple embedded root sections
59
+ share one `/system` landing instead of emitting duplicate index routes.
60
+
61
+ ## Permissions (A2) — client gates, server authority
62
+
63
+ `Me.permissions` is a dict; truthy `true` OR `1` grants. Semantics
64
+ (`hasPermission`):
65
+ - `is_superuser` → everything.
66
+ - system `admin` → full client-side grant (NOTE: the BACKEND does not
67
+ honor `admin` as a wildcard — only `is_superuser` and category rollup.
68
+ The client is deliberately more permissive; the server re-checks).
69
+ - category → granular rollup (`users` grants `view_users`… — one-way);
70
+ apps extend via `registerPermissionCategories({cat: [perms]})`.
71
+ - `sys.`-prefixed checks never consult the group member.
72
+ - Otherwise the ACTIVE GROUP MEMBER's permissions fold in (member `admin`
73
+ grants within the group only).
74
+
75
+ `useCan(permission | permissions[]) → {can, loading}`;
76
+ `<Guarded permission="view_admin" fallback?>…</Guarded>` renders nothing
77
+ (or fallback) without the grant — fail-closed.
78
+
79
+ ## Group context (A3)
80
+
81
+ Product portals place `<GroupProvider>` at the app root. `useActiveGroup() → {group, member,
82
+ loading, setActiveGroup, clearActiveGroup}`. The active group id rides the
83
+ REAL `?group=` search param (hash-safe, deep-linkable) with localStorage
84
+ fallback; activating a group fetches the caller's member record
85
+ (`/api/group/<id>/member`) whose permissions feed `useCan`.
86
+ `GroupSwitcher` is the searchable tree picker (300ms server search,
87
+ embedded-parent rows, kind chips); `RequiresGroup` wraps pages that need a
88
+ group and shows the pick-a-group state otherwise. Do not install these in the
89
+ standalone Admin shell; they belong to group-scoped product navigation.
@@ -0,0 +1,153 @@
1
+ # MultiSelectDropdown — static-options checkbox dropdown
2
+
3
+ ```tsx
4
+ import {
5
+ MultiSelectDropdown,
6
+ type MultiSelectOption,
7
+ type MultiSelectValue,
8
+ } from 'portal-mojo/ui';
9
+ ```
10
+
11
+ A trigger that summarizes the picks over a checkbox menu that **stays open
12
+ while you tick**; `Done` closes it. The static-options sibling of
13
+ [`CollectionMultiSelect`](collection-multiselect.md) (same selection
14
+ contract, but over an `options` array instead of live model data, and folded
15
+ into a dropdown instead of an always-visible panel). Ported from web-mojo
16
+ `src/core/forms/inputs/MultiSelectDropdown.js`.
17
+
18
+ ```tsx
19
+ const [statuses, setStatuses] = useState<MultiSelectValue[]>(['open']);
20
+
21
+ <MultiSelectDropdown
22
+ options={[
23
+ { value: 'new', label: 'New' },
24
+ { value: 'open', label: 'Open' },
25
+ { value: 'closed', label: 'Closed', disabled: true },
26
+ ]}
27
+ value={statuses}
28
+ onChange={setStatuses}
29
+ label="Status"
30
+ required
31
+ />
32
+ ```
33
+
34
+ ## Props
35
+
36
+ | Prop | Behavior |
37
+ |---|---|
38
+ | `options` | `Array<string \| MultiSelectOption>`. Bare strings normalize to `{ value: s, label: s }`. Option shape: `{ value: string \| number, label?, text?, disabled? }` — `label` wins, `text` is the source's alias (accepted, don't reach for it), else `String(value)`. An option with no `value` warns and is dropped. |
39
+ | `value` / `onChange` | **Controlled.** `value` takes an array, a bare scalar (coerced to one pick), or `null`/`undefined` (none). `onChange` always receives the full next **array**; every toggle is a commit. Values keep their caller-side types (a numeric `2` comes back as `2`); comparisons are normalized. |
40
+ | `label` / `required` / `help` / `error` | House field chrome (`.field-label` / `.field-help` / `.field-error`; `error` replaces `help`). The label is a `<div id>` the trigger points at with `aria-labelledby` — a `<label htmlFor>` cannot target a button. The trigger references **both** that id and its own, so the accessible name keeps the summary (`"Status, New, Open"`). |
41
+ | `placeholder` | Trigger text while nothing is selected, rendered muted. Default `'Select...'`. |
42
+ | `maxHeight` | px before the option list scrolls. Default 300. |
43
+ | `showSelectedLabels` | Summarize with labels (default true). `false` → always `"N selected"`. |
44
+ | `maxLabelsToShow` | Picks above this switch the trigger to `"N selected"`. Default 3. |
45
+ | `disabled` | Locks the trigger and every row. |
46
+ | `placement` | Menu placement against the trigger — `Popover`'s `PopoverPlacement`. Default `'bottom-start'`. |
47
+ | `id` / `className` | `id` lands on the trigger button; `className` on the field wrapper. |
48
+
49
+ Not ported: `name` (served only the legacy change-event payload and per-row
50
+ DOM id generation — React callers close over their own field), `getValue` /
51
+ `setValue` / `setOptions` / `clear` / `getFormValue` / `setFormValue` (the
52
+ whole imperative surface is the `value`/`onChange` pair), and `placeHolder`
53
+ (the source accepted both casings; one spelling survives).
54
+
55
+ ## Trigger summarization
56
+
57
+ ```
58
+ 0 picks → placeholder, muted
59
+ ≤ maxLabelsToShow and showSelectedLabels → labels, comma-joined
60
+ otherwise → "N selected"
61
+ ```
62
+
63
+ Labels list in **selection order**, not option order (source parity) — the
64
+ summary reads back what the user did. A selected value matching no option
65
+ falls back to its own `String()` form **with a one-time `console.warn`**
66
+ (unknown-value rule) — the trigger never renders a blank slot.
67
+
68
+ ## Menu
69
+
70
+ - Mounts in the shared [`Popover`](popover.md): portalled to the top layer,
71
+ so it escapes overflow containers and stacks above open native-`<dialog>`
72
+ modals. The menu width is measured from the trigger (the source's `w-100`)
73
+ at open and kept in sync by a `ResizeObserver` while open.
74
+ - The **whole row** is the click target. Toggling never closes the menu —
75
+ `Done`, Escape, and an outside click do.
76
+ - Per-option `disabled` refuses the toggle by click **and** by keyboard, and
77
+ arrow navigation skips the row.
78
+ - No options → a single `"No options available"` line and **no Done footer**
79
+ (source parity); close with Escape or an outside click.
80
+
81
+ ## Keyboard
82
+
83
+ | Key | Effect |
84
+ |---|---|
85
+ | `Enter` / `Space` / `ArrowDown` on the trigger | Open. Focus moves to the first enabled row — the menu is portalled to the end of `<body>`, so Tab from the trigger could never reach it. |
86
+ | `Tab` | Walks the rows in order, then the `Done` button (every enabled row is `tabIndex=0`, matching `CollectionMultiSelect`). |
87
+ | `ArrowDown` / `ArrowUp` | Move between **enabled** rows, wrapping. |
88
+ | `Home` / `End` | First / last enabled row. |
89
+ | `Space` / `Enter` on a row | Toggle (`preventDefault` — no page scroll). |
90
+ | `Escape` | Closes and returns focus to the trigger. Inside a modal the first Escape closes only the menu (`Popover`'s capture-phase handler). |
91
+
92
+ `Done` and Escape always hand focus back to the trigger. An outside click
93
+ only does when focus is still parked inside the menu — never fight the
94
+ element the user just clicked.
95
+
96
+ ## Invariants
97
+
98
+ 1. **One value pipeline.** The ROW is the checkbox widget
99
+ (`role="checkbox"` + `aria-checked`); the `<input type="checkbox">` is
100
+ display-only — `pointer-events: none`, `readOnly`, `aria-hidden`,
101
+ `tabIndex={-1}` — and renders straight from `value`. A row click and a
102
+ keyboard `Space` call the identical `toggle()`. There is no second event
103
+ source that could move the DOM without moving state.
104
+ 2. **Identity never round-trips through DOM attributes.** Toggles close over
105
+ the option object; every comparison goes through one `String()`
106
+ normalization (`keyOf`). String and number values, and mixes, work by
107
+ construction.
108
+ 3. `null`/`undefined` are the only "nothing selected" — `0` and `''` are
109
+ selections.
110
+ 4. Both themes, tokens only: `apps/portal/src/theme/multiselect-dropdown.css`
111
+ (reuses `.tbl-check`, `.btn`, `.field-*`; the popover shell supplies the
112
+ surface/border/shadow).
113
+
114
+ ## The bug this port kills
115
+
116
+ web-mojo tracked selection in a private `item.selected` flag driven **only by
117
+ row-click delegation**, then wrote `checkbox.checked` by hand — while each row
118
+ also contained a real focusable `<input type="checkbox">` and a row-wide
119
+ `<label for>`. Any toggle the delegation didn't originate (keyboard `Space`
120
+ on the focused checkbox; the label's synthesized click) moved the DOM without
121
+ moving the tracked selection, or moved it twice. The rendered boxes and the
122
+ value drifted apart, and the drift survived — the view re-rendered from the
123
+ same desynced flags.
124
+
125
+ Two smaller ones die with it:
126
+
127
+ - **Numeric values were emitted as strings.** The source read
128
+ `element.getAttribute('data-value')` (always a string) and pushed *that*
129
+ into `selectedValues`, then rebuilt rows with
130
+ `selectedValues.includes(optionValue)` against the raw numbers — so a
131
+ numeric option ticked once could never be seen as selected again.
132
+ - **Falsy scalars vanished.** The constructor dropped a non-array `value`
133
+ outright, and `setValue` coerced with a truthy test, so `0` and `''` were
134
+ silently discarded.
135
+
136
+ ## Pitfalls
137
+
138
+ - The menu is `position: fixed` in the top layer. Don't try to clip, cover
139
+ or z-index it from an ancestor — see [popover.md](popover.md).
140
+ - `maxLabelsToShow={0}` is honored (always the count). The source's `|| 3`
141
+ quietly rewrote a 0.
142
+ - Duplicate values in `value` collapse on the next toggle of that row (the
143
+ removal filters every copy). Pass a de-duplicated array.
144
+ - Options are static and rendered in the order given — there is no search and
145
+ no client-side filtering. Reach for `CollectionMultiSelect` once the list is
146
+ server data or long enough to need search.
147
+ - **Cousin, not yet reconciled:** `FilterBar`'s `multiselect` filter type
148
+ renders its own checkbox list (`.check-list` / `.check-row`) inside the
149
+ filter modal, with the same job and a different implementation — see
150
+ `FilterForm` in `packages/portal-mojo/src/ui/FilterBar.tsx`. Collapsing the
151
+ two onto this component is a follow-up **decision** (the filter dialog
152
+ commits on submit, this one commits per toggle), deliberately not attempted
153
+ here. Change one, look at the other.
package/docs/params.md ADDED
@@ -0,0 +1,73 @@
1
+ # params — the table-state store (single source of truth)
2
+
3
+ ```ts
4
+ import {
5
+ useTableParams, PAGE_SIZES, registerNonFilterParams,
6
+ readTableState, writeTableState, clearTableState,
7
+ } from 'portal-mojo/client';
8
+ ```
9
+
10
+ **THE architecture rule:** one flat params object owns search, sort,
11
+ filters and paging for a table. It is URL-synced (real search string, hash
12
+ router safe), drives the server query, the pills, preset matching, and
13
+ persistence. Components read it; nothing else holds table state.
14
+
15
+ ## `useTableParams(defaults?) → TableParamsApi`
16
+
17
+ State: `search`, `sort` (`'field' | '-field' | ''`, default `'-created'`
18
+ unless `defaults.sort`), `page` (1-based), `size`.
19
+
20
+ `wire` — the params to hand to `mojoList`: `{start, size, sort?, search?,
21
+ ...filters}` where `start = (page-1)*size`.
22
+
23
+ | Method | Semantics |
24
+ |---|---|
25
+ | `setSearch(term)` | writes `search`, resets page 1 |
26
+ | `cycleSort(field)` | asc → desc (`-field`) → off |
27
+ | `setFilter(key, value)` | string \| string[] \| null. Arrays collapse: 1 value → `field=`, 2+ → `field__in=a,b`. A field never carries both forms. null clears |
28
+ | `setDateRange(field, start, end)` | writes the `dr_field/dr_start/dr_end` triple — ONE active range by construction |
29
+ | `removeFilter(f)` / `clearFilters()` | pills & clear-all |
30
+ | `applyPreset(params)` / `presetActive(params)` | mutually-exclusive bundles; active state is DERIVED by matching, never stored |
31
+ | `setPage(n)` / `setSize(n)` | paging (`PAGE_SIZES = [5,10,25,50,100]`) |
32
+ | `applySaved(saved)` | one-shot rehydrate from a persistence blob (below) |
33
+
34
+ Every write goes through the URL (`replace`), so table state deep-links and
35
+ survives reloads by construction.
36
+
37
+ ## `registerNonFilterParams(...keys)` — params the PAGE owns
38
+
39
+ Everything in the query string that isn't `search/sort/page/size` is treated
40
+ as a Django lookup. That is deliberate (any `field__lookup=` deep-links with
41
+ no client schema), but it means a **host page's own query param becomes a
42
+ filter**: `#/components?demo=filters` shipped `demo=filters` to
43
+ `/api/group` — zero rows on the mock, `FieldError` on a live backend — and
44
+ kept the "All" preset from ever matching.
45
+
46
+ Declare such keys once, at module load, before any table renders:
47
+
48
+ ```ts
49
+ registerNonFilterParams('demo'); // the component playground's rail key
50
+ ```
51
+
52
+ Registered keys are excluded from `filters`, `wire` and the pills, and are
53
+ **re-emitted on every write** (each write rebuilds the whole query string,
54
+ so without this they would silently vanish the first time a filter changed).
55
+ Do not register a name that is also a model field.
56
+
57
+ ## Persistence blob (used by ModelTable's `persistState`)
58
+
59
+ `readTableState/writeTableState/clearTableState(key)` store
60
+ `{v: 2, sort?, size?, search?, filters?, hidden?}` at
61
+ `localStorage["mojo:tableview:" + key]`.
62
+
63
+ Precedence on restore: **URL > saved > defaults** per key — except `size`,
64
+ a per-user viewing preference the URL merely echoes, where **saved wins**.
65
+ Corrupt or other-version blobs are discarded AND cleared. `sort: ''`
66
+ (deliberately un-sorted) is not persisted — the default sort returns.
67
+
68
+ ## Pitfalls
69
+
70
+ - Values are strings on the wire — booleans as `'true'/'false'`.
71
+ - Never mutate `wire`; it's derived.
72
+ - Don't store table state anywhere else (component state, context, redux
73
+ patterns) — that's the exact drift this store exists to prevent.
@@ -0,0 +1,130 @@
1
+ # Popover — the anchored top-layer primitive
2
+
3
+ ```ts
4
+ import { Popover, type PopoverPlacement, type PopoverCloseReason } from 'portal-mojo/ui';
5
+ ```
6
+
7
+ The shared surface every dropdown-style control mounts in — date/time
8
+ pickers, autocomplete, multi-selects, kebab menus. Ported from web-mojo's
9
+ `CalendarPopover.js`. Portal-mounted so it escapes overflow/clipping
10
+ containers; anchor-positioned; closes on outside-mousedown and Escape;
11
+ repositions on any scroll (capture phase), resize, and anchor/content
12
+ size change.
13
+
14
+ ```tsx
15
+ const btnRef = useRef<HTMLButtonElement>(null);
16
+ const [open, setOpen] = useState(false);
17
+
18
+ <button ref={btnRef} onClick={() => setOpen(v => !v)}>Pick…</button>
19
+ <Popover anchorRef={btnRef} open={open} onClose={() => setOpen(false)}>
20
+ {/* content brings its own padding/width — the shell is bare */}
21
+ </Popover>
22
+ ```
23
+
24
+ ## API
25
+
26
+ | Prop | Type | Notes |
27
+ |---|---|---|
28
+ | `anchorRef` | `RefObject<HTMLElement \| null>` | Trigger element. Re-read on every reposition — swap `.current` to re-point the popover (one instance can serve many triggers). |
29
+ | `open` | `boolean` | Controlled. The popover never closes itself. |
30
+ | `onClose` | `(reason: 'outside' \| 'escape') => void` | Flip `open` false here. The reason lets pickers commit-on-outside vs revert-on-escape. |
31
+ | `placement?` | `'bottom-start' \| 'bottom-end' \| 'top-start' \| 'top-end'` | Default `bottom-start`. Unknown values fall back to the default WITH a `console.warn`. |
32
+ | `gap?` | `number` | px between anchor and popover, default 6. |
33
+ | …rest | `HTMLAttributes<HTMLDivElement>` | Spread onto the shell (`id`, `aria-*`, `style`, …). `role` defaults to `"dialog"`, overridable. `className` merges after `mojo-popover`. |
34
+
35
+ Children are `ReactNode` (never HTML strings — house rule). There is no
36
+ imperative reposition handle: layout effects + capture-phase scroll +
37
+ resize + `ResizeObserver` (on both popover and anchor) cover every
38
+ reposition trigger, including async content growth.
39
+
40
+ The source's `portal: false` inline mode is deliberately not an option:
41
+ in React, "inline" is just rendering the content where you want it (an
42
+ inline calendar renders `<Calendar/>` directly). `Popover` exists only
43
+ for the anchored, floating case.
44
+
45
+ ## The dialog problem (why this is not a z-indexed div)
46
+
47
+ portal-mojo modals are native `<dialog showModal>` — members of the
48
+ browser **top layer**, which paints above the whole document regardless of
49
+ z-index. A body-portaled `z-index: 10000` div (web-mojo's approach)
50
+ renders UNDER an open modal, so a picker inside a modal form would be
51
+ invisible.
52
+
53
+ **Decision:** the popover joins the top layer itself via the HTML Popover
54
+ API — the shell carries `popover="manual"` and is shown with
55
+ `showPopover()`. Top-layer members paint in promotion order, so a popover
56
+ opened after the modal stacks above it. `manual` (never `auto`) opts out
57
+ of UA light-dismiss: close behavior stays this component's own
58
+ outside-mousedown + Escape, identical semantics to the web-mojo source.
59
+
60
+ **Fallback** (browsers without the API — pre-2024): a plain portaled div.
61
+ When the anchor sits inside an open `<dialog>`, the portal target becomes
62
+ that dialog instead of `document.body` — a descendant paints with its
63
+ top-layer host, and `position: fixed` children are not clipped by ancestor
64
+ overflow — so pickers-in-modals still work there too.
65
+
66
+ ## Positioning contract
67
+
68
+ - The shell is `position: fixed` in both paths; all math is
69
+ **viewport-relative** (deliberate translation from the source's
70
+ `absolute` + `scrollX/Y` — the top layer does not scroll with the page,
71
+ so page coordinates would be wrong there).
72
+ - Placement math, then a horizontal viewport clamp (8px inset), then a
73
+ vertical flip: any placement that would overflow the bottom flips above
74
+ the anchor when there is room — the source's exact rule.
75
+ - Reposition triggers: open, placement/gap change, any capture-phase
76
+ scroll, window resize, popover or anchor resize.
77
+
78
+ ## Focus
79
+
80
+ Opening NEVER moves focus — `showPopover()` only focuses `autofocus`
81
+ content, and the shell has none. Typing in an anchored input keeps
82
+ working (the autocomplete precondition). Keeping input focus while
83
+ clicking options (`preventDefault` on option `mousedown`) is the
84
+ consuming control's job, not the shell's.
85
+
86
+ ## Escape layering
87
+
88
+ The Escape listener runs in the **capture phase** and calls
89
+ `preventDefault()` + `stopPropagation()`: with the anchor inside an open
90
+ modal, the first Escape closes only the popover (preventing the dialog's
91
+ cancel), the second closes the modal. (Deviation from source, which
92
+ predates native `<dialog>`: it neither prevented nor stopped — carrying
93
+ that here would close both layers on one keypress.)
94
+
95
+ Outside-mousedown does NOT stop propagation: the click still activates
96
+ whatever was under it (click a second trigger → this popover closes and
97
+ that one opens). Clicks on the anchor are never "outside" — the anchor's
98
+ own click handler owns its toggle. The listener attach is deferred one
99
+ tick so the interaction that opened the popover can't immediately close
100
+ it (source parity).
101
+
102
+ ## Styling
103
+
104
+ `.mojo-popover` lives in `apps/portal/src/theme/popover.css` — tokens
105
+ only, both themes. It neutralizes the UA `[popover]` stylesheet
106
+ (`inset: 0` centering, `margin: auto`, border, padding, Canvas colors)
107
+ and restores the mission-control look (`.filter-menu` family: `--surface`
108
+ bg, `--line` border, radius 10, `--shadow-pop`).
109
+
110
+ The shell is **unpadded and unsized** (`width: max-content`): content
111
+ brings its own padding, width, and scrolling. (The web-mojo source shell
112
+ baked in 20px padding + 260px min-width for its one tenant, the calendar
113
+ — the calendar port carries those itself.)
114
+
115
+ ## Pitfalls
116
+
117
+ - **Don't wrap the shell in `overflow: hidden` styling assumptions** —
118
+ it's in the top layer; nothing ancestral clips or covers it. Anything
119
+ that must paint ABOVE an open popover has to join the top layer later.
120
+ - **Toasts render under open modals** (`.toast-host` is z-index 60, not
121
+ top layer). Don't signal from popover content inside a modal via toast;
122
+ show it in the modal body.
123
+ - **One value pipeline:** the popover is presentation only. Commit
124
+ semantics (`change` on select/Enter/blur, never per keystroke) belong
125
+ to the consuming control.
126
+ - The opening interaction can be `click` or `mousedown`-driven; both are
127
+ safe. But if you open programmatically and simultaneously flip other
128
+ document state, remember listeners attach a tick later.
129
+ - Placement flips only bottom→top (per source). If content can be taller
130
+ than the viewport, cap and scroll it in YOUR content styles.