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,438 @@
1
+ // DetailView + SideNav — the record-viewer layout: flat header (avatar,
2
+ // title, subtitle, chips, active switch, kebab menu, close) over a left rail
3
+ // of grouped sections. Direct port of web-mojo's headerConfig/sections schema
4
+ // (SideNavView.js + ContextMenu.js); the section bodies are plain components
5
+ // built from FlatRow / SecurityItem.
6
+ //
7
+ // C1 semantics (ported from SideNavView.js:115-160,328-438 and
8
+ // ContextMenu.js:102-116):
9
+ // · Sections may carry `permissions` (any-of when array) — resolved through
10
+ // the useCan path (me + active-group member), FAIL-CLOSED: no resolvable
11
+ // grant ⇒ the section is absent from the rail AND unroutable. Divider
12
+ // labels whose following group has no visible section drop with it.
13
+ // · If the ACTIVE section becomes invisible (permission or schema change),
14
+ // selection self-heals to the first visible section — and the heal is
15
+ // STICKY (source `_reconcileActiveSection` rewrote activeSection): the
16
+ // old section regaining visibility does not steal selection back.
17
+ // · Rail badges are a CONTROLLED prop (`badges`), keyed by section key.
18
+ // Deviation from source, by design: web-mojo's imperative
19
+ // `setBadge(key, value)` patched the DOM; here React owns the state —
20
+ // update the prop and the rail follows. Primitive values render as the
21
+ // source's muted pill; ReactNode values render as given.
22
+ // · `contextMenu` renders a header kebab opening a Popover menu. Entries
23
+ // filter by `permissions` (any-of, fail-closed) ∧ `when(menuContext)`;
24
+ // hidden entries drop; separators that end up leading, trailing, or
25
+ // doubled collapse; the menu closes on select.
26
+ // · Lazy mount + keep-alive: a section's content mounts on FIRST
27
+ // activation and stays mounted-but-CSS-hidden afterwards, so section
28
+ // state survives switching — the source's unmount-not-destroy semantic.
29
+ // Sections hidden by permissions unmount entirely (fail-closed beats
30
+ // keep-alive; revoked content must not linger in the DOM).
31
+ // · Invalid config (bad permission shapes, unknown badge keys, malformed
32
+ // menu entries) falls back WITH a console.warn — never silently. Each
33
+ // distinct problem warns once per instance, not once per render.
34
+ //
35
+ // Note: DetailView now resolves permissions, so (like the rest of the
36
+ // toolkit's client-bound components) it must render inside the app's
37
+ // QueryClientProvider. Both are baseline requirements of the package.
38
+ import { useContext, useRef, useState, type ReactNode } from 'react';
39
+ import { initials } from './format';
40
+ import type { Tone } from './format';
41
+ import { hasPermission, useMe } from '../client/me';
42
+ import { GroupContext } from '../client/group-context';
43
+ import { Popover } from './Popover';
44
+
45
+ export interface Chip {
46
+ icon?: string;
47
+ text: string;
48
+ tone?: Tone;
49
+ }
50
+
51
+ export interface Section {
52
+ key: string;
53
+ label: string;
54
+ icon: string;
55
+ render: () => ReactNode;
56
+ /** Permission gate — ANY-of when an array; resolved like `useCan`
57
+ * (signed-in user + active-group member), FAIL-CLOSED: while `me`
58
+ * loads, while anonymous, and on a failed check the section is
59
+ * absent from the rail and cannot be activated. */
60
+ permissions?: string | string[];
61
+ }
62
+
63
+ export type RailEntry = Section | { divider: string };
64
+
65
+ /** Header kebab menu entry. `{ divider: true }` renders a separator. */
66
+ export interface DetailMenuItem<TCtx = unknown> {
67
+ label: string;
68
+ icon?: string;
69
+ /** Same any-of, fail-closed gate as `Section.permissions`. */
70
+ permissions?: string | string[];
71
+ /** Visibility predicate over `menuContext` (the record on display).
72
+ * Re-evaluated every render, so entries track model state live.
73
+ * Receives `undefined` when no `menuContext` prop is given. */
74
+ when?: (ctx: TCtx | undefined) => boolean;
75
+ onSelect: () => void;
76
+ /** Destructive styling (`--bad`), the source's `danger` flag. */
77
+ danger?: boolean;
78
+ }
79
+
80
+ export type DetailMenuEntry<TCtx = unknown> = DetailMenuItem<TCtx> | { divider: true };
81
+
82
+ export interface DetailViewProps<TCtx = unknown> {
83
+ avatarName?: string;
84
+ icon?: string;
85
+ title: string;
86
+ subtitle?: string;
87
+ chips?: Chip[];
88
+ active?: { value: boolean; onChange: (next: boolean) => void };
89
+ sections: RailEntry[];
90
+ initialSection?: string;
91
+ /** Controlled rail badges keyed by section key — numbers, dots, text,
92
+ * any ReactNode. Nullish / `false` / `''` / `true` render nothing
93
+ * (web-mojo `_normalizeBadge` parity). */
94
+ badges?: Record<string, ReactNode>;
95
+ /** Header kebab menu. Renders only when at least one entry survives
96
+ * permissions ∧ when() filtering — an empty menu shows no kebab. */
97
+ contextMenu?: DetailMenuEntry<TCtx>[];
98
+ /** Handed to every `when(ctx)` predicate — by convention the record on
99
+ * display (web-mojo's `this.context`). */
100
+ menuContext?: TCtx;
101
+ onClose: () => void;
102
+ }
103
+
104
+ // ── Config validation ─────────────────────────────────────────────────
105
+ // The do-not-recreate rule: unknown/invalid config falls back WITH a
106
+ // console.warn — never to silence, and (for permissions) never to open.
107
+
108
+ type WarnOnce = (key: string, message: string) => void;
109
+
110
+ /** Warn once per (instance, problem) — loud, but not once per render. */
111
+ function useWarnOnce(): WarnOnce {
112
+ const seen = useRef<Set<string>>(new Set());
113
+ return (key, message) => {
114
+ if (seen.current.has(key)) return;
115
+ seen.current.add(key);
116
+ console.warn(message);
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Validate a `permissions` value into what `hasPermission` accepts.
122
+ * Invalid shapes warn and yield `[]` — any-of over nothing, which DENIES
123
+ * for everyone except superusers/`admin` holders (who pass every gate in
124
+ * this framework). Fail-closed, matching View#checkPermissions.
125
+ */
126
+ function normalizePermissions(raw: string | string[], where: string, warnOnce: WarnOnce): string | string[] {
127
+ if (typeof raw === 'string') return raw;
128
+ if (Array.isArray(raw)) {
129
+ const strings = raw.filter((p): p is string => typeof p === 'string');
130
+ if (strings.length !== raw.length) {
131
+ warnOnce(`perm-shape:${where}`,
132
+ `DetailView: ${where} has non-string entries in its permissions array — ignoring them (fail-closed).`);
133
+ }
134
+ return strings;
135
+ }
136
+ warnOnce(`perm-shape:${where}`,
137
+ `DetailView: ${where} has an invalid permissions value (expected string or string[]) — treating as denied (fail-closed).`);
138
+ return [];
139
+ }
140
+
141
+ /**
142
+ * The `useCan` resolution path (signed-in user + active-group member),
143
+ * returned as a plain checker so a dynamic schema can be gated without
144
+ * running hooks in a loop. Fail-closed by construction: anonymous or
145
+ * still-loading `me` ⇒ every check is false.
146
+ */
147
+ function useCanChecker(): (permission: string | string[]) => boolean {
148
+ const { data } = useMe();
149
+ const member = useContext(GroupContext)?.member ?? null;
150
+ const me = data ?? null;
151
+ return (permission) => hasPermission(me, permission, member);
152
+ }
153
+
154
+ // ── Menu filtering (ContextMenu.visibleItems port) ────────────────────
155
+
156
+ function isMenuDivider<TCtx>(entry: DetailMenuEntry<TCtx>): entry is { divider: true } {
157
+ return 'divider' in entry && Boolean(entry.divider);
158
+ }
159
+
160
+ /**
161
+ * The entries the current user/context may see, in order. Two gates,
162
+ * matching the source: `permissions` (any-of, fail-closed) and
163
+ * `when(menuContext)` — then separators that end up leading, trailing, or
164
+ * doubled are dropped so the menu never shows an orphaned rule.
165
+ */
166
+ function visibleMenuEntries<TCtx>(
167
+ entries: DetailMenuEntry<TCtx>[],
168
+ can: (permission: string | string[]) => boolean,
169
+ ctx: TCtx | undefined,
170
+ warnOnce: WarnOnce,
171
+ ): DetailMenuEntry<TCtx>[] {
172
+ const kept = entries.filter((entry, i) => {
173
+ if (isMenuDivider(entry)) return true;
174
+ const item = entry as DetailMenuItem<TCtx>;
175
+ if (typeof item.label !== 'string' || typeof item.onSelect !== 'function') {
176
+ warnOnce(`menu-entry:${i}`,
177
+ `DetailView: contextMenu entry #${i} needs { label, onSelect } (or { divider: true }) — dropped.`);
178
+ return false;
179
+ }
180
+ if (item.permissions !== undefined
181
+ && !can(normalizePermissions(item.permissions, `contextMenu "${item.label}"`, warnOnce))) {
182
+ return false;
183
+ }
184
+ if (item.when !== undefined) {
185
+ if (typeof item.when !== 'function') {
186
+ warnOnce(`menu-when:${item.label}`,
187
+ `DetailView: contextMenu "${item.label}" has a non-function \`when\` — ignoring the predicate.`);
188
+ } else if (!item.when(ctx)) {
189
+ return false;
190
+ }
191
+ }
192
+ return true;
193
+ });
194
+ return kept.filter((entry, i) => {
195
+ if (!isMenuDivider(entry)) return true;
196
+ if (i === 0) return false;
197
+ const next = kept[i + 1];
198
+ return next !== undefined && !isMenuDivider(next);
199
+ });
200
+ }
201
+
202
+ // ── Rail badges ───────────────────────────────────────────────────────
203
+
204
+ /**
205
+ * Controlled-badge renderer. Primitive values get the source's default
206
+ * muted pill (`_normalizeBadge` normalized numbers/strings to the muted
207
+ * variant); element values render as given, so `<Badge tone>` / dots /
208
+ * custom nodes own their look. Falsy-but-renderable values (`0` excepted)
209
+ * render nothing, like the source.
210
+ */
211
+ function railBadge(value: ReactNode): ReactNode {
212
+ if (value === null || value === undefined || value === false || value === true || value === '') return null;
213
+ const pill = typeof value === 'string' || typeof value === 'number';
214
+ return <span className="rail-badge">{pill ? <span className="chip chip-muted">{value}</span> : value}</span>;
215
+ }
216
+
217
+ // ── The component ─────────────────────────────────────────────────────
218
+
219
+ export function DetailView<TCtx = unknown>({
220
+ avatarName, icon, title, subtitle, chips = [], active,
221
+ sections, initialSection, badges, contextMenu, menuContext, onClose,
222
+ }: DetailViewProps<TCtx>) {
223
+ const can = useCanChecker();
224
+ const warnOnce = useWarnOnce();
225
+
226
+ // One-time schema lint: duplicate keys break activation + keep-alive
227
+ // keying; badge keys must name a section; a never-matching
228
+ // initialSection is a typo (a merely GATED one heals silently — that
229
+ // is normal permission behavior, not misconfig).
230
+ const realAll = sections.filter((s): s is Section => !('divider' in s));
231
+ {
232
+ const seen = new Set<string>();
233
+ for (const s of realAll) {
234
+ if (seen.has(s.key)) warnOnce(`dup:${s.key}`, `DetailView: duplicate section key "${s.key}" — activation and keep-alive key on it.`);
235
+ seen.add(s.key);
236
+ }
237
+ }
238
+ if (badges) {
239
+ for (const key of Object.keys(badges)) {
240
+ if (!realAll.some((s) => s.key === key)) {
241
+ warnOnce(`badge:${key}`, `DetailView: badges key "${key}" matches no section — ignored.`);
242
+ }
243
+ }
244
+ }
245
+ if (initialSection != null && realAll.length > 0 && !realAll.some((s) => s.key === initialSection)) {
246
+ warnOnce(`initial:${initialSection}`,
247
+ `DetailView: initialSection "${initialSection}" matches no section — falling back to the first visible one.`);
248
+ }
249
+
250
+ // Fail-closed visibility, then SideNavView's divider rule: a divider
251
+ // survives only when the next visible entry is a section (trailing and
252
+ // doubled dividers drop; a leading one with content below stays).
253
+ const sectionAllowed = (s: Section): boolean =>
254
+ s.permissions === undefined || can(normalizePermissions(s.permissions, `section "${s.key}"`, warnOnce));
255
+ const allowed = sections.filter((e) => 'divider' in e || sectionAllowed(e));
256
+ const visibleEntries = allowed.filter((e, i) => {
257
+ if (!('divider' in e)) return true;
258
+ const next = allowed[i + 1];
259
+ return next !== undefined && !('divider' in next);
260
+ });
261
+ const visibleSections = visibleEntries.filter((e): e is Section => !('divider' in e));
262
+
263
+ // Active selection + self-heal (source _reconcileActiveSection): when
264
+ // the selected key is missing or gated, fall back to the first visible
265
+ // section — and WRITE the heal back so it sticks (guarded render-time
266
+ // setState; converges in one extra pass).
267
+ const [activeKey, setActiveKey] = useState<string | null>(initialSection ?? null);
268
+ const effectiveKey = activeKey !== null && visibleSections.some((s) => s.key === activeKey)
269
+ ? activeKey
270
+ : visibleSections[0]?.key ?? null;
271
+ if (effectiveKey !== activeKey) setActiveKey(effectiveKey);
272
+
273
+ // Lazy mount + keep-alive: a section joins `activated` on first
274
+ // activation and stays mounted-but-hidden from then on. Only VISIBLE
275
+ // sections mount at all — losing a permission unmounts (and drops the
276
+ // local state of) the revoked section.
277
+ const [activated, setActivated] = useState<ReadonlySet<string>>(() => new Set());
278
+ if (effectiveKey !== null && !activated.has(effectiveKey)) {
279
+ setActivated(new Set(activated).add(effectiveKey));
280
+ }
281
+ const mountedSections = visibleSections.filter((s) => activated.has(s.key));
282
+
283
+ // Header kebab (ContextMenu port) — no surviving entries, no button.
284
+ const kebabRef = useRef<HTMLButtonElement>(null);
285
+ const [menuOpen, setMenuOpen] = useState(false);
286
+ const menuEntries = contextMenu ? visibleMenuEntries(contextMenu, can, menuContext, warnOnce) : [];
287
+ // If every entry filters away WHILE the menu is open (live `when()` /
288
+ // permission flip), the kebab unmounts — reset `open` so the menu can't
289
+ // spring back uninvited when entries return.
290
+ if (menuOpen && menuEntries.length === 0) setMenuOpen(false);
291
+
292
+ return (
293
+ <div className="detail">
294
+ <header className="detail-header">
295
+ <div className="detail-avatar">
296
+ {avatarName ? initials(avatarName) : <i className={`bi ${icon ?? 'bi-file-earmark'}`} />}
297
+ </div>
298
+ <div className="detail-id">
299
+ <h2 className="detail-title">{title}</h2>
300
+ <div className="detail-sub">
301
+ {subtitle}
302
+ {chips.map((chip, i) => (
303
+ <span key={i} className={`chip chip-${chip.tone ?? 'muted'}`}>
304
+ {chip.icon && <i className={`bi ${chip.icon}`} />} {chip.text}
305
+ </span>
306
+ ))}
307
+ </div>
308
+ </div>
309
+ <div className="detail-gutter">
310
+ {active && (
311
+ <label className="switch-inline" title={active.value ? 'Active' : 'Inactive'}>
312
+ <input
313
+ type="checkbox"
314
+ role="switch"
315
+ className="switch"
316
+ checked={active.value}
317
+ onChange={(e) => active.onChange(e.target.checked)}
318
+ />
319
+ </label>
320
+ )}
321
+ {menuEntries.length > 0 && (
322
+ <>
323
+ <button
324
+ ref={kebabRef}
325
+ className="btn-icon dv-kebab"
326
+ aria-haspopup="menu"
327
+ aria-expanded={menuOpen}
328
+ title="More actions"
329
+ aria-label="More actions"
330
+ onClick={() => setMenuOpen((v) => !v)}
331
+ >
332
+ <i className="bi bi-three-dots-vertical" />
333
+ </button>
334
+ <Popover
335
+ anchorRef={kebabRef}
336
+ open={menuOpen}
337
+ onClose={() => setMenuOpen(false)}
338
+ placement="bottom-end"
339
+ role="menu"
340
+ aria-label="More actions"
341
+ >
342
+ <div className="dv-menu">
343
+ {menuEntries.map((entry, i) => isMenuDivider(entry) ? (
344
+ <div key={`d${i}`} className="dv-menu-sep" role="separator" />
345
+ ) : (
346
+ <button
347
+ key={`${entry.label}${i}`}
348
+ className={`dv-menu-item${entry.danger ? ' dv-danger' : ''}`}
349
+ role="menuitem"
350
+ // Source order: run the handler, then close.
351
+ onClick={() => { entry.onSelect(); setMenuOpen(false); }}
352
+ >
353
+ {entry.icon && <i className={`bi ${entry.icon}`} />}
354
+ <span>{entry.label}</span>
355
+ </button>
356
+ ))}
357
+ </div>
358
+ </Popover>
359
+ </>
360
+ )}
361
+ <button className="btn-icon" onClick={onClose} title="Close" aria-label="Close">
362
+ <i className="bi bi-x-lg" />
363
+ </button>
364
+ </div>
365
+ </header>
366
+ <div className="detail-body">
367
+ <nav className="detail-rail" aria-label={`${title} sections`}>
368
+ {visibleEntries.map((entry, i) => 'divider' in entry ? (
369
+ <div key={`d${i}`} className="rail-divider">{entry.divider}</div>
370
+ ) : (
371
+ <button
372
+ key={entry.key}
373
+ className={`rail-item${entry.key === effectiveKey ? ' rail-active' : ''}`}
374
+ onClick={() => setActiveKey(entry.key)}
375
+ >
376
+ <i className={`bi ${entry.icon}`} /> {entry.label}
377
+ {railBadge(badges?.[entry.key])}
378
+ </button>
379
+ ))}
380
+ </nav>
381
+ <div className="detail-content">
382
+ {mountedSections.map((s) => (
383
+ <div key={s.key} className="dv-keep" hidden={s.key !== effectiveKey}>
384
+ {s.render()}
385
+ </div>
386
+ ))}
387
+ </div>
388
+ </div>
389
+ </div>
390
+ );
391
+ }
392
+
393
+ /** Uppercase group heading inside a section (CONTACT / ACCOUNT / …). */
394
+ export function Eyebrow({ children }: { children: ReactNode }) {
395
+ return <div className="eyebrow section-eyebrow">{children}</div>;
396
+ }
397
+
398
+ /** The canonical label / value / action row. */
399
+ export function FlatRow({ label, children, action, actionIcon = 'bi-pencil', actionTitle }: {
400
+ label: string;
401
+ children: ReactNode;
402
+ action?: () => void;
403
+ actionIcon?: string;
404
+ actionTitle?: string;
405
+ }) {
406
+ return (
407
+ <div className="flat-row">
408
+ <div className="flat-label">{label}</div>
409
+ <div className="flat-value">{children}</div>
410
+ <div className="flat-action">
411
+ {action && (
412
+ <button className="btn-icon btn-icon-sm" onClick={action} title={actionTitle ?? `Edit ${label.toLowerCase()}`}>
413
+ <i className={`bi ${actionIcon}`} />
414
+ </button>
415
+ )}
416
+ </div>
417
+ </div>
418
+ );
419
+ }
420
+
421
+ /** Richer icon row: icon / title + description / trailing content. */
422
+ export function SecurityItem({ icon, title, desc, children }: {
423
+ icon: string;
424
+ title: string;
425
+ desc: string;
426
+ children?: ReactNode;
427
+ }) {
428
+ return (
429
+ <div className="sec-item">
430
+ <div className="sec-icon"><i className={`bi ${icon}`} /></div>
431
+ <div className="sec-info">
432
+ <div className="sec-title">{title}</div>
433
+ <div className="sec-desc">{desc}</div>
434
+ </div>
435
+ <div className="sec-right">{children}</div>
436
+ </div>
437
+ );
438
+ }
@@ -0,0 +1,194 @@
1
+ import {
2
+ useCallback, useEffect, useId, useRef, useState,
3
+ type ChangeEvent, type DragEvent, type ReactNode,
4
+ } from 'react';
5
+
6
+ export type FileSelectionRejectionCode = 'type' | 'size' | 'count';
7
+
8
+ export interface FileSelectionRejection {
9
+ filename: string;
10
+ code: FileSelectionRejectionCode;
11
+ message: string;
12
+ }
13
+
14
+ export interface FileSelectionRules {
15
+ /** MIME patterns (`image/*`), exact MIME types, or extensions (`.pdf`). */
16
+ accept?: string | string[];
17
+ maxFileSize?: number;
18
+ /** Maximum files accepted from one gesture. */
19
+ maxFiles?: number;
20
+ }
21
+
22
+ export interface FileSelectionResult {
23
+ accepted: File[];
24
+ rejected: FileSelectionRejection[];
25
+ }
26
+
27
+ const asAcceptList = (accept: FileSelectionRules['accept']): string[] =>
28
+ (Array.isArray(accept) ? accept : String(accept ?? '').split(','))
29
+ .map((value) => value.trim().toLowerCase()).filter(Boolean);
30
+
31
+ function matchesAccept(file: File, patterns: string[]): boolean {
32
+ if (!patterns.length || patterns.includes('*/*')) return true;
33
+ const type = file.type.toLowerCase();
34
+ const name = file.name.toLowerCase();
35
+ return patterns.some((pattern) => {
36
+ if (pattern.startsWith('.')) return name.endsWith(pattern);
37
+ if (pattern.endsWith('/*')) return type.startsWith(`${pattern.slice(0, -1)}`);
38
+ return type !== '' && type === pattern;
39
+ });
40
+ }
41
+
42
+ /** Pure, browser-independent selection validation. `accept` remains UX; the server is authoritative. */
43
+ export function validateFileSelection(files: Iterable<File>, rules: FileSelectionRules = {}): FileSelectionResult {
44
+ const patterns = asAcceptList(rules.accept);
45
+ const limit = Number.isSafeInteger(rules.maxFiles) && Number(rules.maxFiles) > 0
46
+ ? Number(rules.maxFiles) : Number.POSITIVE_INFINITY;
47
+ const maxSize = Number.isFinite(rules.maxFileSize) && Number(rules.maxFileSize) >= 0
48
+ ? Number(rules.maxFileSize) : Number.POSITIVE_INFINITY;
49
+ const accepted: File[] = [];
50
+ const rejected: FileSelectionRejection[] = [];
51
+ for (const file of files) {
52
+ if (accepted.length >= limit) {
53
+ rejected.push({ filename: file.name, code: 'count', message: `Only ${limit} file${limit === 1 ? '' : 's'} can be selected at once.` });
54
+ } else if (!matchesAccept(file, patterns)) {
55
+ rejected.push({ filename: file.name, code: 'type', message: `${file.name} is not an accepted file type.` });
56
+ } else if (file.size > maxSize) {
57
+ rejected.push({ filename: file.name, code: 'size', message: `${file.name} exceeds the maximum file size.` });
58
+ } else {
59
+ accepted.push(file);
60
+ }
61
+ }
62
+ return { accepted, rejected };
63
+ }
64
+
65
+ function isFileDrag(event: globalThis.DragEvent): boolean {
66
+ const types = event.dataTransfer?.types;
67
+ return Boolean(types && Array.from(types).includes('Files'));
68
+ }
69
+
70
+ let navigationGuardUsers = 0;
71
+ const guardFileNavigation = (event: globalThis.DragEvent) => {
72
+ if (isFileDrag(event)) event.preventDefault();
73
+ };
74
+
75
+ function retainFileNavigationGuard(): () => void {
76
+ if (typeof window === 'undefined') return () => {};
77
+ navigationGuardUsers += 1;
78
+ if (navigationGuardUsers === 1) {
79
+ window.addEventListener('dragover', guardFileNavigation);
80
+ window.addEventListener('drop', guardFileNavigation);
81
+ }
82
+ return () => {
83
+ navigationGuardUsers = Math.max(0, navigationGuardUsers - 1);
84
+ if (navigationGuardUsers === 0) {
85
+ window.removeEventListener('dragover', guardFileNavigation);
86
+ window.removeEventListener('drop', guardFileNavigation);
87
+ }
88
+ };
89
+ }
90
+
91
+ export interface UseFileDropOptions extends FileSelectionRules {
92
+ disabled?: boolean;
93
+ onSelection: (result: FileSelectionResult) => void;
94
+ }
95
+
96
+ /** Accessible drop behavior. Consumers spread the returned handlers on their actual control. */
97
+ export function useFileDrop(options: UseFileDropOptions) {
98
+ const { disabled = false, onSelection } = options;
99
+ const [isDragActive, setDragActive] = useState(false);
100
+ const depth = useRef(0);
101
+ const latest = useRef(options);
102
+ latest.current = options;
103
+
104
+ useEffect(() => retainFileNavigationGuard(), []);
105
+
106
+ const onDragEnter = useCallback((event: DragEvent<HTMLElement>) => {
107
+ if (disabled || !isFileDrag(event.nativeEvent)) return;
108
+ event.preventDefault();
109
+ depth.current += 1;
110
+ setDragActive(true);
111
+ }, [disabled]);
112
+ const onDragOver = useCallback((event: DragEvent<HTMLElement>) => {
113
+ if (disabled || !isFileDrag(event.nativeEvent)) return;
114
+ event.preventDefault();
115
+ event.dataTransfer.dropEffect = 'copy';
116
+ }, [disabled]);
117
+ const onDragLeave = useCallback((event: DragEvent<HTMLElement>) => {
118
+ if (disabled || !isFileDrag(event.nativeEvent)) return;
119
+ event.preventDefault();
120
+ depth.current = Math.max(0, depth.current - 1);
121
+ if (depth.current === 0) setDragActive(false);
122
+ }, [disabled]);
123
+ const onDrop = useCallback((event: DragEvent<HTMLElement>) => {
124
+ if (disabled || !isFileDrag(event.nativeEvent)) return;
125
+ event.preventDefault();
126
+ depth.current = 0;
127
+ setDragActive(false);
128
+ onSelection(validateFileSelection(Array.from(event.dataTransfer.files), latest.current));
129
+ }, [disabled, onSelection]);
130
+
131
+ return { isDragActive, dropProps: { onDragEnter, onDragOver, onDragLeave, onDrop } };
132
+ }
133
+
134
+ interface PickerCommonProps extends FileSelectionRules {
135
+ disabled?: boolean;
136
+ multiple?: boolean;
137
+ onSelection: (result: FileSelectionResult) => void;
138
+ }
139
+
140
+ function acceptAttribute(accept: FileSelectionRules['accept']): string | undefined {
141
+ const values = asAcceptList(accept);
142
+ return values.length ? values.join(',') : undefined;
143
+ }
144
+
145
+ export interface FilePickerProps extends PickerCommonProps {
146
+ children?: ReactNode;
147
+ className?: string;
148
+ }
149
+
150
+ export function FilePicker({ children = 'Choose files', className, disabled, multiple = true, onSelection, ...rules }: FilePickerProps) {
151
+ const input = useRef<HTMLInputElement>(null);
152
+ const id = `file-picker-${useId().replace(/:/g, '')}`;
153
+ const changed = (event: ChangeEvent<HTMLInputElement>) => {
154
+ const files = Array.from(event.currentTarget.files ?? []);
155
+ event.currentTarget.value = '';
156
+ onSelection(validateFileSelection(files, { ...rules, maxFiles: rules.maxFiles ?? (multiple ? undefined : 1) }));
157
+ };
158
+ return (
159
+ <>
160
+ <input ref={input} id={id} className="file-input-hidden" type="file" tabIndex={-1} aria-hidden="true"
161
+ accept={acceptAttribute(rules.accept)} multiple={multiple} disabled={disabled} onChange={changed} />
162
+ <button type="button" className={className ? `btn file-picker ${className}` : 'btn file-picker'} disabled={disabled}
163
+ onClick={() => input.current?.click()}>{children}</button>
164
+ </>
165
+ );
166
+ }
167
+
168
+ export interface FileDropZoneProps extends PickerCommonProps {
169
+ children?: ReactNode | ((state: { isDragActive: boolean }) => ReactNode);
170
+ className?: string;
171
+ label?: string;
172
+ }
173
+
174
+ export function FileDropZone({ children, className, label = 'Choose files or drop them here', disabled, multiple = true, onSelection, ...rules }: FileDropZoneProps) {
175
+ const input = useRef<HTMLInputElement>(null);
176
+ const { isDragActive, dropProps } = useFileDrop({ ...rules, disabled, maxFiles: rules.maxFiles ?? (multiple ? undefined : 1), onSelection });
177
+ const changed = (event: ChangeEvent<HTMLInputElement>) => {
178
+ const files = Array.from(event.currentTarget.files ?? []);
179
+ event.currentTarget.value = '';
180
+ onSelection(validateFileSelection(files, { ...rules, maxFiles: rules.maxFiles ?? (multiple ? undefined : 1) }));
181
+ };
182
+ return (
183
+ <div className="file-drop-zone-wrap">
184
+ <input ref={input} className="file-input-hidden" type="file" tabIndex={-1} aria-hidden="true"
185
+ accept={acceptAttribute(rules.accept)} multiple={multiple} disabled={disabled} onChange={changed} />
186
+ <button type="button" className={`file-drop-zone${isDragActive ? ' is-drag-active' : ''}${className ? ` ${className}` : ''}`}
187
+ disabled={disabled} aria-label={label} onClick={() => input.current?.click()} {...dropProps}>
188
+ {typeof children === 'function' ? children({ isDragActive }) : children ?? (
189
+ <span className="file-drop-prompt"><i className="bi bi-cloud-arrow-up" /> <span>{isDragActive ? 'Drop files to add them' : label}</span></span>
190
+ )}
191
+ </button>
192
+ </div>
193
+ );
194
+ }