@cosmicdrift/kumiko-renderer 0.373.0 → 0.377.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 (466) hide show
  1. package/README.md +2 -2
  2. package/dist/app/action-form-support.d.ts +58 -0
  3. package/dist/app/action-form-support.js +96 -0
  4. package/dist/app/client-actions.d.ts +46 -0
  5. package/dist/app/client-actions.js +60 -0
  6. package/dist/app/column-renderers.js +1 -1
  7. package/dist/app/config-edit-shim.js +1 -1
  8. package/dist/app/content-preview.js +1 -6
  9. package/dist/app/custom-screens.js +1 -1
  10. package/dist/app/dashboard-body.d.ts +1 -1
  11. package/dist/app/entity-list-expanded-row.d.ts +1 -1
  12. package/dist/app/entity-list-expanded-row.js +4 -0
  13. package/dist/app/form-schema.d.ts +1 -1
  14. package/dist/app/form-schema.js +2 -2
  15. package/dist/app/header-actions-bar.d.ts +19 -0
  16. package/dist/app/header-actions-bar.js +30 -0
  17. package/dist/app/kumiko-screen.d.ts +1 -1
  18. package/dist/app/kumiko-screen.js +134 -66
  19. package/dist/app/list-empty-content.d.ts +2 -2
  20. package/dist/app/list-empty-content.js +3 -2
  21. package/dist/app/list-facets.d.ts +1 -1
  22. package/dist/app/nav.js +2 -2
  23. package/dist/app/query-state-banners.d.ts +1 -1
  24. package/dist/app/reference-link.d.ts +1 -1
  25. package/dist/app/row-actions.d.ts +10 -2
  26. package/dist/app/row-actions.js +85 -6
  27. package/dist/app/secret-mint-body.d.ts +1 -1
  28. package/dist/app/secrets-edit-body.d.ts +1 -1
  29. package/dist/app/use-record-actions.d.ts +12 -0
  30. package/dist/app/use-record-actions.js +30 -0
  31. package/dist/app/write-failed-error.d.ts +1 -1
  32. package/dist/components/availability-options-select.d.ts +1 -1
  33. package/dist/components/embedded-list-field.d.ts +1 -1
  34. package/dist/components/embedded-list-field.js +1 -1
  35. package/dist/components/file-list-field.d.ts +1 -1
  36. package/dist/components/grid-cell-for-field.d.ts +4 -2
  37. package/dist/components/grid-cell-for-field.js +2 -2
  38. package/dist/components/multi-select-checkboxes.d.ts +1 -1
  39. package/dist/components/multi-select-checkboxes.js +1 -1
  40. package/dist/components/query-options-select.d.ts +1 -1
  41. package/dist/components/related-list-section.d.ts +1 -1
  42. package/dist/components/related-list-section.js +16 -3
  43. package/dist/components/render-edit-logic.d.ts +1 -1
  44. package/dist/components/render-edit-types.d.ts +2 -2
  45. package/dist/components/render-edit.d.ts +1 -1
  46. package/dist/components/render-edit.js +5 -3
  47. package/dist/components/render-field.d.ts +5 -2
  48. package/dist/components/render-field.js +103 -9
  49. package/dist/components/render-list.d.ts +1 -1
  50. package/dist/components/render-list.js +20 -4
  51. package/dist/components/write-form-section.d.ts +1 -1
  52. package/dist/components/write-form-section.js +1 -1
  53. package/dist/context/dispatcher-context.d.ts +1 -1
  54. package/dist/format-when.js +1 -1
  55. package/dist/headless/apex/css.d.ts +3 -0
  56. package/dist/headless/apex/css.js +298 -0
  57. package/dist/headless/apex/index.d.ts +236 -0
  58. package/dist/headless/apex/index.js +377 -0
  59. package/dist/headless/apex/lightbox.d.ts +4 -0
  60. package/dist/headless/apex/lightbox.js +81 -0
  61. package/dist/headless/contracts/asset.d.ts +15 -0
  62. package/dist/headless/contracts/asset.js +17 -0
  63. package/dist/headless/contracts/index.d.ts +3 -0
  64. package/dist/headless/contracts/index.js +1 -0
  65. package/dist/headless/contracts/locale.d.ts +7 -0
  66. package/dist/headless/contracts/locale.js +11 -0
  67. package/dist/headless/contracts/primitives.d.ts +93 -0
  68. package/dist/headless/contracts/primitives.js +21 -0
  69. package/dist/headless/dispatcher/index.d.ts +2 -0
  70. package/dist/headless/dispatcher/index.js +1 -0
  71. package/dist/headless/dispatcher/types.d.ts +86 -0
  72. package/dist/headless/dispatcher/types.js +34 -0
  73. package/dist/headless/form/form-controller.d.ts +2 -0
  74. package/dist/headless/form/form-controller.js +409 -0
  75. package/dist/headless/form/index.d.ts +3 -0
  76. package/dist/headless/form/index.js +2 -0
  77. package/dist/headless/form/types.d.ts +76 -0
  78. package/dist/headless/form/types.js +1 -0
  79. package/dist/headless/form/zod-bridge.d.ts +10 -0
  80. package/dist/headless/form/zod-bridge.js +103 -0
  81. package/dist/headless/format/document-values.d.ts +12 -0
  82. package/dist/headless/format/document-values.js +70 -0
  83. package/dist/headless/format/escape.d.ts +5 -0
  84. package/dist/headless/format/escape.js +75 -0
  85. package/dist/headless/format/html-template.d.ts +9 -0
  86. package/dist/headless/format/html-template.js +39 -0
  87. package/dist/headless/format/index.d.ts +12 -0
  88. package/dist/headless/format/index.js +237 -0
  89. package/dist/headless/format/money.d.ts +3 -0
  90. package/dist/headless/format/money.js +39 -0
  91. package/dist/headless/index.d.ts +14 -0
  92. package/dist/headless/index.js +9 -0
  93. package/dist/headless/locale-routing/index.d.ts +41 -0
  94. package/dist/headless/locale-routing/index.js +145 -0
  95. package/dist/headless/nav/index.d.ts +2 -0
  96. package/dist/headless/nav/index.js +1 -0
  97. package/dist/headless/nav/resolve.d.ts +2 -0
  98. package/dist/headless/nav/resolve.js +71 -0
  99. package/dist/headless/nav/types.d.ts +26 -0
  100. package/dist/headless/nav/types.js +1 -0
  101. package/dist/headless/store/create-store.d.ts +2 -0
  102. package/dist/headless/store/create-store.js +43 -0
  103. package/dist/headless/store/equality.d.ts +1 -0
  104. package/dist/headless/store/equality.js +34 -0
  105. package/dist/headless/store/index.d.ts +3 -0
  106. package/dist/headless/store/index.js +2 -0
  107. package/dist/headless/store/types.d.ts +7 -0
  108. package/dist/headless/store/types.js +10 -0
  109. package/dist/headless/view-model/edit.d.ts +18 -0
  110. package/dist/headless/view-model/edit.js +501 -0
  111. package/dist/headless/view-model/embedded-list.d.ts +27 -0
  112. package/dist/headless/view-model/embedded-list.js +63 -0
  113. package/dist/headless/view-model/index.d.ts +7 -0
  114. package/dist/headless/view-model/index.js +3 -0
  115. package/dist/headless/view-model/list-file-display.d.ts +3 -0
  116. package/dist/headless/view-model/list-file-display.js +27 -0
  117. package/dist/headless/view-model/list.d.ts +16 -0
  118. package/dist/headless/view-model/list.js +229 -0
  119. package/dist/headless/view-model/types.d.ts +382 -0
  120. package/dist/headless/view-model/types.js +1 -0
  121. package/dist/hooks/use-ai-text.d.ts +1 -1
  122. package/dist/hooks/use-form.d.ts +1 -1
  123. package/dist/hooks/use-form.js +1 -1
  124. package/dist/hooks/use-mutation.d.ts +1 -1
  125. package/dist/hooks/use-query.d.ts +1 -1
  126. package/dist/hooks/use-store.d.ts +1 -1
  127. package/dist/hooks/use-store.js +7 -7
  128. package/dist/hooks/use-stream-handler.d.ts +1 -1
  129. package/dist/i18n-defaults.js +7 -0
  130. package/dist/i18n.d.ts +1 -1
  131. package/dist/i18n.js +1 -1
  132. package/dist/index.d.ts +5 -1
  133. package/dist/index.js +7 -4
  134. package/dist/live/csrf.d.ts +2 -0
  135. package/dist/live/csrf.js +60 -0
  136. package/dist/live/dispatcher-live.d.ts +7 -0
  137. package/dist/live/dispatcher-live.js +286 -0
  138. package/dist/live/envelopes.d.ts +101 -0
  139. package/dist/live/envelopes.js +35 -0
  140. package/dist/live/error-mapping.d.ts +16 -0
  141. package/dist/live/error-mapping.js +82 -0
  142. package/dist/live/index.d.ts +7 -0
  143. package/dist/live/index.js +5 -0
  144. package/dist/live/locale.d.ts +2 -0
  145. package/dist/live/locale.js +25 -0
  146. package/dist/live/sse-stream.d.ts +14 -0
  147. package/dist/live/sse-stream.js +128 -0
  148. package/dist/primitives.d.ts +53 -36
  149. package/dist/primitives.js +1 -1
  150. package/dist/session/session-ended.d.ts +1 -1
  151. package/dist/tokens.d.ts +3 -4
  152. package/dist/tokens.js +4 -5
  153. package/dist/web/app/app-schema-boot.d.ts +19 -0
  154. package/dist/web/app/app-schema-boot.js +124 -0
  155. package/dist/web/app/browser-locale.d.ts +22 -0
  156. package/dist/web/app/browser-locale.js +78 -0
  157. package/dist/web/app/client-plugin.d.ts +106 -0
  158. package/dist/web/app/client-plugin.js +12 -0
  159. package/dist/web/app/create-app.d.ts +104 -0
  160. package/dist/web/app/create-app.js +498 -0
  161. package/dist/web/app/create-public-surface.d.ts +36 -0
  162. package/dist/web/app/create-public-surface.js +40 -0
  163. package/dist/web/app/dashboard-body.d.ts +3 -0
  164. package/dist/web/app/dashboard-body.js +865 -0
  165. package/dist/web/app/dashboard-refresh.d.ts +6 -0
  166. package/dist/web/app/dashboard-refresh.js +15 -0
  167. package/dist/web/app/document-lang-sync.d.ts +4 -0
  168. package/dist/web/app/document-lang-sync.js +27 -0
  169. package/dist/web/app/draft-storage.d.ts +11 -0
  170. package/dist/web/app/draft-storage.js +53 -0
  171. package/dist/web/app/nav-providers-context.d.ts +13 -0
  172. package/dist/web/app/nav-providers-context.js +18 -0
  173. package/dist/web/app/nav.d.ts +40 -0
  174. package/dist/web/app/nav.js +231 -0
  175. package/dist/web/app/plain-content-editor.d.ts +3 -0
  176. package/dist/web/app/plain-content-editor.js +30 -0
  177. package/dist/web/app/relative-time.d.ts +10 -0
  178. package/dist/web/app/relative-time.js +56 -0
  179. package/dist/web/app/resolvers-context.d.ts +12 -0
  180. package/dist/web/app/resolvers-context.js +10 -0
  181. package/dist/web/app/rich-content-editor.d.ts +3 -0
  182. package/dist/web/app/rich-content-editor.js +15 -0
  183. package/dist/web/app/root-error-fallback.d.ts +2 -0
  184. package/dist/web/app/root-error-fallback.js +21 -0
  185. package/dist/web/app/screen-error-fallback.d.ts +6 -0
  186. package/dist/web/app/screen-error-fallback.js +17 -0
  187. package/dist/web/app/tiptap-editor.d.ts +3 -0
  188. package/dist/web/app/tiptap-editor.js +120 -0
  189. package/dist/web/app/toast-action-notifier.d.ts +5 -0
  190. package/dist/web/app/toast-action-notifier.js +17 -0
  191. package/dist/web/components/config-cascade.d.ts +13 -0
  192. package/dist/web/components/config-cascade.js +155 -0
  193. package/dist/web/components/config-source-badge.d.ts +6 -0
  194. package/dist/web/components/config-source-badge.js +55 -0
  195. package/dist/web/default-color-scheme.d.ts +2 -0
  196. package/dist/web/default-color-scheme.js +3 -0
  197. package/dist/web/icons.d.ts +94 -0
  198. package/dist/web/icons.js +100 -0
  199. package/dist/web/index.d.ts +78 -0
  200. package/dist/web/index.js +53 -0
  201. package/dist/web/layout/app-layout.d.ts +21 -0
  202. package/dist/web/layout/app-layout.js +8 -0
  203. package/dist/web/layout/avatar.d.ts +12 -0
  204. package/dist/web/layout/avatar.js +63 -0
  205. package/dist/web/layout/default-app-shell.d.ts +41 -0
  206. package/dist/web/layout/default-app-shell.js +16 -0
  207. package/dist/web/layout/editor-panel.d.ts +10 -0
  208. package/dist/web/layout/editor-panel.js +29 -0
  209. package/dist/web/layout/fill-classes.d.ts +9 -0
  210. package/dist/web/layout/fill-classes.js +15 -0
  211. package/dist/web/layout/filter-app-schema-navs.d.ts +7 -0
  212. package/dist/web/layout/filter-app-schema-navs.js +68 -0
  213. package/dist/web/layout/header-action-group.d.ts +6 -0
  214. package/dist/web/layout/header-action-group.js +8 -0
  215. package/dist/web/layout/header-overflow-menu.d.ts +4 -0
  216. package/dist/web/layout/header-overflow-menu.js +8 -0
  217. package/dist/web/layout/language-menu-items.d.ts +8 -0
  218. package/dist/web/layout/language-menu-items.js +16 -0
  219. package/dist/web/layout/language-switcher.d.ts +25 -0
  220. package/dist/web/layout/language-switcher.js +46 -0
  221. package/dist/web/layout/nav-tree.d.ts +19 -0
  222. package/dist/web/layout/nav-tree.js +536 -0
  223. package/dist/web/layout/page-header-slot.d.ts +25 -0
  224. package/dist/web/layout/page-header-slot.js +50 -0
  225. package/dist/web/layout/profile-menu.d.ts +13 -0
  226. package/dist/web/layout/profile-menu.js +6 -0
  227. package/dist/web/layout/shell-breadcrumb.d.ts +11 -0
  228. package/dist/web/layout/shell-breadcrumb.js +34 -0
  229. package/dist/web/layout/shell-header.d.ts +12 -0
  230. package/dist/web/layout/shell-header.js +159 -0
  231. package/dist/web/layout/sidebar-brand.d.ts +15 -0
  232. package/dist/web/layout/sidebar-brand.js +9 -0
  233. package/dist/web/layout/sidebar-panel.d.ts +41 -0
  234. package/dist/web/layout/sidebar-panel.js +107 -0
  235. package/dist/web/layout/sidebar-user.d.ts +8 -0
  236. package/dist/web/layout/sidebar-user.js +17 -0
  237. package/dist/web/layout/sidebar.d.ts +17 -0
  238. package/dist/web/layout/sidebar.js +11 -0
  239. package/dist/web/layout/target-resolver-stub.d.ts +13 -0
  240. package/dist/web/layout/target-resolver-stub.js +61 -0
  241. package/dist/web/layout/target-url.d.ts +12 -0
  242. package/dist/web/layout/target-url.js +77 -0
  243. package/dist/web/layout/theme-label.d.ts +15 -0
  244. package/dist/web/layout/theme-label.js +35 -0
  245. package/dist/web/layout/theme-menu-item.d.ts +12 -0
  246. package/dist/web/layout/theme-menu-item.js +16 -0
  247. package/dist/web/layout/theme-toggle.d.ts +17 -0
  248. package/dist/web/layout/theme-toggle.js +22 -0
  249. package/dist/web/layout/topbar.d.ts +8 -0
  250. package/dist/web/layout/topbar.js +4 -0
  251. package/dist/web/layout/workspace-shell.d.ts +63 -0
  252. package/dist/web/layout/workspace-shell.js +220 -0
  253. package/dist/web/layout/workspace-switcher.d.ts +15 -0
  254. package/dist/web/layout/workspace-switcher.js +36 -0
  255. package/dist/web/lib/accept-attr.d.ts +1 -0
  256. package/dist/web/lib/accept-attr.js +6 -0
  257. package/dist/web/lib/clamp.d.ts +1 -0
  258. package/dist/web/lib/clamp.js +3 -0
  259. package/dist/web/lib/cn.d.ts +6 -0
  260. package/dist/web/lib/cn.js +9 -0
  261. package/dist/web/lib/download.d.ts +2 -0
  262. package/dist/web/lib/download.js +24 -0
  263. package/dist/web/lib/file-ref-download.d.ts +15 -0
  264. package/dist/web/lib/file-ref-download.js +62 -0
  265. package/dist/web/lib/file-upload-form-data.d.ts +7 -0
  266. package/dist/web/lib/file-upload-form-data.js +11 -0
  267. package/dist/web/lib/resize-image.d.ts +1 -0
  268. package/dist/web/lib/resize-image.js +66 -0
  269. package/dist/web/lib/upload-file-ref.d.ts +9 -0
  270. package/dist/web/lib/upload-file-ref.js +29 -0
  271. package/dist/web/primitives/action-menu.d.ts +34 -0
  272. package/dist/web/primitives/action-menu.js +30 -0
  273. package/dist/web/primitives/calendar-popover.d.ts +13 -0
  274. package/dist/web/primitives/calendar-popover.js +76 -0
  275. package/dist/web/primitives/combobox.d.ts +56 -0
  276. package/dist/web/primitives/combobox.js +127 -0
  277. package/dist/web/primitives/date-field.d.ts +18 -0
  278. package/dist/web/primitives/date-field.js +74 -0
  279. package/dist/web/primitives/date-input.d.ts +1 -0
  280. package/dist/web/primitives/date-input.js +5 -0
  281. package/dist/web/primitives/date-parse.d.ts +11 -0
  282. package/dist/web/primitives/date-parse.js +159 -0
  283. package/dist/web/primitives/dialog.d.ts +3 -0
  284. package/dist/web/primitives/dialog.js +42 -0
  285. package/dist/web/primitives/drawer.d.ts +3 -0
  286. package/dist/web/primitives/drawer.js +17 -0
  287. package/dist/web/primitives/dropdown-menu.d.ts +15 -0
  288. package/dist/web/primitives/dropdown-menu.js +56 -0
  289. package/dist/web/primitives/embedded-list-input.d.ts +9 -0
  290. package/dist/web/primitives/embedded-list-input.js +382 -0
  291. package/dist/web/primitives/file-thumbnail.d.ts +25 -0
  292. package/dist/web/primitives/file-thumbnail.js +90 -0
  293. package/dist/web/primitives/file-upload.d.ts +21 -0
  294. package/dist/web/primitives/file-upload.js +56 -0
  295. package/dist/web/primitives/index.d.ts +34 -0
  296. package/dist/web/primitives/index.js +2021 -0
  297. package/dist/web/primitives/json-view.d.ts +7 -0
  298. package/dist/web/primitives/json-view.js +84 -0
  299. package/dist/web/primitives/layout.d.ts +27 -0
  300. package/dist/web/primitives/layout.js +30 -0
  301. package/dist/web/primitives/lightbox.d.ts +3 -0
  302. package/dist/web/primitives/lightbox.js +36 -0
  303. package/dist/web/primitives/locale-number.d.ts +2 -0
  304. package/dist/web/primitives/locale-number.js +54 -0
  305. package/dist/web/primitives/located-timestamp-input.d.ts +22 -0
  306. package/dist/web/primitives/located-timestamp-input.js +22 -0
  307. package/dist/web/primitives/metric.d.ts +5 -0
  308. package/dist/web/primitives/metric.js +18 -0
  309. package/dist/web/primitives/modal-shell.d.ts +17 -0
  310. package/dist/web/primitives/modal-shell.js +9 -0
  311. package/dist/web/primitives/modal.d.ts +3 -0
  312. package/dist/web/primitives/modal.js +13 -0
  313. package/dist/web/primitives/money-input.d.ts +18 -0
  314. package/dist/web/primitives/money-input.js +122 -0
  315. package/dist/web/primitives/narrow-pinned-footer.d.ts +12 -0
  316. package/dist/web/primitives/narrow-pinned-footer.js +153 -0
  317. package/dist/web/primitives/number-input.d.ts +18 -0
  318. package/dist/web/primitives/number-input.js +53 -0
  319. package/dist/web/primitives/option-groups.d.ts +11 -0
  320. package/dist/web/primitives/option-groups.js +27 -0
  321. package/dist/web/primitives/page-header.d.ts +3 -0
  322. package/dist/web/primitives/page-header.js +52 -0
  323. package/dist/web/primitives/promo-panel.d.ts +3 -0
  324. package/dist/web/primitives/promo-panel.js +11 -0
  325. package/dist/web/primitives/segmented-select.d.ts +21 -0
  326. package/dist/web/primitives/segmented-select.js +62 -0
  327. package/dist/web/primitives/share-actions.d.ts +4 -0
  328. package/dist/web/primitives/share-actions.js +56 -0
  329. package/dist/web/primitives/status-badge.d.ts +3 -0
  330. package/dist/web/primitives/status-badge.js +5 -0
  331. package/dist/web/primitives/sticky-action-bar.d.ts +5 -0
  332. package/dist/web/primitives/sticky-action-bar.js +15 -0
  333. package/dist/web/primitives/tabs.d.ts +3 -0
  334. package/dist/web/primitives/tabs.js +107 -0
  335. package/dist/web/primitives/timestamp-input.d.ts +27 -0
  336. package/dist/web/primitives/timestamp-input.js +87 -0
  337. package/dist/web/primitives/toast.d.ts +19 -0
  338. package/dist/web/primitives/toast.js +63 -0
  339. package/dist/web/primitives/tz-input.d.ts +11 -0
  340. package/dist/web/primitives/tz-input.js +8 -0
  341. package/dist/web/primitives/tz-options.d.ts +4 -0
  342. package/dist/web/primitives/tz-options.js +17 -0
  343. package/dist/web/primitives/use-keyboard-inset.d.ts +1 -0
  344. package/dist/web/primitives/use-keyboard-inset.js +28 -0
  345. package/dist/web/primitives/use-narrow-viewport.d.ts +4 -0
  346. package/dist/web/primitives/use-narrow-viewport.js +46 -0
  347. package/dist/web/react-css-custom-properties.d.ts +6 -0
  348. package/dist/web/react-css-custom-properties.js +1 -0
  349. package/dist/web/sse/live-events.d.ts +15 -0
  350. package/dist/web/sse/live-events.js +148 -0
  351. package/dist/web/theme-plugin.d.ts +41 -0
  352. package/dist/web/theme-plugin.js +123 -0
  353. package/dist/web/tokens.d.ts +24 -0
  354. package/dist/web/tokens.js +203 -0
  355. package/dist/web/ui/avatar.d.ts +11 -0
  356. package/dist/web/ui/avatar.js +25 -0
  357. package/dist/web/ui/badge.d.ts +9 -0
  358. package/dist/web/ui/badge.js +26 -0
  359. package/dist/web/ui/breadcrumb.d.ts +11 -0
  360. package/dist/web/ui/breadcrumb.js +29 -0
  361. package/dist/web/ui/button.d.ts +10 -0
  362. package/dist/web/ui/button.js +37 -0
  363. package/dist/web/ui/checkbox.d.ts +4 -0
  364. package/dist/web/ui/checkbox.js +11 -0
  365. package/dist/web/ui/input.d.ts +3 -0
  366. package/dist/web/ui/input.js +8 -0
  367. package/dist/web/ui/label.d.ts +4 -0
  368. package/dist/web/ui/label.js +10 -0
  369. package/dist/web/ui/resizable.d.ts +7 -0
  370. package/dist/web/ui/resizable.js +16 -0
  371. package/dist/web/ui/separator.d.ts +4 -0
  372. package/dist/web/ui/separator.js +10 -0
  373. package/dist/web/ui/sheet.d.ts +14 -0
  374. package/dist/web/ui/sheet.js +42 -0
  375. package/dist/web/ui/sidebar.d.ts +73 -0
  376. package/dist/web/ui/sidebar.js +211 -0
  377. package/dist/web/ui/skeleton.d.ts +2 -0
  378. package/dist/web/ui/skeleton.js +7 -0
  379. package/dist/web/ui/switch.d.ts +6 -0
  380. package/dist/web/ui/switch.js +10 -0
  381. package/dist/web/ui/table.d.ts +10 -0
  382. package/dist/web/ui/table.js +30 -0
  383. package/dist/web/ui/tabs.d.ts +11 -0
  384. package/dist/web/ui/tabs.js +31 -0
  385. package/dist/web/ui/textarea.d.ts +3 -0
  386. package/dist/web/ui/textarea.js +8 -0
  387. package/dist/web/ui/tooltip.d.ts +7 -0
  388. package/dist/web/ui/tooltip.js +19 -0
  389. package/dist/web/ui/use-mobile.d.ts +1 -0
  390. package/dist/web/ui/use-mobile.js +16 -0
  391. package/dist/web/version/update-checker.d.ts +9 -0
  392. package/dist/web/version/update-checker.js +88 -0
  393. package/dist/web/widgets/ai-text-field.d.ts +36 -0
  394. package/dist/web/widgets/ai-text-field.js +137 -0
  395. package/dist/web/widgets/chart-window.d.ts +58 -0
  396. package/dist/web/widgets/chart-window.js +190 -0
  397. package/dist/web/widgets/charts.d.ts +208 -0
  398. package/dist/web/widgets/charts.js +685 -0
  399. package/dist/web/widgets/collapsible-section.d.ts +10 -0
  400. package/dist/web/widgets/collapsible-section.js +17 -0
  401. package/dist/web/widgets/dashboard-list.d.ts +21 -0
  402. package/dist/web/widgets/dashboard-list.js +45 -0
  403. package/dist/web/widgets/detail-list.d.ts +15 -0
  404. package/dist/web/widgets/detail-list.js +8 -0
  405. package/dist/web/widgets/drawer.d.ts +65 -0
  406. package/dist/web/widgets/drawer.js +142 -0
  407. package/dist/web/widgets/feed-list.d.ts +13 -0
  408. package/dist/web/widgets/feed-list.js +9 -0
  409. package/dist/web/widgets/floating-panel.d.ts +30 -0
  410. package/dist/web/widgets/floating-panel.js +242 -0
  411. package/dist/web/widgets/form-fields.d.ts +93 -0
  412. package/dist/web/widgets/form-fields.js +50 -0
  413. package/dist/web/widgets/index.d.ts +27 -0
  414. package/dist/web/widgets/index.js +30 -0
  415. package/dist/web/widgets/infinity-list.d.ts +46 -0
  416. package/dist/web/widgets/infinity-list.js +211 -0
  417. package/dist/web/widgets/mode-switch.d.ts +21 -0
  418. package/dist/web/widgets/mode-switch.js +25 -0
  419. package/dist/web/widgets/photo-slots.d.ts +29 -0
  420. package/dist/web/widgets/photo-slots.js +52 -0
  421. package/dist/web/widgets/plan-card.d.ts +36 -0
  422. package/dist/web/widgets/plan-card.js +13 -0
  423. package/dist/web/widgets/progress-bar.d.ts +19 -0
  424. package/dist/web/widgets/progress-bar.js +24 -0
  425. package/dist/web/widgets/progress-list.d.ts +19 -0
  426. package/dist/web/widgets/progress-list.js +11 -0
  427. package/dist/web/widgets/public-shell.d.ts +22 -0
  428. package/dist/web/widgets/public-shell.js +15 -0
  429. package/dist/web/widgets/query-table.d.ts +29 -0
  430. package/dist/web/widgets/query-table.js +30 -0
  431. package/dist/web/widgets/result-panel.d.ts +69 -0
  432. package/dist/web/widgets/result-panel.js +30 -0
  433. package/dist/web/widgets/section-card.d.ts +12 -0
  434. package/dist/web/widgets/section-card.js +9 -0
  435. package/dist/web/widgets/sheet-parts.d.ts +9 -0
  436. package/dist/web/widgets/sheet-parts.js +11 -0
  437. package/dist/web/widgets/side-by-side-table.d.ts +31 -0
  438. package/dist/web/widgets/side-by-side-table.js +11 -0
  439. package/dist/web/widgets/stat.d.ts +54 -0
  440. package/dist/web/widgets/stat.js +65 -0
  441. package/dist/web/widgets/states.d.ts +23 -0
  442. package/dist/web/widgets/states.js +20 -0
  443. package/dist/web/widgets/status-badge.d.ts +15 -0
  444. package/dist/web/widgets/status-badge.js +24 -0
  445. package/dist/web/widgets/step-bar.d.ts +35 -0
  446. package/dist/web/widgets/step-bar.js +60 -0
  447. package/dist/web/widgets/upload-zone.d.ts +24 -0
  448. package/dist/web/widgets/upload-zone.js +108 -0
  449. package/dist/web/widgets/use-draft.d.ts +20 -0
  450. package/dist/web/widgets/use-draft.js +21 -0
  451. package/dist/web/widgets/use-pointer-drag.d.ts +21 -0
  452. package/dist/web/widgets/use-pointer-drag.js +53 -0
  453. package/package.json +105 -7
  454. package/src/changes.json +77 -0
  455. package/src/web/fonts/OFL.txt +93 -0
  456. package/src/web/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
  457. package/src/web/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
  458. package/src/web/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
  459. package/src/web/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
  460. package/src/web/fonts/ibm-plex-sans-latin-400.woff2 +0 -0
  461. package/src/web/fonts/ibm-plex-sans-latin-500.woff2 +0 -0
  462. package/src/web/fonts/ibm-plex-sans-latin-600.woff2 +0 -0
  463. package/src/web/fonts/ibm-plex-sans-latin-ext-400.woff2 +0 -0
  464. package/src/web/fonts/ibm-plex-sans-latin-ext-500.woff2 +0 -0
  465. package/src/web/fonts/ibm-plex-sans-latin-ext-600.woff2 +0 -0
  466. package/src/web/styles.css +401 -0
@@ -0,0 +1,2021 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cardColumnRoles } from "@cosmicdrift/kumiko-framework/ui-types";
3
+ import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-types/kms-adapter-types";
4
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
5
+ import { cva } from "class-variance-authority";
6
+ import { ArrowDown, ArrowUp, ArrowUpDown, Building, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, Globe, Hash, KeyRound, Link, Loader2, Lock, Mail, MapPin, MoreHorizontal, Phone, Search, SlidersHorizontal, Tag, Undo2, User, X, } from "lucide-react";
7
+ // The main entry pulls Node-only deps (yargs/pngjs) under bundlers that ignore
8
+ // package.json#browser (Metro), hence the explicit browser subpath.
9
+ import QRCode from "qrcode/lib/browser.js";
10
+ import { Children, createContext, Fragment, isValidElement, useContext, useEffect, useId, useRef, useState, } from "react";
11
+ import { applyFormatSpec, EMPTY_VALUE_PLACEHOLDER, isSafeHref } from "../../headless/index.js";
12
+ import { InsideDrawerProvider, needsActionConfirm, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useColumnRenderer, useInsideDrawer, useOptionalLocale, useOptionalPrimitives, useOptionalTranslation, useTranslation, WriteFailedError, } from "../../index.js";
13
+ import { Icon, NAV_ICONS } from "../icons.js";
14
+ import { cn } from "../lib/cn.js";
15
+ import { Badge } from "../ui/badge.js";
16
+ import { buttonVariants, Button as UiButton } from "../ui/button.js";
17
+ import { Checkbox } from "../ui/checkbox.js";
18
+ import { Input as UiInput } from "../ui/input.js";
19
+ import { Label as UiLabel } from "../ui/label.js";
20
+ import { Switch } from "../ui/switch.js";
21
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table.js";
22
+ import { Textarea } from "../ui/textarea.js";
23
+ import { ProgressBar } from "../widgets/progress-bar.js";
24
+ import { StatusBadge } from "../widgets/status-badge.js";
25
+ import { StepBar } from "../widgets/step-bar.js";
26
+ import { inFieldIconButtonClass } from "./calendar-popover.js";
27
+ import { ComboboxInput } from "./combobox.js";
28
+ import { DateInput } from "./date-input.js";
29
+ import { DefaultDialog } from "./dialog.js";
30
+ import { DefaultDrawer } from "./drawer.js";
31
+ import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "./dropdown-menu.js";
32
+ import { EmbeddedListInput } from "./embedded-list-input.js";
33
+ import { CardLeadingImage, FileDisplayCell } from "./file-thumbnail.js";
34
+ import { FileUploadInput } from "./file-upload.js";
35
+ import { DefaultJsonView } from "./json-view.js";
36
+ import { screenFormColumnWidthClassName, screenPaddingClassName, screenWidthClassName, } from "./layout.js";
37
+ import { DefaultLightbox } from "./lightbox.js";
38
+ import { LocatedTimestampInput } from "./located-timestamp-input.js";
39
+ import { DefaultMetric, DefaultMetricBand, InsideScreenFormColumnContext } from "./metric.js";
40
+ import { DefaultModal } from "./modal.js";
41
+ import { currencyDecimals, formatMoney, MoneyInput } from "./money-input.js";
42
+ import { NarrowPinnedFooter } from "./narrow-pinned-footer.js";
43
+ import { NumberInput } from "./number-input.js";
44
+ import { groupOptions, hasDescriptionOrGroup } from "./option-groups.js";
45
+ import { DefaultPageHeader } from "./page-header.js";
46
+ import { PromoPanel } from "./promo-panel.js";
47
+ import { SegmentedControl } from "./segmented-select.js";
48
+ import { CopyButton, ShareButton } from "./share-actions.js";
49
+ import { DefaultStatusBadge } from "./status-badge.js";
50
+ import { STICKY_FOOTER_SAFE_AREA_CLASS, STICKY_FOOTER_SPACER_CLASS, StickyActionBar, } from "./sticky-action-bar.js";
51
+ import { DefaultTabs } from "./tabs.js";
52
+ import { TimestampInput } from "./timestamp-input.js";
53
+ import { useToast } from "./toast.js";
54
+ import { TzInput } from "./tz-input.js";
55
+ import { useKeyboardInset } from "./use-keyboard-inset.js";
56
+ import { useIsBelowSmViewport, useIsNarrowViewport } from "./use-narrow-viewport.js";
57
+ import "../react-css-custom-properties.js";
58
+ // ---- Card-Chrome (eine Definition für Form/Section/Card) ----
59
+ // Die eine Card-Surface. Maße (Padding/Radius/Shadow) kommen aus --card-*
60
+ // CSS-Tokens (Defaults in styles.css), damit eine App sie einmal zentral in
61
+ // ihrer styles.css überschreiben kann — wie die Farben. Radius-Variante:
62
+ // xl = Card/Screen-Fläche (Default, token-getrieben), lg = "innen"-Fläche.
63
+ const cardSurface = cva("flex flex-col border bg-card text-card-foreground shadow-[var(--card-shadow)]", {
64
+ variants: { radius: { xl: "rounded-[var(--card-radius)]", lg: "rounded-lg" } },
65
+ defaultVariants: { radius: "xl" },
66
+ });
67
+ // Wraps instead of running off-screen when the row outgrows its container (fw#2528).
68
+ const cardFooter = "flex flex-wrap items-center justify-end gap-2 py-4";
69
+ const cardFooterInset = "px-[var(--card-padding)]";
70
+ // /30 read as nearly invisible against the light-theme card (white card,
71
+ // muted at 94% lightness) — /50 keeps the same token, just a stronger step.
72
+ const cardFooterBorder = "border-t bg-muted/50";
73
+ const cardHeaderBorder = "border-b bg-muted/50";
74
+ // ---- Button (vendored shadcn ui/button) ----
75
+ // Contract-Variant → shadcn-Variant: secondary war schon immer der
76
+ // bordered-bg-background-Look = shadcns `outline`. primary→default,
77
+ // danger→destructive, link→link (kein BG, underline on hover).
78
+ const BUTTON_VARIANT = {
79
+ primary: "default",
80
+ secondary: "outline",
81
+ danger: "destructive",
82
+ link: "link",
83
+ "danger-ghost": "ghost",
84
+ ghost: "ghost",
85
+ };
86
+ const BUTTON_SIZE = {
87
+ sm: "sm",
88
+ md: "default",
89
+ icon: "icon",
90
+ };
91
+ // Closed IconKey vocabulary, checked against the shared NAV_ICONS registry
92
+ // (icons.tsx) — an unknown key (schema data isn't statically typed against
93
+ // IconKey the way this prop is) renders no icon instead of crashing, same
94
+ // fallback shape as fieldIconFor above.
95
+ function actionIconFor(icon) {
96
+ return icon !== undefined && Object.hasOwn(NAV_ICONS, icon) ? icon : undefined;
97
+ }
98
+ // Shared by DefaultButton's `secondary` variant and triggers that need Radix `asChild`
99
+ // prop forwarding (which DefaultButton does not support), so both stay visually identical.
100
+ const SECONDARY_BUTTON_EXTRA_CLASSES = "border-input hover:bg-muted";
101
+ function DefaultButton({ type = "button", onClick, disabled, loading, variant = "primary", size = "md", ariaLabel, title, pressed, expanded, width = "auto", children, testId, className, ref, icon, iconEnd, dataAttributes, }) {
102
+ // link-Variant rendert text-artig (Inline-Link im Fließtext/Banner), nicht als
103
+ // gepolsterte Fläche; width="full" streckt CTA-Buttons in Karten/Panels.
104
+ const resolvedClassName = cn(variant === "link" ? "h-auto px-0 py-0" : "", variant === "danger-ghost"
105
+ ? "text-destructive hover:text-destructive hover:bg-destructive/10"
106
+ : "", variant === "ghost" ? "text-primary hover:text-primary hover:bg-primary/10" : "", variant === "secondary" ? SECONDARY_BUTTON_EXTRA_CLASSES : "", pressed === true
107
+ ? "border-primary/40 bg-accent text-accent-foreground ring-1 ring-primary/30"
108
+ : "", width === "full" ? "w-full" : "", className);
109
+ const IconStart = icon !== undefined ? NAV_ICONS[icon] : undefined;
110
+ const IconEnd = iconEnd !== undefined ? NAV_ICONS[iconEnd] : undefined;
111
+ // Loading swaps the leading icon slot for a spinner and keeps `children` —
112
+ // replacing the label would shift the button's width mid-submit.
113
+ const leading = loading === true ? (_jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": "true" })) : IconStart !== undefined ? (_jsx(IconStart, { className: "size-4", "aria-hidden": "true" })) : null;
114
+ return (_jsxs(UiButton, { ref: ref, type: type, onClick: onClick, disabled: disabled === true || loading === true, ...dataAttributes, "data-testid": testId, "data-loading": loading === true ? "true" : undefined, variant: BUTTON_VARIANT[variant], size: BUTTON_SIZE[size], "aria-label": ariaLabel, "aria-pressed": pressed, "aria-expanded": expanded, title: title ?? (size === "icon" ? ariaLabel : undefined), className: resolvedClassName, children: [leading, children, IconEnd !== undefined && _jsx(IconEnd, { className: "size-4", "aria-hidden": "true" })] }));
115
+ }
116
+ // ---- Banner (shadcn: Alert) ----
117
+ function DefaultBanner({ variant = "info", title, titleTestId, children, actions, padded, testId, id, }) {
118
+ const isError = variant === "error";
119
+ const isWarning = variant === "warning";
120
+ const isPrimary = variant === "primary";
121
+ // `id` is set when a <Field> wraps this Banner as its control (see
122
+ // BannerProps.id). A <div> isn't labelable, so a <label htmlFor> pointing
123
+ // at it is inert for screen readers — role="group" + aria-labelledby is
124
+ // the actual mechanism, pointed at the label id Field emits (fieldLabelId).
125
+ const bannerRole = id !== undefined ? "group" : isError ? "alert" : undefined;
126
+ const banner = (_jsxs("div", { role: bannerRole, ...(id !== undefined && { "aria-labelledby": fieldLabelId(id) }), "data-testid": testId, "data-variant": variant, className: cn("relative w-full rounded-lg border px-4 py-3 text-sm flex items-center gap-3",
127
+ // Viewport breakpoint like the form footer bar; actions drop below the text on phones.
128
+ isPrimary && "flex-col items-start sm:flex-row sm:items-center", isError
129
+ ? "border-destructive/50 text-destructive bg-destructive/10 dark:border-destructive"
130
+ : isWarning
131
+ ? "border-status-warn/30 bg-status-warn/10 text-status-warn"
132
+ : isPrimary
133
+ ? "border-l-4 border-l-primary bg-card text-card-foreground"
134
+ : "bg-card text-card-foreground"), children: [_jsxs("div", { className: "flex-1", children: [title !== undefined && (_jsx("div", { "data-testid": titleTestId, className: "font-semibold", children: title })), children] }), actions !== undefined && _jsx("div", { "data-slot": "actions", children: actions })] }));
135
+ // Page-State: Banner sitzt alleine im Main (das kein Padding mehr
136
+ // hat). Wrapper gibt 24px Außenabstand damit der Banner nicht edge-
137
+ // to-edge an Sidebar/Browser klebt.
138
+ return padded === true ? _jsx("div", { className: "p-6", children: banner }) : banner;
139
+ }
140
+ // ---- Field (Label + Error) ----
141
+ // Matches DefaultBanner's aria-labelledby target: a non-labelable control
142
+ // (e.g. Banner's <div>) can't take a real htmlFor, so Field always also
143
+ // gives its label a stable id that such controls can point to.
144
+ function fieldLabelId(id) {
145
+ return `${id}-label`;
146
+ }
147
+ // Boolean optics depend on the surrounding Field layout: layout="inline"
148
+ // (checkbox lists like MultiSelectCheckboxes, the BooleanField widget) keeps
149
+ // the checkbox, the standard form field (layout="stacked"/default) gets the
150
+ // new switch. Pure web-renderer rendering decision, no contract field needed
151
+ // — Field hands its resolved layout down to the nested Input via context.
152
+ const FieldLayoutContext = createContext("stacked");
153
+ // True inside the sections of a card-less screen form: sections then drop their
154
+ // own horizontal padding, the screen form's scroll surface provides it.
155
+ const ScreenFormContext = createContext(false);
156
+ const DrawerBodyContext = createContext(false);
157
+ // aria-labelledby into a Field label that doesn't exist leaves a radiogroup
158
+ // without any accessible name — fall back to the control's name instead.
159
+ const FieldLabelledContext = createContext(false);
160
+ function useFieldLabelProps(id, name) {
161
+ const insideLabelledField = useContext(FieldLabelledContext);
162
+ return insideLabelledField ? { "aria-labelledby": fieldLabelId(id) } : { "aria-label": name };
163
+ }
164
+ // The Field description is the control's accessible description; the control
165
+ // is a descendant (not a prop of Field), so the id travels via context. Always
166
+ // provided (undefined without a description) so a nested Field never inherits
167
+ // its parent's description id.
168
+ const FieldDescriptionContext = createContext(undefined);
169
+ function fieldDescriptionId(id) {
170
+ return `${id}-description`;
171
+ }
172
+ function DefaultField(props) {
173
+ return (_jsx(FieldLabelledContext.Provider, { value: props.label !== "", children: _jsx(FieldDescriptionContext.Provider, { value: props.description !== undefined ? fieldDescriptionId(props.id) : undefined, children: _jsx(DefaultFieldBody, { ...props }) }) }));
174
+ }
175
+ function DefaultFieldBody({ id, label, required, issues, labelAppendix, fieldAppendix, appendixPlacement, description, status, children, layout, accent, hideLabel, testId, changed, }) {
176
+ const t = useTranslation();
177
+ const hasError = issues !== undefined && issues.length > 0;
178
+ const labelEl = (_jsxs(UiLabel, { id: fieldLabelId(id), htmlFor: id, className: cn("min-w-0 gap-0 text-[13px]", hasError ? "text-destructive" : "text-foreground", hideLabel === true && "sr-only"), children: [_jsx("span", { className: layout === "inline" ? undefined : "truncate", title: typeof label === "string" ? label : undefined, children: label }), required === true && (_jsx("span", { "data-required": true, className: "ml-0.5 shrink-0 text-destructive", children: "*" }))] }));
179
+ const errorsEl = hasError ? (_jsx("div", { role: "alert", "data-testid": testId !== undefined ? `${testId}-errors` : undefined, className: "text-xs text-destructive", children: issues.map((issue) => (_jsx("div", { children: t(issue.i18nKey, issue.params) }, `${issue.path}:${issue.code}`))) })) : null;
180
+ // Inline (boolean/checkbox): Control links, Label rechts — shadcn-Muster.
181
+ if (layout === "inline") {
182
+ return (_jsxs("div", { "data-testid": testId, className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(FieldLayoutContext.Provider, { value: "inline", children: children }), labelEl, labelAppendix !== undefined && labelAppendix] }), errorsEl] }));
183
+ }
184
+ const changedMarker = changed === true ? (_jsxs("span", { "data-testid": testId !== undefined ? `${testId}-changed` : undefined, className: "inline-flex items-center gap-1 text-xs text-status-active", children: [_jsx("span", { className: "size-1.5 rounded-full bg-current", "aria-hidden": "true" }), t("kumiko.form.changed")] })) : null;
185
+ const statusEl = status !== undefined ? (_jsx(StatusBadge, { tone: status.tone, testId: status.testId ?? (testId !== undefined ? `${testId}-status` : undefined), children: status.label })) : null;
186
+ const belowControl = appendixPlacement === "below-control";
187
+ // Settings row: label/help/origin on the left, control on the right. The
188
+ // hairlines between rows come from the list Grid, not from the row itself.
189
+ if (layout === "row") {
190
+ return (_jsxs("div", { "data-testid": testId, className: cn("group/row relative grid gap-x-6 gap-y-2 py-4 @2xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)] @2xl:grid-rows-[auto_auto_auto_1fr] @2xl:gap-y-0",
191
+ // Switch rows keep label left / switch right when stacked.
192
+ "@max-2xl:has-[[data-slot=switch]]:grid-cols-[minmax(0,1fr)_auto]", accent === true && "@max-2xl:pl-3"), children: [accent === true && (_jsx("span", { "aria-hidden": "true", "data-testid": testId !== undefined ? `${testId}-accent` : undefined, className: "absolute inset-y-3 w-0.5 bg-status-active @max-2xl:left-0 @2xl:-left-4" })), _jsxs("div", { className: "flex min-w-0 items-center gap-2 @2xl:col-start-1 @2xl:row-start-1", children: [labelEl, changedMarker, labelAppendix !== undefined && labelAppendix, statusEl] }), description !== undefined && (_jsx("div", { id: fieldDescriptionId(id), "data-testid": testId !== undefined ? `${testId}-description` : undefined, className: "min-w-0 text-sm text-muted-foreground @2xl:col-start-1 @2xl:row-start-2 @2xl:mt-1 @max-2xl:group-has-[[data-slot=switch]]/row:col-span-2", children: description })), _jsxs("div", { className: "flex min-w-0 flex-col gap-1.5 has-[[data-slot=switch]]:items-end @2xl:col-start-2 @2xl:row-span-4 @2xl:row-start-1 @max-2xl:group-has-[[data-slot=switch]]/row:col-start-2 @max-2xl:group-has-[[data-slot=switch]]/row:row-start-1", children: [_jsx(FieldLayoutContext.Provider, { value: "row", children: children }), errorsEl] }), fieldAppendix !== undefined && (_jsx("div", { className: "min-w-0 @2xl:col-start-1 @2xl:row-start-3 @2xl:mt-1 @max-2xl:group-has-[[data-slot=switch]]/row:col-span-2", children: fieldAppendix }))] }));
193
+ }
194
+ return (_jsxs("div", { "data-testid": testId, ...(belowControl && fieldAppendix !== undefined && { "data-appendix-below": "" }), className: "flex flex-col gap-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [changedMarker !== null ? (_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [labelEl, changedMarker] })) : (labelEl), labelAppendix !== undefined && labelAppendix, statusEl] }), description !== undefined && (_jsx("div", { id: fieldDescriptionId(id), "data-testid": testId !== undefined ? `${testId}-description` : undefined, className: "text-sm text-muted-foreground", children: description })), !belowControl && fieldAppendix !== undefined && fieldAppendix, children, errorsEl, belowControl && fieldAppendix !== undefined && fieldAppendix] }));
195
+ }
196
+ // ---- Input ----
197
+ // Field-icon registry: `EditFieldSpec.icon`/`InputProps.icon` sets a
198
+ // symbolic key, mapped here to a lucide component. Unknown keys → no
199
+ // icon (clean fallback, no boot-fail). Mirrors NAV_ICONS' pattern
200
+ // (nav-tree.tsx) — a separate, smaller registry instead of a shared
201
+ // import, because field icons cover a different use case (email, phone,
202
+ // location, …) than nav icons (dashboard, tables, …).
203
+ const FIELD_ICONS = {
204
+ mail: Mail,
205
+ lock: Lock,
206
+ hash: Hash,
207
+ search: Search,
208
+ user: User,
209
+ phone: Phone,
210
+ calendar: CalendarDays,
211
+ link: Link,
212
+ tag: Tag,
213
+ building: Building,
214
+ globe: Globe,
215
+ key: KeyRound,
216
+ "map-pin": MapPin,
217
+ };
218
+ function fieldIconFor(icon) {
219
+ return icon !== undefined && Object.hasOwn(FIELD_ICONS, icon)
220
+ ? FIELD_ICONS[icon]
221
+ : undefined;
222
+ }
223
+ // Wraps a text/number input with a left-positioned prefix icon when
224
+ // `icon` carries a known FIELD_ICONS key. `pl-8` overrides (via
225
+ // tailwind-merge) only the left padding of the vendored ui/input.tsx —
226
+ // right padding and other defaults stay untouched.
227
+ const FIELD_ICON_CLASS = "pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground";
228
+ function withFieldIcon(icon, input) {
229
+ const Icon = fieldIconFor(icon);
230
+ if (Icon === undefined)
231
+ return input;
232
+ return (_jsxs("div", { className: "relative", children: [_jsx(Icon, { "aria-hidden": "true", className: FIELD_ICON_CLASS }), input] }));
233
+ }
234
+ const INPUT_AFFIX_CLASS = "inline-flex shrink-0 items-center text-sm text-muted-foreground max-md:min-h-11";
235
+ // Prefix/suffix text and the fill button sit beside the input, not inside it,
236
+ // so the input keeps its own padding contract (icon, unit). The wrapper is
237
+ // only added when an addon exists — a plain input keeps its tree shape.
238
+ function withInputAddons(addons, input) {
239
+ const { name, prefix, suffix, fill } = addons;
240
+ if (prefix === undefined && suffix === undefined && fill === undefined)
241
+ return input;
242
+ return (_jsxs("div", { "data-slot": "input-addons", className: "flex items-center gap-2", children: [prefix !== undefined && (_jsx("span", { "data-testid": `input-prefix-${name}`, className: INPUT_AFFIX_CLASS, children: prefix })), _jsx("div", { className: "min-w-0 flex-1", children: input }), suffix !== undefined && (_jsx("span", { "data-testid": `input-suffix-${name}`, className: INPUT_AFFIX_CLASS, children: suffix })), fill !== undefined && (_jsx(DefaultButton, { variant: "secondary", size: "sm", onClick: fill.onPress, loading: fill.busy === true, disabled: fill.disabled === true, testId: fill.testId ?? `input-fill-${name}`, children: fill.label }))] }));
243
+ }
244
+ // The unit changes the meaning of the value ("58 mi" vs "58 km"), so it is
245
+ // exposed as the input's description rather than hidden from assistive tech.
246
+ function unitSuffixId(inputId) {
247
+ return `${inputId}-unit`;
248
+ }
249
+ const TRAILING_ACTION_ICONS = { clear: X, undo: Undo2 };
250
+ // In-field icon button on the right (same look as the date input's calendar
251
+ // trigger). The wrapped input must reserve the room itself (`pr-9`).
252
+ // `keepWrapper` holds the tree shape when the action comes and goes — a
253
+ // write-only input loses its action on the first keystroke, and swapping the
254
+ // wrapper would remount the input and drop focus mid-typing.
255
+ function withTrailingAction(action, input, keepWrapper = false) {
256
+ if (action === undefined && !keepWrapper)
257
+ return input;
258
+ const ActionIcon = action !== undefined ? TRAILING_ACTION_ICONS[action.icon] : undefined;
259
+ return (_jsxs("div", { className: "relative", children: [input, action !== undefined && ActionIcon !== undefined && (_jsx("button", { type: "button", "aria-label": action.label, title: action.label, "data-testid": action.testId, onClick: action.onPress, className: inFieldIconButtonClass, children: _jsx(ActionIcon, { className: "size-4", "aria-hidden": "true" }) }))] }));
260
+ }
261
+ // Default presentation for `kind: "select"` without a `display` of its own
262
+ // (board rule): up to 3 short options read as a segmented control, up to 3
263
+ // longer ones as a vertical radio list, everything else as a dropdown. Labels
264
+ // arrive translated, so the widget can differ between UI languages (#2606).
265
+ const SEGMENTED_SELECT_MAX_OPTIONS = 3;
266
+ const SEGMENTED_SELECT_MAX_LABEL_LENGTH = 16;
267
+ function defaultSelectPresentation(options) {
268
+ if (options.length === 0 || options.length > SEGMENTED_SELECT_MAX_OPTIONS)
269
+ return "dropdown";
270
+ return options.every((option) => option.label.length <= SEGMENTED_SELECT_MAX_LABEL_LENGTH)
271
+ ? "segmented"
272
+ : "radioList";
273
+ }
274
+ // A "" value is the unselected placeholder, not a real choice — a radio
275
+ // group can't render an unchecked-everything state as its own segment
276
+ // (#2606 follow-up). Filtered once so the eligibility count and the
277
+ // rendered segments never drift apart.
278
+ function withoutSegmentedSelectPlaceholder(options) {
279
+ return options.filter((option) => option.value !== "");
280
+ }
281
+ function SegmentedSelect(props) {
282
+ const labelProps = useFieldLabelProps(props.id, props.name);
283
+ return _jsx(SegmentedControl, { ...props, labelProps: labelProps });
284
+ }
285
+ const RADIO_CARD_CLASS = "flex items-start gap-3 rounded-lg border border-input bg-background px-[15px] py-[13px] text-sm font-normal text-foreground " +
286
+ "has-[:checked]:border-2 has-[:checked]:border-primary has-[:checked]:bg-primary/5 has-[:checked]:px-[14px] has-[:checked]:py-3 " +
287
+ "has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:disabled]:opacity-50";
288
+ function RadioListSelect({ id, name, value, onChange, options, variant = "list", disabled, required, hasError, }) {
289
+ const labelProps = useFieldLabelProps(id, name);
290
+ const card = variant === "card";
291
+ // One radiogroup over all sections keeps native arrow-key navigation across groups
292
+ // (radios share `name` in DOM order).
293
+ const descriptionIdOf = (opt) => `${id}-option-${options.findIndex((o) => o.value === opt.value)}-description`;
294
+ return (_jsx("div", { role: "radiogroup", ...labelProps, "aria-required": required, "aria-invalid": hasError === true ? true : undefined, "data-testid": `radio-list-${id}`, "data-radio-list": "", className: "flex flex-col gap-2", children: groupOptions(options).map((section) => (_jsxs(Fragment, { children: [section.heading !== undefined && (_jsx("div", { "data-testid": `radio-list-${id}-group-${section.heading}`, className: "mt-1 text-xs font-medium text-muted-foreground", children: section.heading })), section.options.map((opt) => (_jsxs("label", { className: card
295
+ ? RADIO_CARD_CLASS
296
+ : "flex items-start gap-2 text-sm font-normal text-foreground has-[:disabled]:opacity-50", children: [_jsx("input", { type: "radio", name: name, value: opt.value, checked: opt.value === value, disabled: disabled === true || opt.disabled === true, "aria-describedby": opt.description !== undefined ? descriptionIdOf(opt) : undefined, "data-testid": `radio-list-${id}-${opt.value}`, onChange: () => onChange(opt.value), className: cn("size-4 accent-primary", card ? "mt-0.5 shrink-0" : "mt-0.5") }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: cn(card && "font-semibold"), children: opt.label }), opt.description !== undefined && (_jsx("span", { id: descriptionIdOf(opt), className: "text-[13px] text-foreground-secondary", children: opt.description }))] })] }, opt.value)))] }, section.heading ?? ""))) }));
297
+ }
298
+ // The vendored shadcn Textarea carries `field-sizing: content`, which derives
299
+ // the box height from the content and makes the `rows` attribute inert (#2677).
300
+ // A declared row count therefore only survives as a min-height floor: the field
301
+ // starts at `rows` lines tall and still grows with its content. The addend is
302
+ // the textarea frame — py-2 top+bottom plus the 1px borders.
303
+ function textareaMinHeight(rows) {
304
+ return { minHeight: `calc(${rows} * 1lh + 1rem + 2px)` };
305
+ }
306
+ function withInlineDisabledHint(option) {
307
+ if (option.disabled !== true || option.description === undefined)
308
+ return option;
309
+ return {
310
+ value: option.value,
311
+ label: `${option.label} (${option.description})`,
312
+ disabled: true,
313
+ ...(option.group !== undefined && { group: option.group }),
314
+ };
315
+ }
316
+ function DefaultInput(props) {
317
+ // Vendored ui/input + ui/checkbox stylen Fehler über `aria-invalid`
318
+ // selbst — kein manuelles border-destructive mehr nötig.
319
+ const booleanLayout = useContext(FieldLayoutContext);
320
+ const describedBy = useContext(FieldDescriptionContext);
321
+ const common = {
322
+ id: props.id,
323
+ name: props.name,
324
+ disabled: props.disabled,
325
+ "aria-describedby": describedBy,
326
+ "aria-required": props.required,
327
+ "aria-invalid": props.hasError === true ? true : undefined,
328
+ };
329
+ switch (props.kind) {
330
+ case "text":
331
+ return withInputAddons(props, withFieldIcon(props.icon, _jsx(UiInput, { type: "text", ...common, ...props.dataAttributes, "data-testid": props.testId, readOnly: props.readOnly, value: props.value, onChange: (e) => props.onChange(e.target.value), onKeyDown: props.onKeyDown, ...(props.placeholder !== undefined && { placeholder: props.placeholder }), ...(props.autoComplete !== undefined && { autoComplete: props.autoComplete }), className: cn(fieldIconFor(props.icon) !== undefined ? "pl-8" : undefined) })));
332
+ case "email":
333
+ return (_jsx(UiInput, { type: "email", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "email" }));
334
+ case "month":
335
+ return (_jsx(UiInput, { type: "month", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value) }));
336
+ case "password":
337
+ return withTrailingAction(props.trailingAction, _jsx(UiInput, { type: "password", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "current-password", className: props.trailingAction !== undefined ? "pr-9" : undefined }), true);
338
+ case "number": {
339
+ const NumberIcon = fieldIconFor(props.icon);
340
+ // Icon, input and unit share this one box, and the grid cell sizes the
341
+ // box rather than the bare input: anchored to a wider wrapper, the unit
342
+ // would sit next to a narrower input instead of inside it.
343
+ const numberField = (_jsxs("div", { "data-slot": "number-field", className: "relative", children: [NumberIcon !== undefined && (_jsx(NumberIcon, { "aria-hidden": "true", className: FIELD_ICON_CLASS })), _jsx(NumberInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, disabled: props.disabled, required: props.required, hasError: props.hasError, testId: props.testId, ...(props.unit !== undefined && { ariaDescribedBy: unitSuffixId(props.id) }), ...(props.locale !== undefined && { locale: props.locale }), ...(props.grouping !== undefined && { grouping: props.grouping }), ...(props.integer !== undefined && { integer: props.integer }), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), className: cn(NumberIcon !== undefined ? "pl-8" : undefined, props.unit !== undefined ? "pr-8" : undefined) }), props.unit !== undefined && (_jsx("span", { id: unitSuffixId(props.id), className: "pointer-events-none absolute right-2.5 top-1/2 max-w-[40%] -translate-y-1/2 truncate text-sm text-muted-foreground", children: props.unit }))] }));
344
+ return withInputAddons(props, numberField);
345
+ }
346
+ case "range":
347
+ return (_jsx("input", { type: "range", ...common, min: props.min, max: props.max, step: props.step ?? 1, value: props.value, onChange: (e) => props.onChange(Number(e.target.value)), className: "w-full accent-primary" }));
348
+ case "boolean":
349
+ // layout="inline" (checkbox lists like MultiSelectCheckboxes, the
350
+ // BooleanField widget) keeps the checkbox optics; the standard form
351
+ // field (render-field.tsx, layout="stacked") gets the switch.
352
+ return booleanLayout === "inline" ? (_jsx(Checkbox, { id: props.id, name: props.name, disabled: props.disabled, "aria-required": props.required, "aria-invalid": props.hasError === true ? true : undefined, checked: props.value, onCheckedChange: (checked) => props.onChange(checked === true) })) : (_jsx(Switch, { id: props.id, name: props.name, disabled: props.disabled, "aria-required": props.required, "aria-invalid": props.hasError === true ? true : undefined, checked: props.value, onCheckedChange: (checked) => props.onChange(checked === true), className: "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40" }));
353
+ case "file":
354
+ case "image":
355
+ return (_jsx(FileUploadInput, { kind: props.kind, id: props.id, value: props.value, onChange: props.onChange, ...(props.accept !== undefined && { accept: props.accept }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.entityType !== undefined && { entityType: props.entityType }), ...(props.entityId !== undefined && { entityId: props.entityId }), ...(props.fieldName !== undefined && { fieldName: props.fieldName }), ...(props.imageVariant !== undefined && { imageVariant: props.imageVariant }), ...(props.capture !== undefined && { capture: props.capture }), ...(props.directory !== undefined && { directory: props.directory }), ...(props.onFilesPicked !== undefined && { onFilesPicked: props.onFilesPicked }), ...(props.pickLabel !== undefined && { pickLabel: props.pickLabel }), ...(props.testId !== undefined && { testId: props.testId }) }));
356
+ case "date":
357
+ return (_jsx(DateInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, ...(props.locale !== undefined && { locale: props.locale }), ...(props.min !== undefined && { min: props.min }), ...(props.max !== undefined && { max: props.max }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
358
+ case "select": {
359
+ // Visual-Konsolidierung: alle Selects laufen über ComboboxInput
360
+ // (cmdk + Radix-Popover). Vorher hatten wir zwei Pfade — Radix-
361
+ // Select für `kind:"select"` und cmdk für `kind:"combobox"`. Drei
362
+ // visuell unterschiedliche Variants (Single-Select, Combobox-
363
+ // Single, Combobox-Multi) wurden unhandbar. Mit dem Merge ist die
364
+ // Combobox die einzige Implementation: Search-Input ist auch bei
365
+ // 4-Item-Status-Selects vorhanden, das ist eine bewusst akzeptierte
366
+ // UX-Konsequenz für Style-Konsistenz.
367
+ const comboOptions = props.options.map((o) => typeof o === "string" ? { value: o, label: o } : o);
368
+ // The dropdown shows a disabled option's hint inline, so it stays readable in the trigger and search.
369
+ const dropdownOptions = comboOptions.map(withInlineDisabledHint);
370
+ // The radio group can't render the unselected placeholder as its own
371
+ // segment, so it counts and renders on the real options only — the
372
+ // dropdown below keeps comboOptions (placeholder included) since it
373
+ // has a genuine "nothing selected" row.
374
+ // An author who asks for `display: "radio"` explicitly declared the ""
375
+ // option as a real choice (e.g. "Any"), so it stays a segment.
376
+ const radioGroupOptions = props.display === "radio" ? comboOptions : withoutSegmentedSelectPlaceholder(comboOptions);
377
+ // An explicit `display` is an author decision and outranks the
378
+ // heuristic in both directions — a requested radio group renders as
379
+ // one even when the options outnumber the heuristic's threshold (#2711).
380
+ const requestedPresentation = props.radioVariant === "card"
381
+ ? "radioList"
382
+ : props.display === "radio"
383
+ ? "segmented"
384
+ : props.display === "dropdown"
385
+ ? "dropdown"
386
+ : defaultSelectPresentation(radioGroupOptions);
387
+ // A segmented control has no room for a description line or group headings.
388
+ const presentation = requestedPresentation === "segmented" && hasDescriptionOrGroup(radioGroupOptions)
389
+ ? "radioList"
390
+ : requestedPresentation;
391
+ if (presentation === "radioList" && radioGroupOptions.length > 0) {
392
+ return (_jsx(RadioListSelect, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: radioGroupOptions, ...(props.radioVariant !== undefined && { variant: props.radioVariant }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
393
+ }
394
+ if (presentation === "segmented" && radioGroupOptions.length > 0) {
395
+ return (_jsx(SegmentedSelect, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: radioGroupOptions, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
396
+ }
397
+ return (_jsx(ComboboxInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, options: dropdownOptions, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
398
+ }
399
+ case "combobox": {
400
+ // Tier 2.1c + Tier 2.7e: Discriminated-Union per `multiple` —
401
+ // wir splittan TS-side in zwei Branches damit ComboboxInput's
402
+ // Single/Multi-Variants typgerecht gerendert werden.
403
+ const baseProps = {
404
+ id: props.id,
405
+ name: props.name,
406
+ options: props.options,
407
+ ...(props.disabled !== undefined && { disabled: props.disabled }),
408
+ ...(props.required !== undefined && { required: props.required }),
409
+ ...(props.hasError !== undefined && { hasError: props.hasError }),
410
+ ...(props.placeholder !== undefined && { placeholder: props.placeholder }),
411
+ ...(props.searchPlaceholder !== undefined && {
412
+ searchPlaceholder: props.searchPlaceholder,
413
+ }),
414
+ ...(props.emptyText !== undefined && { emptyText: props.emptyText }),
415
+ ...(props.onSearchChange !== undefined && { onSearchChange: props.onSearchChange }),
416
+ ...(props.loading !== undefined && { loading: props.loading }),
417
+ ...(props.onCreate !== undefined && { onCreate: props.onCreate }),
418
+ ...(props.createLabel !== undefined && { createLabel: props.createLabel }),
419
+ };
420
+ if (props.multiple === true) {
421
+ return (_jsx(ComboboxInput, { ...baseProps, multiple: true, value: props.value, onChange: props.onChange }));
422
+ }
423
+ return _jsx(ComboboxInput, { ...baseProps, value: props.value, onChange: props.onChange });
424
+ }
425
+ case "money":
426
+ return (_jsx(MoneyInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, currency: props.currency ?? "EUR", ...(props.locale !== undefined && { locale: props.locale }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
427
+ case "timestamp":
428
+ return (_jsx(TimestampInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, ...(props.wallClock !== undefined && { wallClock: props.wallClock }), ...(props.locale !== undefined && { locale: props.locale }), ...(props.min !== undefined && { min: props.min }), ...(props.max !== undefined && { max: props.max }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
429
+ case "locatedTimestamp":
430
+ return (_jsx(LocatedTimestampInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, ...(props.locale !== undefined && { locale: props.locale }), ...(props.min !== undefined && { min: props.min }), ...(props.max !== undefined && { max: props.max }), ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
431
+ case "textarea": {
432
+ const rows = props.rows;
433
+ const onSubmitShortcut = props.onSubmitShortcut;
434
+ const onKeyDown = props.onKeyDown;
435
+ return (_jsx(Textarea, { ...common, ...props.dataAttributes, readOnly: props.readOnly, value: props.value, onChange: (e) => props.onChange(e.target.value), rows: rows ?? 4, className: "resize-y", ...(props.placeholder !== undefined && { placeholder: props.placeholder }), ...(rows !== undefined && { style: textareaMinHeight(rows) }), ...((onSubmitShortcut !== undefined || onKeyDown !== undefined) && {
436
+ ...(onSubmitShortcut !== undefined && {
437
+ "aria-keyshortcuts": "Control+Enter Meta+Enter",
438
+ }),
439
+ onKeyDown: (e) => {
440
+ onKeyDown?.(e);
441
+ // Lets a caller that already handles Ctrl/Cmd+Enter (e.g. an
442
+ // "Enter sends" handler that ignores only Shift) opt out of the
443
+ // shortcut instead of submitting twice.
444
+ if (e.defaultPrevented)
445
+ return;
446
+ if (onSubmitShortcut === undefined || e.key !== "Enter" || !(e.metaKey || e.ctrlKey))
447
+ return;
448
+ e.preventDefault();
449
+ onSubmitShortcut();
450
+ },
451
+ }) }));
452
+ }
453
+ case "tz":
454
+ return (_jsx(TzInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, ...(props.disabled !== undefined && { disabled: props.disabled }), ...(props.required !== undefined && { required: props.required }), ...(props.hasError !== undefined && { hasError: props.hasError }) }));
455
+ }
456
+ }
457
+ // ---- DataTable (shadcn: Table) ----
458
+ function sameValueSet(a, b) {
459
+ return a.length === b.length && a.every((value) => b.includes(value));
460
+ }
461
+ // Single-choice chip row for a facet with `chips`: a chip is pressed while the
462
+ // current selection equals its values, a click replaces the selection.
463
+ function FacetChips({ facet, selected, onChange, }) {
464
+ const chips = (facet.chips ?? []).filter((chip) => facet.hideEmpty !== true || chip.count !== 0 || sameValueSet(selected, chip.values));
465
+ return (_jsx("div", { role: "group", "aria-label": facet.label, className: "flex flex-wrap items-center gap-1.5", "data-testid": `facet-${facet.field}`, children: chips.map((chip) => (_jsxs(DefaultButton, { variant: "secondary", size: "sm", pressed: sameValueSet(selected, chip.values), onClick: () => onChange(facet.field, chip.values), testId: `facet-${facet.field}-${chip.id}`, className: "rounded-full", children: [chip.label, facet.showCounts === true && chip.count !== undefined && (_jsx("span", { className: "tabular-nums text-muted-foreground", children: chip.count }))] }, chip.id))) }));
466
+ }
467
+ // Faceted-Filter-Dropdown: Outline-Button (wie shadcns "Columns"-Toggle) +
468
+ // Multi-Select-Checkboxen. Aktive Auswahl → Count-Badge am Button.
469
+ function FacetFilter({ facet, selected, onChange, dense = false, }) {
470
+ const toggle = (value, checked) => {
471
+ const next = checked ? [...selected, value] : selected.filter((v) => v !== value);
472
+ onChange(facet.field, next);
473
+ };
474
+ if (facet.chips !== undefined) {
475
+ return _jsx(FacetChips, { facet: facet, selected: selected, onChange: onChange });
476
+ }
477
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsxs(UiButton, { variant: "outline", size: "sm", className: cn("h-9", dense && "h-8 border-border-strong px-2.5 max-md:h-11"), "data-testid": `facet-${facet.field}`, children: [facet.label, selected.length > 0 && (_jsx(Badge, { variant: "secondary", className: "ml-1 rounded-sm px-1 font-normal tabular-nums", children: selected.length })), _jsx(ChevronDown, { className: "text-muted-foreground" })] }) }), _jsx(DropdownMenuContent, { align: "start", className: "max-h-[var(--radix-dropdown-menu-content-available-height)] w-44 overflow-y-auto", children: facet.options.map((opt) => (_jsx(DropdownMenuCheckboxItem, { checked: selected.includes(opt.value), onCheckedChange: (checked) => toggle(opt.value, checked), onSelect: (e) => e.preventDefault(), "data-testid": `facet-${facet.field}-${opt.value}`, children: opt.label }, opt.value))) })] }));
478
+ }
479
+ // Time-range filter (fw#3104): two native <input type="date"> instead of a
480
+ // dropdown — the browser supplies the calendar, the locale and the keyboard
481
+ // handling, so no date dependency is needed. Each bound constrains the other
482
+ // through min/max so the picker can't offer an inverted span; the caller
483
+ // clamps a typed value on top of that.
484
+ function DateRangeFacetFilter({ facet, onChange, }) {
485
+ const inputClass = "h-9 rounded-md border border-input bg-transparent px-2 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 max-md:min-h-11";
486
+ return (_jsxs("div", { className: "flex items-center gap-2", "data-testid": `facet-daterange-${facet.field}`, children: [_jsx("span", { className: "text-sm text-muted-foreground", children: facet.label }), _jsx("input", { type: "date", className: inputClass, value: facet.from, ...(facet.to !== "" && { max: facet.to }), "aria-label": `${facet.label} from`, "data-testid": `facet-daterange-${facet.field}-from`, onChange: (e) => onChange(facet.field, "from", e.target.value) }), _jsx("span", { className: "text-sm text-muted-foreground", children: "\u2013" }), _jsx("input", { type: "date", className: inputClass, value: facet.to, ...(facet.from !== "" && { min: facet.from }), "aria-label": `${facet.label} to`, "data-testid": `facet-daterange-${facet.field}-to`, onChange: (e) => onChange(facet.field, "to", e.target.value) })] }));
487
+ }
488
+ // The vendored Table wraps <table> in an `overflow-x-auto` container, which
489
+ // would become the scrollport of a sticky header. A scrollBody table scrolls
490
+ // in its parent instead, so it renders the bare <table>.
491
+ function FlushTable({ className, ...props }) {
492
+ return (_jsx("table", { "data-slot": "table", className: cn("w-full caption-bottom text-sm", className), ...props }));
493
+ }
494
+ // Header cells stick to the scroll surface; the bottom rule is an inset
495
+ // shadow because a border on a sticky cell scrolls away under border-collapse.
496
+ // Column weight (medium vs. semibold for the sorted one) lives on the th itself
497
+ // in SortableHeader: a `[&_th]:font-*` rule here would outrank it.
498
+ // Child selectors only: an expanded row nests a whole table inside a cell, and
499
+ // descendant selectors would make its header sticky and re-pad its cells.
500
+ const FILL_TABLE_CLASS = cn("[&>thead>tr>th]:sticky [&>thead>tr>th]:top-0 [&>thead>tr>th]:z-10 [&>thead>tr>th]:h-9 [&>thead>tr>th]:bg-muted [&>thead>tr>th]:px-2 [&>thead>tr>th]:text-[13px]", "[&>thead>tr>th]:shadow-[inset_0_-1px_0_var(--color-border)] [&>thead>tr>th_button]:text-[13px] [&>tbody>tr>td]:px-2 [&>tbody>tr>td]:py-0", "[&>*>tr>:first-child]:pl-6 [&>*>tr>:last-child]:pr-6");
501
+ // `!`: beats the first/last-child padding of FILL_TABLE_CLASS, which would
502
+ // otherwise widen the narrow toggle column / pad the full-width expansion cell.
503
+ // The nested table's sticky actions cell (last td) gets the muted surface
504
+ // instead of its own md:bg-background, so it blends into the area.
505
+ const EXPAND_TOGGLE_CELL_CLASS = "w-10 px-1! py-0!";
506
+ const EXPANSION_CELL_CLASS = "whitespace-normal bg-muted p-0! align-top md:[&_td:last-child]:bg-muted";
507
+ const EXPANSION_CONTENT_CLASS = "pt-1 pr-4 pb-3.5 pl-[52px]";
508
+ function RowExpandToggle({ expanded, controlsId, label, onToggle, testId, }) {
509
+ return (_jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: "size-8 shrink-0 max-md:size-11", "aria-expanded": expanded, ...(expanded && { "aria-controls": controlsId }), "aria-label": label, "data-testid": testId, onClick: (e) => {
510
+ e.stopPropagation();
511
+ onToggle();
512
+ }, onKeyDown: (e) => e.stopPropagation(), children: _jsx(ChevronRight, { className: cn("size-4 transition-transform", expanded && "rotate-90"), "aria-hidden": "true" }) }));
513
+ }
514
+ const TABLE_FOOTER_BAR_CLASS = cn("flex h-15 shrink-0 items-center gap-4 border-t border-border bg-card pl-4 pr-2", "text-[13px] tabular-nums text-foreground-secondary md:h-11 md:px-6");
515
+ function DataTableFooter({ count, itemNoun, testId, }) {
516
+ const t = useOptionalTranslation();
517
+ const noun = itemNoun?.(count);
518
+ const label = noun !== undefined
519
+ ? (t?.("kumiko.list.count.noun", { count: count.toLocaleString(), noun }) ??
520
+ `${count.toLocaleString()} ${noun}`)
521
+ : count === 1
522
+ ? (t?.("kumiko.list.count.one") ?? "1 entry")
523
+ : (t?.("kumiko.list.count.other", { count: count.toLocaleString() }) ??
524
+ `${count.toLocaleString()} entries`);
525
+ return (_jsx("div", { "data-testid": testId, className: TABLE_FOOTER_BAR_CLASS, children: _jsx("span", { "data-testid": `${testId}-count`, children: label }) }));
526
+ }
527
+ // Cell content is a ReactNode, so the tooltip text is read from the rendered DOM.
528
+ function showFullTextOnHover(event) {
529
+ const element = event.currentTarget;
530
+ element.title = element.scrollWidth > element.clientWidth ? (element.textContent ?? "") : "";
531
+ }
532
+ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, emptyState, toolbarStart, toolbarDescription, toolbarEnd, pager, onReachEnd, loadingMore, hasMore, itemNoun, rowActions, rowActionMode, rowGrouping, rowTone, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, testId, onCellChange, getRowTestId, getCellTestId, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, }) {
533
+ // One locale/translate subscription per table — not per cell (fw#2345).
534
+ // Optional hooks: a bare DataTable outside LocaleProvider must not crash.
535
+ const tableTranslate = useOptionalTranslation();
536
+ const tableLocale = useOptionalLocale();
537
+ // Below 768px a table scrolls its columns out of reach with no visible
538
+ // affordance (fw#2159 fixed the desktop case; narrow viewports never had
539
+ // one). Cards replace the table entirely below the breakpoint — same
540
+ // single-mount pattern as EmbeddedListInput/embedded-list-input.tsx.
541
+ const isNarrow = useIsNarrowViewport();
542
+ const [facetsOpenNarrow, setFacetsOpenNarrow] = useState(false);
543
+ const [toggledGroups, setToggledGroups] = useState(new Set());
544
+ // Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
545
+ // beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
546
+ // scrollen typischerweise mit dem Page-Container, nicht intern.
547
+ // Sticky würde mit der Topbar konkurrieren.
548
+ // The row click already runs rowClick actions, so with a clickable row they
549
+ // must not reappear in the kebab (a list whose only action is the rowClick
550
+ // one gets no actions column at all).
551
+ const rowIsLink = onRowClick !== undefined;
552
+ const menuActions = rowActions !== undefined && rowIsLink
553
+ ? rowActions.filter((action) => action.rowClick !== true)
554
+ : rowActions;
555
+ // The table keeps every action in "inline" mode, and with editable cells the
556
+ // first cell is not a keyboard link (FirstCellLink is off), so the kebab is
557
+ // then the only keyboard route to the rowClick action.
558
+ const tableActions = rowActionMode === "inline" || onCellChange !== undefined ? rowActions : menuActions;
559
+ const rowClickAction = rowActions?.find((action) => action.rowClick === true);
560
+ const rowClickLabel = rowClickAction?.label;
561
+ const rowClickIsHidden = (row) => rowClickAction?.isVisible !== undefined && !rowClickAction.isVisible(row);
562
+ const hasTableActions = tableActions !== undefined && tableActions.length > 0;
563
+ const isEmpty = rows.length === 0;
564
+ const expansionIdPrefix = useId();
565
+ const isRowExpandable = renderExpandedRow !== undefined && onToggleRowExpanded !== undefined;
566
+ const rowTestId = (row) => getRowTestId?.(row) ?? `row-${row.id}`;
567
+ const expansionDomId = (row) => `${expansionIdPrefix}-${row.id}`;
568
+ // The expand toggle of a row, shared by table and card layout. Named after
569
+ // the first data column, the one a user recognizes the row by.
570
+ function renderExpandToggle(row) {
571
+ if (!isRowExpandable)
572
+ return null;
573
+ const expanded = expandedRowIds?.has(row.id) === true;
574
+ const firstColumn = columns[0];
575
+ const title = (firstColumn !== undefined ? cellTitle(row.values[firstColumn.field]) : undefined) ?? "";
576
+ const labelKey = expanded ? "kumiko.list.row.collapse" : "kumiko.list.row.expand";
577
+ return (_jsx(RowExpandToggle, { expanded: expanded, controlsId: expansionDomId(row), label: tableTranslate?.(labelKey, { title }) ?? `${expanded ? "Collapse" : "Expand"} ${title}`, onToggle: () => onToggleRowExpanded(row.id), testId: `${rowTestId(row)}-toggle` }));
578
+ }
579
+ function renderExpansion(row, as, colSpan) {
580
+ if (!isRowExpandable || expandedRowIds?.has(row.id) !== true)
581
+ return null;
582
+ const content = renderExpandedRow(row);
583
+ if (as === "item") {
584
+ return (_jsx("li", { id: expansionDomId(row), "data-testid": `${rowTestId(row)}-expansion`, className: "border-b border-border-row bg-muted px-4 pt-1 pb-3.5", children: content }));
585
+ }
586
+ return (_jsx(TableRow, { id: expansionDomId(row), "data-testid": `${rowTestId(row)}-expansion`, className: "hover:bg-transparent", children: _jsx(TableCell, { colSpan: colSpan, className: EXPANSION_CELL_CLASS, children: _jsx("div", { className: EXPANSION_CONTENT_CLASS, children: content }) }) }));
587
+ }
588
+ const fillsHeight = scrollBody === true;
589
+ const TableRoot = fillsHeight ? FlushTable : Table;
590
+ const emptyBlock = (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-empty` : "render-list-empty", className: cn("flex flex-col items-center justify-center p-12 text-sm text-muted-foreground gap-3", chromeless !== true && !fillsHeight && "rounded-md border border-dashed"), children: emptyState ?? _jsx("span", { children: tableTranslate?.("kumiko.list.no-entries") ?? "No entries." }) }));
591
+ const tableItems = buildTableItems(rows, rowGrouping, toggledGroups);
592
+ function renderGroupToggle(item) {
593
+ return (_jsxs("button", { type: "button", "aria-expanded": !item.collapsed, "data-testid": `row-group-${item.key}-toggle`, onClick: () => setToggledGroups((prev) => {
594
+ const next = new Set(prev);
595
+ if (!next.delete(item.key))
596
+ next.add(item.key);
597
+ return next;
598
+ }), className: "flex w-full items-center gap-2 px-4 py-2 text-left text-xs font-medium text-foreground-secondary", children: [item.collapsed ? (_jsx(ChevronRight, { className: "size-3.5" })) : (_jsx(ChevronDown, { className: "size-3.5" })), item.label] }));
599
+ }
600
+ function tableInner() {
601
+ if (isEmpty)
602
+ return emptyBlock;
603
+ return (_jsx("div", { className: cn(!fillsHeight && "overflow-hidden", !fillsHeight && chromeless !== true && "rounded-lg border bg-card"), children: _jsxs(TableRoot, { "data-testid": testId, className: fillsHeight ? FILL_TABLE_CLASS : undefined, children: [_jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { className: "hover:bg-transparent", children: [isRowExpandable && (_jsx(TableHead, { "data-testid": "column-expand", className: EXPAND_TOGGLE_CELL_CLASS })), columns.map((col) => (_jsx(SortableHeader, { field: col.field, label: col.label, sortable: col.sortable === true, highlighted: col.highlighted === true, numeric: NUMERIC_COLUMN_TYPES.has(col.type), fitContent: col.fileDisplay !== undefined, ...(sort !== undefined && sort !== null && { sort }), ...(onSortChange !== undefined && { onSortChange }) }, col.field))), hasTableActions && (_jsx(TableHead, { "data-testid": "column-actions",
604
+ // From md: sticky right-0 + bg-muted (= header tone) so the
605
+ // action column stays at the right edge during horizontal
606
+ // scroll on wider screens. Below md, actions scroll in
607
+ // normal flow — sticky there would pin the column over the
608
+ // preceding data column instead of the (already narrow)
609
+ // page edge. No border-l: a permanent divider looks heavy;
610
+ // the sticky bg already sets the column apart during scroll.
611
+ className: "w-px text-right text-muted-foreground md:sticky md:right-0 md:z-10 md:bg-muted", "aria-label": tableTranslate?.("kumiko.list.actions") ?? "Actions" }))] }) }), _jsx(TableBody, { children: tableItems.map((item) => {
612
+ if (item.kind === "group") {
613
+ return (_jsx(TableRow, { "data-testid": `row-group-${item.key}`, children: _jsx(TableCell, { colSpan: columns.length + (isRowExpandable ? 1 : 0) + (hasTableActions ? 1 : 0), className: "bg-muted/40 p-0", children: renderGroupToggle(item) }) }, `group-${item.key}`));
614
+ }
615
+ const row = item.row;
616
+ const opensOnClick = rowIsLink && !rowClickIsHidden(row);
617
+ return (_jsxs(Fragment, { children: [_jsxs(TableRow, { "data-testid": rowTestId(row), "data-tone": rowTone?.(row), onClick: opensOnClick ? () => onRowClick(row) : undefined, className: cn(rowToneClass(rowTone?.(row)), opensOnClick && "cursor-pointer", fillsHeight && "h-10 border-border-row hover:bg-muted"), children: [isRowExpandable && (_jsx(TableCell, { "data-testid": getCellTestId?.(row, "expand") ?? `cell-${row.id}-expand`, className: EXPAND_TOGGLE_CELL_CLASS, children: renderExpandToggle(row) })), columns.map((col, colIndex) => (_jsx(TableCell, { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, "data-highlighted": col.highlighted === true ? "true" : undefined,
618
+ // Cells truncate long values with ellipsis instead of
619
+ // wrapping — lists stay single-line + scannable (Linear
620
+ // pattern). max-w-xs gives a sensible default upper
621
+ // bound; the table container scrolls horizontally
622
+ // if the sum of the columns gets too wide.
623
+ className: cn(col.fileDisplay === undefined
624
+ ? "max-w-xs truncate"
625
+ : FIT_CONTENT_CELL_CLASS, colIndex === 0
626
+ ? "font-medium text-foreground"
627
+ : "text-foreground-secondary", NUMERIC_COLUMN_TYPES.has(col.type) && "text-right", TABULAR_COLUMN_TYPES.has(col.type) && "tabular-nums", col.highlighted === true && "bg-accent/40"), title: cellTitle(row.values[col.field]), children: _jsx(FirstCellLink, { enabled: colIndex === 0 && opensOnClick && onCellChange === undefined, onOpen: () => onRowClick?.(row), empty: isEmptyCellValue(row.values[col.field]), emptyLabel: rowClickLabel, children: _jsx(DataTableCell, { value: row.values[col.field], row: row.values, field: col.field, type: col.type, renderer: col.renderer, translate: tableTranslate, locale: tableLocale, ...(col.optionLabels !== undefined && {
628
+ optionLabels: col.optionLabels,
629
+ }), ...(col.optionTones !== undefined && { optionTones: col.optionTones }), ...(col.grouping !== undefined && { grouping: col.grouping }), ...(col.fileDisplay !== undefined && { fileDisplay: col.fileDisplay }), ...(onCellChange !== undefined && {
630
+ onChange: (value) => onCellChange(row.id, col.field, value),
631
+ }), ...cellLinkProp(col, row, !(colIndex === 0 && opensOnClick && onCellChange === undefined)) }) }) }, col.field))), hasTableActions && (_jsx(TableCell, { "data-testid": getCellTestId?.(row, "actions") ?? `cell-${row.id}-actions`,
632
+ // From md: sticky-right so the actions stay visible on the
633
+ // right edge during horizontal scroll. Below md, actions
634
+ // scroll with the row like any other cell — sticky there
635
+ // would hide the neighboring data column on narrow
636
+ // viewports. bg-background sets the column apart during
637
+ // scroll — no border-l (divider too heavy).
638
+ className: cn("text-right md:sticky md:right-0 md:z-10 md:bg-background", fillsHeight && "md:bg-card"),
639
+ // Action-cell events must not trigger the row click/activation
640
+ // (typically "Open Detail" — the user wanted the action,
641
+ // not navigation). We stopPropagation for mouse and
642
+ // keyboard so a11y stays consistent.
643
+ onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(RowActionsCell, { row: row, actions: tableActions, mode: rowActionMode, rowIsLink: rowIsLink }) }))] }), renderExpansion(row, "row", columns.length + (isRowExpandable ? 1 : 0) + (hasTableActions ? 1 : 0))] }, row.id));
644
+ }) })] }) }));
645
+ }
646
+ // No column headers below the breakpoint, so the click-to-sort affordance
647
+ // on SortableHeader has nothing to attach to. A single native <select> is
648
+ // the whole fix — no custom widget, no menu — fed straight from the
649
+ // columns marked sortable in the ViewModel. Options carry the resolved
650
+ // {field, dir} directly so onChange can look the pick up by value instead
651
+ // of parsing/casting the option string back apart.
652
+ const sortableColumns = columns.filter((col) => col.sortable);
653
+ const sortOptions = sortableColumns.flatMap((col) => [
654
+ { value: `${col.field}:asc`, field: col.field, dir: "asc", label: `${col.label} ↑` },
655
+ { value: `${col.field}:desc`, field: col.field, dir: "desc", label: `${col.label} ↓` },
656
+ ]);
657
+ const { image: cardImageColumn, title: cardTitleColumn, status: cardStatusColumn, meta: cardMetaColumns, } = cardColumnRoles(columns);
658
+ function cardCell(row, col) {
659
+ // A bare check mark in the subtitle line says nothing without its column.
660
+ if (showsBareCheckMark(col.type, row.values[col.field], col.renderer))
661
+ return col.label;
662
+ return (_jsx(DataTableCell, { value: row.values[col.field], row: row.values, field: col.field, type: col.type, renderer: col.renderer, translate: tableTranslate, locale: tableLocale, ...(col.optionLabels !== undefined && { optionLabels: col.optionLabels }), ...(col.optionTones !== undefined && { optionTones: col.optionTones }), ...(col.grouping !== undefined && { grouping: col.grouping }), ...(col.fileDisplay !== undefined && { fileDisplay: col.fileDisplay, compactFile: true }), ...(onCellChange !== undefined && {
663
+ onChange: (value) => onCellChange(row.id, col.field, value),
664
+ }), ...cellLinkProp(col, row, !(rowIsLink && !rowClickIsHidden(row))) }));
665
+ }
666
+ function renderCard(row) {
667
+ const metaColumns = cardMetaColumns.filter((col) => !isEmptyCellValue(row.values[col.field]) &&
668
+ !isUnlabeledFalse(col.type, row.values[col.field], col.renderer));
669
+ const showStatus = cardStatusColumn !== undefined && !isEmptyCellValue(row.values[cardStatusColumn.field]);
670
+ const hasMenu = menuActions !== undefined && menuActions.length > 0;
671
+ const opensOnClick = rowIsLink && !rowClickIsHidden(row);
672
+ const body = (_jsxs(_Fragment, { children: [cardImageColumn?.fileDisplay !== undefined && (_jsx("div", { "data-testid": `card-image-${row.id}`, className: "shrink-0", children: _jsx(CardLeadingImage, { value: row.values[cardImageColumn.field], display: cardImageColumn.fileDisplay }) })), _jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1", children: [_jsxs("div", { className: "flex min-w-0 items-start justify-between gap-2", children: [cardTitleColumn !== undefined && (_jsx("span", { "data-testid": getCellTestId?.(row, cardTitleColumn.field) ??
673
+ `cell-${row.id}-${cardTitleColumn.field}`, className: "min-w-0 truncate text-base font-semibold text-foreground", children: cardCell(row, cardTitleColumn) })), showStatus && (_jsx("span", { "data-testid": getCellTestId?.(row, cardStatusColumn.field) ??
674
+ `cell-${row.id}-${cardStatusColumn.field}`, className: "shrink-0", children: cardCell(row, cardStatusColumn) }))] }), metaColumns.length > 0 && (_jsx("div", { className: "min-w-0 overflow-x-clip", children: _jsx("div", { "data-testid": `card-meta-${row.id}`, className: "-ms-3 flex min-w-0 flex-wrap items-center text-[13px] leading-5 tabular-nums text-foreground-secondary", children: metaColumns.map((col) => (_jsxs("div", { className: "flex h-5 min-w-0 max-w-full items-center", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 shrink-0 text-center", children: "\u00B7" }), _jsx("div", { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, className: "min-w-0 truncate leading-5", onPointerEnter: showFullTextOnHover, children: isBadgeColumn(col) ? (_jsx("span", { className: "inline-flex align-middle", children: cardCell(row, col) })) : (cardCell(row, col)) })] }, col.field))) }) }))] }), opensOnClick && (_jsx(ChevronRight, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" }))] }));
675
+ const rowItem = (_jsxs("li", { "data-testid": rowTestId(row), "data-tone": rowTone?.(row), className: cn("flex min-h-[72px] items-center gap-2 border-b border-border-row py-3", rowToneClass(rowTone?.(row)), isRowExpandable ? "pl-2" : "pl-4", hasMenu ? "pr-2" : "pr-4"), ...(opensOnClick && { onClick: () => onRowClick(row) }), children: [renderExpandToggle(row), opensOnClick ? (_jsx("button", { type: "button", className: "flex min-w-0 flex-1 cursor-pointer items-center gap-2 bg-transparent p-0 text-left", onKeyDown: (e) => {
676
+ if (e.key !== "Enter")
677
+ return;
678
+ e.preventDefault();
679
+ e.stopPropagation();
680
+ onRowClick(row);
681
+ }, children: body })) : (body), hasMenu && (_jsx("div", { onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(RowActionsCell, { row: row, actions: menuActions, mode: rowActionMode, rowIsLink: rowIsLink, compact: true }) }))] }, row.id));
682
+ if (!isRowExpandable)
683
+ return rowItem;
684
+ // The expansion is a sibling <li>, outside the row's click target, so
685
+ // clicks in the sub-area never navigate the parent row.
686
+ return (_jsxs(Fragment, { children: [rowItem, renderExpansion(row, "item", 0)] }, row.id));
687
+ }
688
+ function cardsInner() {
689
+ if (isEmpty)
690
+ return emptyBlock;
691
+ const sortLabel = sort !== undefined && sort !== null
692
+ ? (tableTranslate?.("kumiko.list.sort.by", {
693
+ column: columns.find((col) => col.field === sort.field)?.label ?? sort.field,
694
+ }) ?? `Sorted by ${sort.field}`)
695
+ : (tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted");
696
+ const SortArrow = sort?.dir === "desc" ? ArrowDown : ArrowUp;
697
+ // URL state can carry a sort on a column that is not sortable; without an
698
+ // option for it the select would claim "Unsorted" while the list is sorted.
699
+ const currentSortValue = sort !== undefined && sort !== null ? `${sort.field}:${sort.dir}` : "";
700
+ const currentSortIsUnlisted = sort !== undefined && sort !== null && !sortOptions.some((o) => o.value === currentSortValue);
701
+ return (_jsx("div", { "data-testid": testId, className: "contents", children: _jsxs("div", { "data-testid": testId !== undefined ? `${testId}-cards` : "render-list-cards", className: "flex flex-col", children: [onSortChange !== undefined && sortableColumns.length > 0 && (_jsxs("div", { className: "relative flex h-9 items-center gap-1.5 px-4 text-[13px] text-foreground-secondary", children: [_jsx("span", { "aria-hidden": "true", children: sortLabel }), sort !== undefined && sort !== null && (_jsx(SortArrow, { className: "size-3.5", "aria-hidden": "true" })), _jsxs("select", { "aria-label": tableTranslate?.("kumiko.list.sort.label") ?? "Sort", "data-testid": testId !== undefined ? `${testId}-sort` : "render-list-sort", value: currentSortValue, onChange: (e) => {
702
+ const raw = e.target.value;
703
+ if (raw === "") {
704
+ onSortChange(null);
705
+ return;
706
+ }
707
+ const picked = sortOptions.find((o) => o.value === raw);
708
+ if (picked === undefined)
709
+ return;
710
+ onSortChange({ field: picked.field, dir: picked.dir });
711
+ }, className: "absolute inset-0 size-full cursor-pointer opacity-0", children: [_jsx("option", { value: "", children: tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted" }), currentSortIsUnlisted && (_jsx("option", { value: currentSortValue, children: `${columns.find((col) => col.field === sort.field)?.label ?? sort.field} ${sort.dir === "desc" ? "↓" : "↑"}` })), sortOptions.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value)))] })] })), _jsx("ul", { className: "m-0 list-none border-t border-border-row p-0", children: tableItems.map((item) => item.kind === "group" ? (_jsx("li", { "data-testid": `row-group-${item.key}`, className: "border-b border-border-row bg-muted/40", children: renderGroupToggle(item) }, `group-${item.key}`)) : (renderCard(item.row))) })] }) }));
712
+ }
713
+ const tableContent = isNarrow ? cardsInner() : tableInner();
714
+ // Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
715
+ // damit der User bei einem Filter-Hit-of-Zero zurückblättern kann
716
+ // ohne die Liste zu verlieren. Außer total === 0 — dann gibt's
717
+ // nichts zu paginieren. Inkompatibel mit Infinite-Scroll: Caller
718
+ // setzt entweder pager ODER onReachEnd.
719
+ const pagerElement = pager !== undefined && pager.total > 0 ? (_jsx(Pager, { page: pager.page, limit: pager.limit, total: pager.total, onPageChange: pager.onPageChange, ...(pager.pageSizeOptions !== undefined && { pageSizeOptions: pager.pageSizeOptions }), ...(pager.onPageSizeChange !== undefined && { onPageSizeChange: pager.onPageSizeChange }), ...(itemNoun !== undefined && { itemNoun }), testId: testId !== undefined ? `${testId}-pager` : "render-list-pager", pinned: fillsHeight })) : undefined;
720
+ const sentinelElement = pagerElement === undefined && onReachEnd !== undefined ? (_jsx(InfiniteSentinel, { onReachEnd: onReachEnd, loadingMore: loadingMore === true, hasMore: hasMore !== false, rowsCount: rows.length, testId: testId !== undefined ? `${testId}-sentinel` : "render-list-sentinel" })) : undefined;
721
+ // With scrollBody the footer (pager, else the entry count) sits OUTSIDE the
722
+ // scroll surface so it stays put at any row count; the infinite sentinel
723
+ // stays INSIDE, or it would never intersect the scrollport.
724
+ const content = fillsHeight ? (_jsxs(_Fragment, { children: [_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : "render-list-scroll", className: "min-h-0 flex-1 overflow-auto border-t border-border bg-card", children: [tableContent, sentinelElement] }), pagerElement ?? (_jsx(DataTableFooter, { count: rows.length, ...(itemNoun !== undefined && { itemNoun }), testId: testId !== undefined ? `${testId}-footer` : "render-list-footer" }))] })) : (_jsxs(_Fragment, { children: [tableContent, pagerElement, sentinelElement] }));
725
+ const hasFacets = filterFacets !== undefined && filterFacets.length > 0 && onFilterChange !== undefined;
726
+ const hasDateRangeFacets = dateRangeFacets !== undefined && dateRangeFacets.length > 0 && onDateRangeChange !== undefined;
727
+ const hasActiveFilters = filterValues !== undefined && Object.values(filterValues).some((v) => v.length > 0);
728
+ const facetCluster = hasFacets || hasDateRangeFacets ? (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [hasFacets &&
729
+ filterFacets.map((facet) => (_jsx(FacetFilter, { facet: facet, selected: filterValues?.[facet.field] ?? [], onChange: onFilterChange, dense: fillsHeight }, facet.field))), hasDateRangeFacets &&
730
+ dateRangeFacets.map((facet) => (_jsx(DateRangeFacetFilter, { facet: facet, onChange: onDateRangeChange }, facet.field))), hasActiveFilters && onFilterReset !== undefined && (_jsxs(UiButton, { variant: "ghost", size: "sm", className: "h-9 px-2", onClick: onFilterReset, "data-testid": "facet-reset", children: [tableTranslate?.("kumiko.list.filter.reset") ?? "Reset", _jsx(X, {})] }))] })) : undefined;
731
+ const collapsesFacetsBehindButton = isNarrow && fillsHeight && facetCluster !== undefined;
732
+ const hasToolbar = toolbarStart !== undefined || toolbarEnd !== undefined || facetCluster !== undefined;
733
+ // dashboard-01-Muster: die Toolbar (Search + Facets + "+ Neu") sitzt ÜBER
734
+ // der Tabelle im selben Padding-Block — kein separater bg-Bar, kein Screen-
735
+ // Titel (der steht im Breadcrumb der Shell).
736
+ return (_jsxs("div", { className: cn("flex flex-col w-full",
737
+ // scrollBody: the wrapper fills its container so the footer sits at the
738
+ // same spot at 3 and 300 rows; the 24px insets live in toolbar, cells
739
+ // and footer, so no outer padding.
740
+ fillsHeight
741
+ ? "h-full min-h-0"
742
+ : cn("gap-4", screenPadding === true
743
+ ? screenPaddingClassName
744
+ : screenPadding === false
745
+ ? undefined
746
+ : "p-6")), children: [hasToolbar && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar", className: cn("flex flex-wrap items-center", fillsHeight
747
+ ? isNarrow
748
+ ? "shrink-0 gap-2 p-3"
749
+ : "min-h-13 shrink-0 gap-2 px-6 py-2 md:py-0"
750
+ : "gap-3"), children: [toolbarStart !== undefined && (_jsxs("div", { className: cn(fillsHeight
751
+ ? "relative w-full md:w-[300px] max-md:flex-1 [&_input]:border-border-strong [&_input]:pl-8 [&_input]:max-md:h-11 md:[&_input]:h-8"
752
+ : "flex-1 min-w-48 max-w-sm"), children: [fillsHeight && (_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 z-10 size-4 -translate-y-1/2 text-muted-foreground", "aria-hidden": "true" })), toolbarStart] })), collapsesFacetsBehindButton ? (_jsxs(_Fragment, { children: [_jsx(UiButton, { type: "button", variant: "outline", size: "icon", className: "size-11", "aria-label": tableTranslate?.("kumiko.list.filter.toggle") ?? "Filter", "aria-expanded": facetsOpenNarrow, "data-testid": testId !== undefined ? `${testId}-filter-toggle` : "filter-toggle", onClick: () => setFacetsOpenNarrow((open) => !open), children: _jsx(SlidersHorizontal, { className: "size-4", "aria-hidden": "true" }) }), facetsOpenNarrow && _jsx("div", { className: "w-full", children: facetCluster })] })) : (facetCluster), toolbarEnd !== undefined && (_jsx("div", { className: "flex flex-wrap items-center gap-2 ml-auto", children: toolbarEnd }))] })), toolbarDescription !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-description` : "render-list-description", className: cn("m-0 pb-2 text-sm text-muted-foreground", fillsHeight && (isNarrow ? "px-3" : "px-6")), children: toolbarDescription })), content] }));
753
+ }
754
+ // The primary row action is the one that always stays a visible text
755
+ // button — `edit` if declared, else the first visible action (fw
756
+ // bedienkonzept L3: "Bearbeiten steht in jeder Zeile, immer, als Text").
757
+ function primaryRowAction(actions) {
758
+ return actions.find((a) => a.id === "edit") ?? actions[0];
759
+ }
760
+ // RowActionsCell renders the row actions depending on `mode`:
761
+ // - "adaptive" (default): when the row itself is the link (`rowIsLink`)
762
+ // every action sits in the kebab; otherwise the primary action (see
763
+ // `primaryRowAction`) is a link-button and the rest goes into the kebab.
764
+ // A single action never gets a kebab.
765
+ // - "inline": ALWAYS inline buttons, left-aligned + full-width, even for
766
+ // >2 (no kebab). `w-full justify-start` pins the first button to the
767
+ // column's left edge so it sits at the same position across rows
768
+ // (otherwise it would drift with differently-sized labels).
769
+ // The isVisible filter runs here; an action hidden for a given row never
770
+ // reaches render. If every action is hidden, the cell stays empty (no
771
+ // phantom column).
772
+ function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, compact = false, }) {
773
+ const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
774
+ if (visible.length === 0)
775
+ return null;
776
+ const hasExplicitDisplay = visible.some((a) => a.display !== undefined);
777
+ if (mode === "inline") {
778
+ // Teil C: >2 actions all carrying an icon collapse to icon-only —
779
+ // otherwise "inline" is exactly the wall-to-wall text-button problem
780
+ // this feature exists to fix. An author-set `display` takes that choice over.
781
+ const iconOnly = !hasExplicitDisplay && shouldRenderActionsIconOnly(visible);
782
+ return (_jsx("div", { className: "flex w-full items-center gap-1 justify-start", children: visible.map((a) => (_jsx(RowActionButton, { row: row, action: a, iconOnly: iconOnly, compact: compact }, a.id))) }));
783
+ }
784
+ if (hasExplicitDisplay) {
785
+ const inline = visible.filter((a) => a.display !== undefined);
786
+ const rest = visible.filter((a) => a.display === undefined);
787
+ return (_jsxs("div", { className: "inline-flex items-center gap-1 justify-end", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a, compact: compact }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
788
+ }
789
+ if (rowIsLink) {
790
+ return (_jsx("div", { className: "inline-flex items-center justify-end", children: _jsx(RowActionsKebab, { row: row, actions: visible }) }));
791
+ }
792
+ const primary = primaryRowAction(visible);
793
+ const rest = visible.filter((a) => a.id !== primary?.id);
794
+ return (_jsxs("div", { className: "inline-flex items-center gap-1 justify-end", children: [primary !== undefined && _jsx(RowActionButton, { row: row, action: primary, asLink: true }), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
795
+ }
796
+ function isEmptyCellValue(value) {
797
+ return (value === null || value === undefined || (typeof value === "string" && value.trim() === ""));
798
+ }
799
+ // A boolean false renders as an empty string unless the column's format spec names a falseLabel.
800
+ // Projection lists synthesize every column as type "text", so a boolean format spec
801
+ // marks a boolean column on its own.
802
+ function isBooleanColumn(type, renderer) {
803
+ if (type === "boolean")
804
+ return true;
805
+ return (typeof renderer === "object" &&
806
+ renderer !== null &&
807
+ "format" in renderer &&
808
+ renderer.format === "boolean");
809
+ }
810
+ function isUnlabeledFalse(type, value, renderer) {
811
+ if (value !== false || !isBooleanColumn(type, renderer))
812
+ return false;
813
+ if (renderer === undefined)
814
+ return true;
815
+ return (typeof renderer === "object" &&
816
+ renderer !== null &&
817
+ "format" in renderer &&
818
+ renderer.format === "boolean" &&
819
+ !("falseLabel" in renderer));
820
+ }
821
+ function isUnlabeledBooleanFormat(renderer) {
822
+ return (typeof renderer === "object" &&
823
+ renderer !== null &&
824
+ "format" in renderer &&
825
+ renderer.format === "boolean" &&
826
+ !("trueLabel" in renderer) &&
827
+ !("falseLabel" in renderer));
828
+ }
829
+ function BooleanCell({ value, translate, }) {
830
+ const label = value
831
+ ? (translate?.("kumiko.boolean.yes") ?? "Yes")
832
+ : (translate?.("kumiko.boolean.no") ?? "No");
833
+ return (_jsxs("span", { "data-boolean-cell": value ? "true" : "false", children: [value ? (_jsx(Check, { "aria-hidden": "true", className: "h-4 w-4" })) : (_jsx("span", { "aria-hidden": "true", className: "text-muted-foreground", children: EMPTY_VALUE_PLACEHOLDER })), _jsx("span", { className: "sr-only", children: label })] }));
834
+ }
835
+ // True when the cell would render the default "✓": no renderer, or a boolean
836
+ // format spec that names no trueLabel of its own.
837
+ function showsBareCheckMark(type, value, renderer) {
838
+ if (value !== true || !isBooleanColumn(type, renderer))
839
+ return false;
840
+ if (renderer === undefined)
841
+ return true;
842
+ return (typeof renderer === "object" &&
843
+ renderer !== null &&
844
+ "format" in renderer &&
845
+ !("trueLabel" in renderer));
846
+ }
847
+ // Badges (select pills, component renderers) must never be clipped by the card's meta truncation.
848
+ function isBadgeColumn(col) {
849
+ return (col.type === "select" ||
850
+ col.type === "multiSelect" ||
851
+ isComponentRendererRef(col.renderer) !== undefined);
852
+ }
853
+ // The first cell is the keyboard-reachable entry point of a clickable row; the
854
+ // row's own onClick keeps serving the mouse. Enter is handled explicitly so
855
+ // it also opens the row where a button does not synthesize a click.
856
+ function FirstCellLink({ enabled, empty, emptyLabel, onOpen, children, }) {
857
+ if (!enabled || (empty && emptyLabel === undefined))
858
+ return children;
859
+ return (_jsx("button", { type: "button", className: "max-w-full cursor-pointer truncate bg-transparent p-0 text-left font-medium text-foreground hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", onClick: (e) => {
860
+ e.stopPropagation();
861
+ onOpen();
862
+ }, onKeyDown: (e) => {
863
+ if (e.key !== "Enter")
864
+ return;
865
+ e.preventDefault();
866
+ e.stopPropagation();
867
+ onOpen();
868
+ }, children: empty ? _jsx("span", { className: "sr-only", children: emptyLabel }) : children }));
869
+ }
870
+ // Shared trigger-State zwischen Inline-Button + Kebab-Item: busy-Flag
871
+ // (während async onTrigger läuft) + confirm-pending-Action. Beide Sub-
872
+ // Components hatten denselben State-Block dupliziert + parallel zur
873
+ // Confirm-Dialog-Render-Logic — der Hook konsolidiert das.
874
+ function useRowActionTrigger(row) {
875
+ const [busy, setBusy] = useState(false);
876
+ const { toast } = useToast();
877
+ const t = useTranslation();
878
+ const triggerNow = async (action) => {
879
+ setBusy(true);
880
+ try {
881
+ await action.onTrigger(row);
882
+ }
883
+ catch (e) {
884
+ // Surfacing statt schlucken: ein verschluckter Write-Fehler sah für
885
+ // den User wie "nichts passiert" aus (Prod-Bug 2026-06-07).
886
+ const docsUrl = e instanceof WriteFailedError ? e.dispatcherError.docsUrl : undefined;
887
+ toast({
888
+ title: t("kumiko.rowAction.failed"),
889
+ description: e instanceof Error ? e.message : String(e),
890
+ variant: "bad",
891
+ ...(docsUrl !== undefined && { docsUrl }),
892
+ });
893
+ }
894
+ finally {
895
+ setBusy(false);
896
+ }
897
+ };
898
+ return { busy, triggerNow };
899
+ }
900
+ // An icon-only form without a resolvable icon would render an empty button.
901
+ // `compact` is the narrow card layout, where "responsive" collapses to its icon.
902
+ function effectiveRowActionDisplay(display, hasIcon, compact) {
903
+ if (display === "responsive")
904
+ return hasIcon && compact ? "icon" : "button";
905
+ if (display === "icon" && !hasIcon)
906
+ return "link";
907
+ return display;
908
+ }
909
+ function RowActionButton({ row, action, iconOnly = false, asLink = false, compact = false, }) {
910
+ const { busy, triggerNow } = useRowActionTrigger(row);
911
+ const [confirmOpen, setConfirmOpen] = useState(false);
912
+ const resolvedIcon = actionIconFor(action.icon);
913
+ const display = effectiveRowActionDisplay(action.display, resolvedIcon !== undefined, compact);
914
+ const isBorderedButton = display === "button";
915
+ const showIconOnly = display === "icon" || (display === undefined && iconOnly && resolvedIcon !== undefined);
916
+ const linkLike = display === "link" || (display === undefined && asLink);
917
+ const variantClass = isBorderedButton
918
+ ? action.style === "primary"
919
+ ? "border border-transparent bg-primary text-primary-foreground hover:bg-primary/90"
920
+ : action.style === "danger"
921
+ ? "border border-destructive/40 bg-card text-destructive hover:bg-destructive/10"
922
+ : "border border-border-strong bg-card text-foreground hover:bg-accent"
923
+ : action.style === "danger"
924
+ ? "text-destructive hover:bg-destructive/10"
925
+ : linkLike
926
+ ? "font-medium text-primary hover:bg-muted"
927
+ : action.style === "primary"
928
+ ? "text-primary hover:bg-primary/10"
929
+ : "text-foreground hover:bg-accent";
930
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-testid": `row-${row.id}-action-${action.id}`, disabled: busy, ...(showIconOnly && { "aria-label": action.label, title: action.label }), onClick: (e) => {
931
+ e.stopPropagation();
932
+ if (needsActionConfirm(action)) {
933
+ setConfirmOpen(true);
934
+ }
935
+ else {
936
+ void triggerNow(action);
937
+ }
938
+ }, className: cn("inline-flex items-center justify-center gap-1.5 text-sm", isBorderedButton
939
+ ? "h-8 rounded-md px-3 font-medium max-md:h-11"
940
+ : linkLike
941
+ ? "h-7 rounded-md px-2.5 max-md:h-11"
942
+ : "h-8 rounded-sm max-md:h-11", showIconOnly ? "w-8 max-md:w-11" : !linkLike && !isBorderedButton && "px-2", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:opacity-50 disabled:pointer-events-none", variantClass), children: busy ? (_jsx(Loader2, { className: "size-3.5 animate-spin", "aria-hidden": "true" })) : resolvedIcon === undefined || (linkLike && !showIconOnly) ? (action.label) : showIconOnly ? (_jsx(Icon, { name: resolvedIcon, className: "size-4" })) : (_jsxs(_Fragment, { children: [_jsx(Icon, { name: resolvedIcon, className: "size-4" }), action.label] })) }), _jsx(DefaultDialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: () => triggerNow(action), testId: `row-${row.id}-action-${action.id}-dialog` })] }));
943
+ }
944
+ // Kebab dropdown for >2 actions, one inline confirm dialog per item like
945
+ // the inline-button path. Menu is controlled so it closes explicitly on
946
+ // select, instead of relying on Radix's auto-close, which preventDefault
947
+ // below blocks.
948
+ function RowActionsKebab({ row, actions, }) {
949
+ const { triggerNow } = useRowActionTrigger(row);
950
+ const t = useTranslation();
951
+ const [pendingConfirm, setPendingConfirm] = useState(null);
952
+ const [menuOpen, setMenuOpen] = useState(false);
953
+ return (_jsxs(_Fragment, { children: [_jsxs(DropdownMenu, { open: menuOpen, onOpenChange: setMenuOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("kumiko.list.row-actions.more"), "data-testid": `row-${row.id}-actions-menu`, className: cn("inline-flex size-7 items-center justify-center rounded-md max-md:size-11", "hover:bg-accent text-muted-foreground hover:text-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"), children: _jsx(MoreHorizontal, { className: "size-4", "aria-hidden": "true" }) }) }), _jsx(DropdownMenuContent, { align: "end",
954
+ // Radix refocuses the trigger after the exit animation, which by then sits behind the confirm dialog's aria-hidden.
955
+ onCloseAutoFocus: (e) => {
956
+ if (pendingConfirm !== null)
957
+ e.preventDefault();
958
+ }, children: actions.map((action) => (_jsx(DropdownMenuItem, { "data-testid": `row-${row.id}-action-${action.id}`, onSelect: (e) => {
959
+ e.preventDefault();
960
+ setMenuOpen(false);
961
+ if (needsActionConfirm(action)) {
962
+ setPendingConfirm(action);
963
+ }
964
+ else {
965
+ void triggerNow(action);
966
+ }
967
+ }, className: cn(action.style === "danger" && "text-destructive focus:text-destructive"), children: action.label }, action.id))) })] }), pendingConfirm !== null && (_jsx(DefaultDialog, { open: true, onOpenChange: (open) => {
968
+ if (!open)
969
+ setPendingConfirm(null);
970
+ }, title: pendingConfirm.label, ...(pendingConfirm.confirm !== undefined && { description: pendingConfirm.confirm }), confirmLabel: pendingConfirm.confirmLabel ?? pendingConfirm.label, ...(pendingConfirm.style === "danger" && { variant: "danger" }), onConfirm: async () => {
971
+ const action = pendingConfirm;
972
+ setPendingConfirm(null);
973
+ await triggerNow(action);
974
+ }, testId: `row-${row.id}-action-${pendingConfirm.id}-dialog` }))] }));
975
+ }
976
+ // Header/row-actions overflow menu (A7): the trigger carries the classes of a
977
+ // secondary icon Button so it matches neighbouring icon buttons, including
978
+ // the 44 px touch size on phones. Generic ActionMenuItemSpec items instead of
979
+ // the DataTableRowAction schema (callers own confirm/danger handling per item).
980
+ function ActionOverflowMenu({ items, label, testId }) {
981
+ const [open, setOpen] = useState(false);
982
+ return (_jsxs(DropdownMenu, { open: open, onOpenChange: setOpen, children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(UiButton, { type: "button", variant: "outline", size: "icon", className: SECONDARY_BUTTON_EXTRA_CLASSES, "aria-label": label, "data-testid": testId ?? "action-overflow-menu-trigger", children: _jsx(MoreHorizontal, { className: "size-4", "aria-hidden": "true" }) }) }), _jsx(DropdownMenuContent, { align: "end", children: items.map((item) => {
983
+ const resolvedIcon = actionIconFor(item.icon);
984
+ return (_jsxs(DropdownMenuItem, { "data-testid": item.testId ?? `${testId ?? "action-overflow-menu"}-item-${item.id}`, disabled: item.disabled === true, onSelect: (e) => {
985
+ e.preventDefault();
986
+ setOpen(false);
987
+ item.onSelect();
988
+ }, className: cn(item.variant === "danger" && "text-destructive focus:text-destructive"), children: [resolvedIcon !== undefined && _jsx(Icon, { name: resolvedIcon, className: "size-4" }), item.label] }, item.id));
989
+ }) })] }));
990
+ }
991
+ // InfiniteSentinel — empty div at the end of the table that uses
992
+ // IntersectionObserver to detect when the user scrolls near the list end.
993
+ // onReachEnd fires exactly once per "becomes visible" transition; the
994
+ // caller debounces via loadingMore (further visibility events are ignored
995
+ // while a page is loading). No observer during server-side render, no
996
+ // observer when hasMore=false — then the sentinel only shows the
997
+ // end-of-list hint via t("kumiko.list.end-of-list"), which requires a
998
+ // LocaleProvider in the tree (same requirement as Field/useRowActionTrigger).
999
+ // Without one, useTranslation() throws; with one that's missing the framework
1000
+ // defaults (fallbackBundles not including kumikoDefaultTranslations), the
1001
+ // user sees the raw key instead of translated text.
1002
+ // ponytail: threshold mirrors the framework default pageSize (kumiko-screen.tsx
1003
+ // `screen.pageSize ?? 50`) rather than the real per-screen pageSize, which isn't
1004
+ // threaded down to this component. A screen with a custom pageSize can still
1005
+ // under- or over-show the marker; thread pageSize through KumikoScreen →
1006
+ // RenderList → DataTable → InfiniteSentinel if that's needed (fw#1713).
1007
+ export const END_LABEL_MIN_ROWS = 50;
1008
+ function InfiniteSentinel({ onReachEnd, loadingMore, hasMore, rowsCount, testId, }) {
1009
+ const t = useTranslation();
1010
+ const ref = useRef(null);
1011
+ useEffect(() => {
1012
+ if (!hasMore)
1013
+ return;
1014
+ if (loadingMore)
1015
+ return;
1016
+ if (typeof IntersectionObserver === "undefined")
1017
+ return;
1018
+ const node = ref.current;
1019
+ if (node === null)
1020
+ return;
1021
+ const observer = new IntersectionObserver((entries) => {
1022
+ // Nur das erste sichtbar-Event pro Mount auslösen — wenn der
1023
+ // User weiter scrollt während noch geladen wird, hindert
1024
+ // loadingMore=true den useEffect dass er den Observer überhaupt
1025
+ // erst remountet.
1026
+ for (const entry of entries) {
1027
+ if (entry.isIntersecting) {
1028
+ onReachEnd();
1029
+ break;
1030
+ }
1031
+ }
1032
+ }, { rootMargin: "200px" });
1033
+ observer.observe(node);
1034
+ return () => observer.disconnect();
1035
+ }, [onReachEnd, loadingMore, hasMore]);
1036
+ const showEndLabel = !hasMore && rowsCount >= END_LABEL_MIN_ROWS;
1037
+ return (_jsx("div", { ref: ref, "data-testid": testId, className: cn("flex items-center justify-center text-sm text-muted-foreground", showEndLabel || loadingMore ? "py-4" : ""), children: showEndLabel ? (_jsx("span", { "data-testid": testId !== undefined ? `${testId}-end` : undefined, children: t("kumiko.list.end-of-list") })) : loadingMore ? (_jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": "true" })) : hasMore ? (_jsx("span", { "aria-hidden": "true", children: "\u00A0" })) : null }));
1038
+ }
1039
+ // Pager — status text on the left ("X – Y of Z"), "Page X of Y" and the
1040
+ // prev/next arrows on the right. No numbered page list: the board shows none.
1041
+ // `pinned` renders the footer bar of a scrollBody table (fixed height, top
1042
+ // border); unpinned it sits as a plain row below the table.
1043
+ //
1044
+ // Status text and aria-labels go through t(...), which requires a
1045
+ // LocaleProvider in the tree — same requirement as InfiniteSentinel above,
1046
+ // already established for DataTable consumers.
1047
+ function Pager({ page, limit, total, onPageChange, pageSizeOptions, onPageSizeChange, itemNoun, testId, pinned, }) {
1048
+ const t = useTranslation();
1049
+ const totalPages = Math.max(1, Math.ceil(total / limit));
1050
+ const safePage = Math.max(1, Math.min(page, totalPages));
1051
+ const from = (safePage - 1) * limit + 1;
1052
+ const to = Math.min(safePage * limit, total);
1053
+ return (_jsxs("div", { "data-testid": testId, className: pinned === true
1054
+ ? TABLE_FOOTER_BAR_CLASS
1055
+ : "mt-3 flex items-center gap-4 text-[13px] tabular-nums text-foreground-secondary", children: [_jsx("div", { "data-testid": testId !== undefined ? `${testId}-status` : undefined, className: "flex-1", children: itemNoun !== undefined
1056
+ ? t("kumiko.pager.status.noun", {
1057
+ from: from.toLocaleString(),
1058
+ to: to.toLocaleString(),
1059
+ total: total.toLocaleString(),
1060
+ noun: itemNoun(total),
1061
+ })
1062
+ : t("kumiko.pager.status", {
1063
+ from: from.toLocaleString(),
1064
+ to: to.toLocaleString(),
1065
+ total: total.toLocaleString(),
1066
+ }) }), pageSizeOptions !== undefined && onPageSizeChange !== undefined && (_jsxs("div", { className: "relative hidden md:block", children: [_jsx("select", { "aria-label": t("kumiko.pager.pageSizeLabel"), "data-testid": testId !== undefined ? `${testId}-page-size` : undefined, value: limit, onChange: (e) => onPageSizeChange(Number(e.target.value)), className: "h-7 appearance-none rounded-md border border-input bg-card pl-2 pr-7 text-[13px] text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", children: pageSizeOptions.map((size) => (_jsx("option", { value: size, children: t("kumiko.pager.pageSize", { size: size.toLocaleString() }) }, size))) }), _jsx(ChevronDown, { className: "pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 opacity-50", "aria-hidden": "true" })] })), _jsx("div", { className: "hidden md:block", children: t("kumiko.pager.pageOf", {
1067
+ page: safePage.toLocaleString(),
1068
+ pages: totalPages.toLocaleString(),
1069
+ }) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(PagerButton, { ariaLabel: t("kumiko.pager.previousPage"), disabled: safePage <= 1, onClick: () => onPageChange(safePage - 1), testId: testId !== undefined ? `${testId}-prev` : undefined, children: _jsx(ChevronLeft, { className: "size-3.5", "aria-hidden": "true" }) }), _jsx(PagerButton, { ariaLabel: t("kumiko.pager.nextPage"), disabled: safePage >= totalPages, onClick: () => onPageChange(safePage + 1), testId: testId !== undefined ? `${testId}-next` : undefined, children: _jsx(ChevronRight, { className: "size-3.5", "aria-hidden": "true" }) })] })] }));
1070
+ }
1071
+ function PagerButton({ children, onClick, ariaLabel, disabled, testId, }) {
1072
+ return (_jsx("button", { type: "button", onClick: onClick, disabled: disabled, "aria-label": ariaLabel, "data-testid": testId, className: cn("inline-flex size-11 items-center justify-center rounded-md border border-border md:size-7", "hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:pointer-events-none disabled:text-foreground-disabled"), children: children }));
1073
+ }
1074
+ // SortableHeader — rendert pro Spalte den th-Header, mit oder ohne
1075
+ // Click-Sort. Drei Pfade:
1076
+ // (a) sortable=false ODER kein onSortChange → plain Label, keine
1077
+ // Cursor-Interaktion (DataTable rein als View ohne Sort-Wiring).
1078
+ // (b) sortable=true + onSortChange → Header ist ein Button, klick
1079
+ // cycled asc → desc → null. aria-sort spiegelt den State.
1080
+ // (c) sortable=true im Schema, aber keine onSortChange-Prop → still
1081
+ // label-only, aber data-sortable=true bleibt damit Tests die
1082
+ // Schema-Sicht kennen.
1083
+ // w-px on an auto-layout table cell shrinks the column to its content (the
1084
+ // actions column relies on the same behaviour).
1085
+ const FIT_CONTENT_CELL_CLASS = "w-px";
1086
+ function SortableHeader({ field, label, sortable, highlighted, numeric, fitContent, sort, onSortChange, }) {
1087
+ const active = sort?.field === field ? sort : undefined;
1088
+ const ariaSort = active?.dir === "asc" ? "ascending" : active?.dir === "desc" ? "descending" : "none";
1089
+ if (!sortable || onSortChange === undefined) {
1090
+ return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": sortable === true ? true : undefined, "data-highlighted": highlighted === true ? "true" : undefined, className: cn("font-medium text-muted-foreground", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: label }));
1091
+ }
1092
+ const Icon = active?.dir === "asc" ? ArrowUp : active?.dir === "desc" ? ArrowDown : ArrowUpDown;
1093
+ const next = nextSortState(active?.dir, field);
1094
+ return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": "true", "data-highlighted": highlighted === true ? "true" : undefined, "aria-sort": ariaSort, className: cn(active !== undefined
1095
+ ? "font-semibold text-foreground"
1096
+ : "font-medium text-muted-foreground", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: _jsxs("button", { type: "button", onClick: () => onSortChange(next), className: cn("group/sort inline-flex h-8 items-center gap-1.5 rounded-sm px-2 -mx-2 text-sm [font-weight:inherit]", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", active !== undefined && "text-foreground"), children: [_jsx("span", { children: label }), _jsx(Icon, { "data-sort-icon": active === undefined ? "idle" : "active", className: cn("size-3.5", active === undefined &&
1097
+ "opacity-0 group-hover/sort:opacity-40 group-focus-visible/sort:opacity-40"), "aria-hidden": "true" })] }) }));
1098
+ }
1099
+ // 3-State-Toggle: kein Sort → asc → desc → kein Sort. Idiomatisch für
1100
+ // Power-User-Listen wo "ich will die Server-Default-Order zurück" eine
1101
+ // echte Aktion ist (statt unendlich asc↔desc zu togglen).
1102
+ function nextSortState(current, field) {
1103
+ if (current === undefined)
1104
+ return { field, dir: "asc" };
1105
+ if (current === "asc")
1106
+ return { field, dir: "desc" };
1107
+ return null;
1108
+ }
1109
+ // Type-guard für die `{ react: { __component: "Name" } }`-Form, in der
1110
+ // PlatformComponent-Renderer im Schema serialisiert ankommen. Schemas
1111
+ // reisen über die Wire (Server → Client), echte Component-Refs würden
1112
+ // das brechen — der String-Key ist die SSoT.
1113
+ export function isComponentRendererRef(renderer) {
1114
+ if (renderer === null || typeof renderer !== "object")
1115
+ return undefined;
1116
+ const reactBranch = propertyOf(renderer, "react");
1117
+ if (reactBranch === null || typeof reactBranch !== "object")
1118
+ return undefined;
1119
+ const component = propertyOf(reactBranch, "__component");
1120
+ if (typeof component !== "string" || component.length === 0)
1121
+ return undefined;
1122
+ return { name: component };
1123
+ }
1124
+ // applyFormatSpec re-exported from headless (platform-agnostic).
1125
+ export { applyFormatSpec };
1126
+ function isMoneyValue(value) {
1127
+ const currency = propertyOf(value, "currency");
1128
+ return (typeof value === "object" &&
1129
+ value !== null &&
1130
+ typeof propertyOf(value, "amount") === "number" &&
1131
+ typeof currency === "string" &&
1132
+ /^[A-Za-z]{3}$/.test(currency));
1133
+ }
1134
+ // Type-spezifische Default-Cell-Renderer. Author kann pro Spalte einen
1135
+ // expliziten renderer setzen (FormatSpec oder PlatformComponent); ohne
1136
+ // expliziten renderer fällt DataTableCell hier durch.
1137
+ //
1138
+ // - boolean → ✓ / leer
1139
+ // - timestamp/date → locale-formatiert (kein roher ISO-String)
1140
+ // - number/decimal/bigInt → locale-formatted via Intl.NumberFormat (fw#2160)
1141
+ // - select/multiSelect → human-readable (kebab-case → Title Case), multiSelect values joined with ", "
1142
+ // - money → { amount, currency } formatted via Intl (not "[object Object]")
1143
+ // - text/else → toString
1144
+ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
1145
+ // Deduplicated per column key only (not per screen): a once-per-field-name
1146
+ // hint, so a second screen reusing the same field name stays silent.
1147
+ const warnedTimestampColumns = new Set();
1148
+ // A raw ISO string in a "text" column means the author forgot `renderer:
1149
+ // { format: "timestamp" }` — guessing a format here could be wrong, so we
1150
+ // warn and leave the value as-is instead of auto-formatting.
1151
+ function warnMissingTimestampFormat(columnKey) {
1152
+ if (typeof process === "undefined" || process.env.NODE_ENV === "production")
1153
+ return;
1154
+ const key = columnKey ?? "<unknown column>";
1155
+ if (warnedTimestampColumns.has(key))
1156
+ return;
1157
+ warnedTimestampColumns.add(key);
1158
+ // biome-ignore lint/suspicious/noConsole: dev-only assertion
1159
+ console.warn(`[kumiko] column "${key}" renders a raw ISO timestamp as text — add renderer: { format: "timestamp" } to its column definition.`);
1160
+ }
1161
+ export function defaultCellRender(value, type, optionLabels, locale, columnKey, grouping = true) {
1162
+ if (value === null || value === undefined || value === "")
1163
+ return "";
1164
+ if (type === "boolean")
1165
+ return value === true ? "✓" : "";
1166
+ if (type === "timestamp" || type === "date") {
1167
+ return applyFormatSpec({ format: type, locale }, value);
1168
+ }
1169
+ if (type === "number" || type === "decimal" || type === "bigInt") {
1170
+ return applyFormatSpec({ format: type, locale, grouping }, value);
1171
+ }
1172
+ if (type === "money") {
1173
+ if (!isMoneyValue(value))
1174
+ return String(value);
1175
+ // formatMoney expects minor units scaled by currencyDecimals(currency).
1176
+ // rehydrateMoney's `amountMinor` is scaled by a flat MINOR_UNIT_SCALE=100
1177
+ // instead, which disagrees with currencyDecimals for non-2-decimal
1178
+ // currencies (JPY: 0 decimals → 100x too high; BHD: 3 decimals → 10x too
1179
+ // low). Deriving minor units from `amount` keeps this consistent with
1180
+ // render-field.tsx's moneyMinorValue.
1181
+ const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
1182
+ return formatMoney(minor, value.currency, locale);
1183
+ }
1184
+ if (type === "select" || type === "multiSelect") {
1185
+ const values = Array.isArray(value) ? value : [value];
1186
+ return values
1187
+ .map((v) => {
1188
+ const raw = String(v);
1189
+ // Translated label from the view-model builder (convention key
1190
+ // `<feature>:entity:<entity>:field:<field>:option:<value>`).
1191
+ // Fallback to humanizeSlug when no label is registered — same
1192
+ // behavior as before the optionLabels patch.
1193
+ const translated = optionLabels?.[raw];
1194
+ return translated !== undefined && translated !== raw ? translated : humanizeSlug(raw);
1195
+ })
1196
+ .join(", ");
1197
+ }
1198
+ if (type === "text" && typeof value === "string" && ISO_DATETIME_RE.test(value)) {
1199
+ warnMissingTimestampFormat(columnKey);
1200
+ }
1201
+ return typeof value === "string" ? value : String(value);
1202
+ }
1203
+ function humanizeSlug(slug) {
1204
+ // "degraded-performance" → "Degraded performance". A dotted value such as
1205
+ // "mobile.de" is a domain, not a slug, and must stay as stored.
1206
+ if (slug.includes("."))
1207
+ return slug;
1208
+ const spaced = slug.replace(/[-_]/g, " ");
1209
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
1210
+ }
1211
+ // Tooltip-Text für truncated Cells — bei Hover zeigt der Browser den
1212
+ // vollen Text. Skipping für Object/Array (das ist nicht user-readable);
1213
+ // Number stringifyt der Browser ohnehin korrekt; booleans get no raw true/false tooltip.
1214
+ function cellTitle(value) {
1215
+ if (value === null || value === undefined)
1216
+ return undefined;
1217
+ if (typeof value === "string")
1218
+ return value.length > 0 ? value : undefined;
1219
+ if (typeof value === "number")
1220
+ return String(value);
1221
+ return undefined;
1222
+ }
1223
+ function selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBadge) {
1224
+ const label = defaultCellRender(value, "select", optionLabels, locale);
1225
+ const declaredTone = typeof value === "string" && optionTones && Object.hasOwn(optionTones, value)
1226
+ ? optionTones[value]
1227
+ : undefined;
1228
+ const tone = (declaredTone !== undefined ? statusToneForOptionTone(declaredTone) : undefined) ??
1229
+ (typeof value === "string" ? statusToneForValue(value) : undefined);
1230
+ if (tone !== undefined) {
1231
+ // Same registered primitive as the projectionDetail header, so an app-provided badge applies to list cells too.
1232
+ if (RegisteredStatusBadge !== undefined) {
1233
+ return _jsx(RegisteredStatusBadge, { value: label, tone: tone });
1234
+ }
1235
+ return _jsx(StatusBadge, { tone: tone, children: label });
1236
+ }
1237
+ // dashboard-01 pattern: outline badge + muted instead of a filled secondary.
1238
+ return (_jsx(Badge, { variant: "outline", className: "px-1.5 text-muted-foreground", children: label }));
1239
+ }
1240
+ // A multi reference may list the same id twice; the occurrence count keeps React keys unique.
1241
+ function uniqueLinkItemKeys(items) {
1242
+ const seen = new Map();
1243
+ return items.map((item) => {
1244
+ const occurrence = seen.get(item.id) ?? 0;
1245
+ seen.set(item.id, occurrence + 1);
1246
+ return `${item.id}#${occurrence}`;
1247
+ });
1248
+ }
1249
+ // A link nested in the clickable first-cell button or card button would be an
1250
+ // <a> inside a <button>, so those cells stay plain text.
1251
+ function cellLinkProp(col, row, allowed) {
1252
+ if (!allowed)
1253
+ return {};
1254
+ const link = col.linkFor?.(row.values[col.field], row.values);
1255
+ const linkItems = col.linkItemsFor?.(row.values[col.field], row.values);
1256
+ return {
1257
+ ...(link !== undefined && { link }),
1258
+ ...(linkItems !== undefined && { linkItems }),
1259
+ };
1260
+ }
1261
+ function buildTableItems(rows, grouping, toggledGroups) {
1262
+ if (grouping === undefined)
1263
+ return rows.map((row) => ({ kind: "row", row }));
1264
+ const groups = new Map();
1265
+ for (const row of rows) {
1266
+ const key = grouping.keyOf(row);
1267
+ const members = groups.get(key);
1268
+ if (members === undefined)
1269
+ groups.set(key, [row]);
1270
+ else
1271
+ members.push(row);
1272
+ }
1273
+ return [...groups].flatMap(([key, members]) => {
1274
+ const label = grouping.headerLabel(key, members);
1275
+ if (label === undefined)
1276
+ return members.map((row) => ({ kind: "row", row }));
1277
+ const collapsed = grouping.startsCollapsed(key) !== toggledGroups.has(key);
1278
+ return [
1279
+ { kind: "group", key, label, collapsed },
1280
+ ...(collapsed ? [] : members.map((row) => ({ kind: "row", row }))),
1281
+ ];
1282
+ });
1283
+ }
1284
+ const ROW_TONE_CLASS = {
1285
+ bad: "bg-status-bad/10 hover:bg-status-bad/15",
1286
+ warn: "bg-status-warn/10 hover:bg-status-warn/15",
1287
+ ok: "bg-status-ok/10 hover:bg-status-ok/15",
1288
+ neutral: "",
1289
+ };
1290
+ function rowToneClass(tone) {
1291
+ return tone === undefined ? "" : ROW_TONE_CLASS[tone];
1292
+ }
1293
+ const NUMERIC_COLUMN_TYPES = new Set(["number", "decimal", "bigInt", "money"]);
1294
+ // Digits stand in columns here, so they need equal-width figures; the body no longer sets tabular-nums.
1295
+ const TABULAR_COLUMN_TYPES = new Set([
1296
+ ...NUMERIC_COLUMN_TYPES,
1297
+ "date",
1298
+ "timestamp",
1299
+ "locatedTimestamp",
1300
+ ]);
1301
+ // A plain <a>, not DefaultLink: the click must not reach the row's own click
1302
+ // handler, which opens the same record's row action.
1303
+ function CellTextLink({ link, children, }) {
1304
+ return (_jsx("a", { href: isSafeHref(link.href) ? link.href : "#", className: "text-primary underline-offset-4 hover:underline", onClick: (event) => {
1305
+ event.stopPropagation();
1306
+ if (!isPlainPrimaryClick(event))
1307
+ return;
1308
+ event.preventDefault();
1309
+ link.onPress();
1310
+ }, children: children }));
1311
+ }
1312
+ // Cell-Renderer als Component (statt reiner Funktion) damit der
1313
+ // useColumnRenderer-Hook aus dem Provider lesen kann. Die vier Pfade:
1314
+ // 1. FormatSpec (`{ format: "timestamp" }` etc.) → applyFormatSpec.
1315
+ // 2. RuntimeRenderer (Funktion) → direkter Aufruf. Nur für render-list-
1316
+ // interne Reference-Lookup-Closures — niemals aus dem serialisierten Schema.
1317
+ // 3. PlatformComponent (`{ react: { __component: "X" } }`) → schaut
1318
+ // "X" über useColumnRenderer auf und rendert `<X value row column/>`.
1319
+ // Nicht registriert → einmalige Warnung + Default-Fallback.
1320
+ // 4. Sonst → defaultCellRender (Type-basierter String-Renderer).
1321
+ function DataTableCell({ value, row, field, type, renderer, optionLabels, optionTones, onChange, translate, locale, grouping = true, fileDisplay, compactFile = false, link, linkItems, }) {
1322
+ const componentRef = isComponentRendererRef(renderer);
1323
+ const ResolvedComponent = useColumnRenderer(componentRef?.name);
1324
+ const RegisteredStatusBadge = useOptionalPrimitives()?.StatusBadge;
1325
+ if (isEmptyCellValue(value) && typeof renderer !== "function" && componentRef === undefined) {
1326
+ return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_VALUE_PLACEHOLDER }));
1327
+ }
1328
+ if (value === PII_ERASED_SENTINEL && translate !== undefined) {
1329
+ return translate("kumiko.pii.erased");
1330
+ }
1331
+ if (typeof value === "boolean" &&
1332
+ isBooleanColumn(type, renderer) &&
1333
+ (renderer === undefined || isUnlabeledBooleanFormat(renderer))) {
1334
+ return _jsx(BooleanCell, { value: value, translate: translate });
1335
+ }
1336
+ if (isUnlabeledFalse(type, value, renderer)) {
1337
+ return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_VALUE_PLACEHOLDER }));
1338
+ }
1339
+ if (fileDisplay !== undefined) {
1340
+ return (_jsx(FileDisplayCell, { value: value, display: fileDisplay, compact: compactFile, fallbackLabel: translate?.("kumiko.field.file-upload.attached") ?? "File attached" }));
1341
+ }
1342
+ if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
1343
+ const spec = renderer;
1344
+ return applyFormatSpec({ ...spec, locale: spec.locale ?? locale }, value, translate);
1345
+ }
1346
+ if (typeof renderer === "function") {
1347
+ if (linkItems !== undefined) {
1348
+ const linkItemKeys = uniqueLinkItemKeys(linkItems);
1349
+ return (_jsx(_Fragment, { children: linkItems.map((item, index) => (_jsxs(Fragment, { children: [index > 0 && ", ", item.link === undefined ? (item.label) : (_jsx(CellTextLink, { link: item.link, children: item.label }))] }, linkItemKeys[index]))) }));
1350
+ }
1351
+ const fn = renderer;
1352
+ const text = fn(value, row);
1353
+ return link === undefined ? text : _jsx(CellTextLink, { link: link, children: text });
1354
+ }
1355
+ if (componentRef !== undefined) {
1356
+ if (ResolvedComponent !== undefined) {
1357
+ const node = (_jsx(ResolvedComponent, { value: value, row: row, column: { field }, ...(onChange !== undefined && { onChange }) }));
1358
+ // stopPropagation only on the editable branch — plain cells must still
1359
+ // fire row onClick when onCellChange is set for sibling editable cells.
1360
+ if (onChange !== undefined) {
1361
+ return (_jsx("span", { className: "contents", onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: node }));
1362
+ }
1363
+ return node;
1364
+ }
1365
+ // Renderer im Schema referenziert, aber client-side kein Map-Eintrag —
1366
+ // typischer Fall: clientFeatures.columnRenderers vergessen oder
1367
+ // Tippfehler im __component-Key. Warnen statt crashen, damit ein
1368
+ // Schema-Boot trotzdem funktioniert (Default-Type-Renderer übernimmt).
1369
+ // biome-ignore lint/suspicious/noConsole: dev-warning für Schema-Konflikte
1370
+ console.warn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
1371
+ }
1372
+ // A select value renders as a pill instead of plain text. When the raw
1373
+ // value is a known status word, it carries the same tone as the
1374
+ // projectionDetail header badge — a status column stayed grey while the
1375
+ // detail view of the same value was coloured (fw#2579). Unknown values
1376
+ // keep the neutral outline pill.
1377
+ if (type === "select" && value !== null && value !== undefined && value !== "") {
1378
+ return selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBadge);
1379
+ }
1380
+ // One pill per picked value; a single pill would hold the whole joined list.
1381
+ if (type === "multiSelect" && Array.isArray(value) && value.length > 0) {
1382
+ return (_jsx("span", { className: "flex flex-wrap gap-1", children: value.map((entry) => (_jsx(Fragment, { children: selectPill(entry, optionLabels, optionTones, locale, RegisteredStatusBadge) }, String(entry)))) }));
1383
+ }
1384
+ return defaultCellRender(value, type, optionLabels, locale, field, grouping);
1385
+ }
1386
+ // ---- Form + Section + Grid + Text ----
1387
+ // Setzt DefaultSection in den Inner-Region-Modus: das ganze Form ist EINE
1388
+ // Card, Sections sind divider-getrennte Abschnitte darin (shadcn-Muster wie
1389
+ // Shipping/Invoice/Profile). Standalone (außerhalb Form) bleibt Section eine
1390
+ // eigene Card.
1391
+ const InsideFormContext = createContext(false);
1392
+ // Eingebettete Forms (z.B. im AuthCard) tragen ihre Card-Fläche schon vom
1393
+ // Container — der self-cardende DefaultForm würde sonst eine Card-in-Card
1394
+ // erzeugen. BareFormProvider schaltet DefaultForm auf ein nacktes <form>
1395
+ // (gestapelte Felder, kein eigener Rahmen/max-width).
1396
+ const BareFormContext = createContext(false);
1397
+ // Dashboard panels with an embedded settings form (`DashboardScreenPanel.chromeless`):
1398
+ // the form renders in the page grid like a standalone screen form, without the
1399
+ // card frame, but keeps its own (non-pinned) save bar.
1400
+ const EmbeddedFormContext = createContext(false);
1401
+ export function EmbeddedFormProvider({ children }) {
1402
+ return _jsx(EmbeddedFormContext.Provider, { value: true, children: children });
1403
+ }
1404
+ export function BareFormProvider({ children }) {
1405
+ return _jsx(BareFormContext.Provider, { value: true, children: children });
1406
+ }
1407
+ // App-wide default for FormScreenShell's width when a screen doesn't set its
1408
+ // own `layout.width`. Undefined (no `createKumikoApp({ screenWidth })`) lets each
1409
+ // shell keep its own default: "4xl" for FormScreenShell, 640px for the
1410
+ // screen-form column.
1411
+ const ScreenWidthContext = createContext(undefined);
1412
+ export function ScreenWidthProvider({ width, children, }) {
1413
+ return _jsx(ScreenWidthContext.Provider, { value: width, children: children });
1414
+ }
1415
+ // Extension sections that render their own <form> via BareFormProvider
1416
+ // (legacy custom-form pattern, e.g. ChangeEmailSection before fw#2703) land
1417
+ // inside RenderEdit's host <form> (ExtensionSectionMount, render-edit.tsx) —
1418
+ // nested <form> elements are invalid DOM, and real browsers can silently
1419
+ // fall back to a native GET submit of the OUTER form, leaking field values
1420
+ // into the URL (fw#2312, fw#2705). Degrading to a <div> when already inside
1421
+ // a form avoids the nesting; the captured click still routes to THIS form's
1422
+ // onSubmit instead of activating the real ancestor <form>.
1423
+ const SUBMIT_CONTROL_SELECTOR = [
1424
+ "button[type=submit]:not(:disabled)",
1425
+ "button:not([type]):not(:disabled)",
1426
+ "input[type=submit]:not(:disabled)",
1427
+ "input[type=image]:not(:disabled)",
1428
+ ].join(", ");
1429
+ function FormRoot({ onSubmit, testId, className, children, }) {
1430
+ if (useContext(InsideFormContext)) {
1431
+ return (_jsx("div", { "data-kumiko-form-root": "", onKeyDownCapture: (e) => {
1432
+ // Implicit submission: the section's inputs belong to the OUTER
1433
+ // <form> in the DOM, so the browser would otherwise route Enter to
1434
+ // that form's default button.
1435
+ if (e.key !== "Enter" || e.nativeEvent.isComposing)
1436
+ return;
1437
+ const field = e.target;
1438
+ if (!(field instanceof HTMLInputElement))
1439
+ return;
1440
+ if (field.closest("[data-kumiko-form-root]") !== e.currentTarget)
1441
+ return;
1442
+ if (["checkbox", "radio", "submit", "button"].includes(field.type))
1443
+ return;
1444
+ e.preventDefault();
1445
+ onSubmit();
1446
+ }, onClickCapture: (e) => {
1447
+ // @cast-boundary dom-event-target: closest() needs an Element, and
1448
+ // click targets are always one in the browser/happy-dom.
1449
+ const submitButton = e.target.closest(SUBMIT_CONTROL_SELECTOR);
1450
+ if (submitButton === null)
1451
+ return;
1452
+ // React events bubble through portals and nested degraded roots; a
1453
+ // real <form> only reacts to buttons in its own DOM subtree.
1454
+ if (submitButton.closest("[data-kumiko-form-root]") !== e.currentTarget)
1455
+ return;
1456
+ e.preventDefault();
1457
+ onSubmit();
1458
+ }, "data-testid": testId, className: className, children: children }));
1459
+ }
1460
+ return (_jsx("form", { onSubmit: (e) => {
1461
+ e.preventDefault();
1462
+ onSubmit(e);
1463
+ }, "data-testid": testId, className: className, children: children }));
1464
+ }
1465
+ // stickyActions alone pins the primary action with `position: fixed` on
1466
+ // narrow viewports; together with fillHeight the flex chain pins the footer
1467
+ // on every viewport instead.
1468
+ function isPinnedFooter(stickyActions, fillHeight) {
1469
+ return stickyActions === true && fillHeight === true;
1470
+ }
1471
+ // Sections wrapper for DefaultForm's card and chromeless layouts alike —
1472
+ // only the card-derived padding on non-section children differs between
1473
+ // them (see `chromeless` doc on FormProps).
1474
+ function FormSections({ children, chromeless, stickyActions, fillHeight, screenForm = false, }) {
1475
+ if (screenForm) {
1476
+ return (_jsx("div", { className: "flex flex-col gap-9", children: _jsx(InsideFormContext.Provider, { value: true, children: _jsx(ScreenFormContext.Provider, { value: true, children: children }) }) }));
1477
+ }
1478
+ return (_jsx("div", { className: cn("flex flex-col",
1479
+ // Section-Children (Auto-UI-Edit) trennt eine Linie ZWISCHEN
1480
+ // ihnen — sie padden sich selbst. Flache Felder (Custom-Screens)
1481
+ // kriegen Padding + Rhythmus, keine Linie zwischen jedem Feld.
1482
+ "[&>section:not(:first-child)]:border-t",
1483
+ // Margins, not padding: padding would land inside a child that has its own border/height.
1484
+ !chromeless && "[&>:not(section)]:mx-6 [&>:not(section)]:my-3", !chromeless && "[&>:not(section):first-child]:mt-6 [&>:not(section):last-child]:mb-6",
1485
+ // ponytail: fixed footer now pins only the primary action (single
1486
+ // button row + its own p-4, fw#2606) instead of the whole footer —
1487
+ // shrunk from pb-32 accordingly. Widen again if a wizard's primary
1488
+ // action ever wraps to two rows.
1489
+ stickyActions === true && fillHeight !== true && STICKY_FOOTER_SPACER_CLASS,
1490
+ // Same "no flex-1" reasoning as the card above: this is the
1491
+ // one section allowed to shrink (min-h-0) inside the card, not
1492
+ // one forced to grow past its content.
1493
+ // Chromeless has no card above to hold a floor, so the sections do.
1494
+ fillHeight === true && (chromeless ? "min-h-64" : "min-h-0"),
1495
+ // The flex chain pins the footer, so the sections are the scroll surface.
1496
+ isPinnedFooter(stickyActions, fillHeight) && "flex-1 overflow-y-auto"), children: _jsx(InsideFormContext.Provider, { value: true, children: children }) }));
1497
+ }
1498
+ // Title/subtitle block shared by DefaultForm's card and chromeless layouts —
1499
+ // RenderEdit already withholds screen.description from `subtitle` in tabs
1500
+ // mode (chromeless), so whatever arrives here still renders, just without card chrome.
1501
+ function FormTitleBlock({ title, subtitle, titleAction, testId, bordered, fillHeight, }) {
1502
+ if (title === undefined && subtitle === undefined && titleAction === undefined)
1503
+ return null;
1504
+ return (_jsxs("div", { className: cn("flex items-start justify-between gap-4", bordered ? cn(cardHeaderBorder, "px-6 pb-4 pt-5") : "pb-4", fillHeight === true && "shrink-0"), children: [_jsxs("div", { className: "min-w-0", children: [title !== undefined && (_jsx("h2", { "data-testid": testId !== undefined ? `${testId}-title` : undefined, className: "text-lg font-semibold tracking-tight", children: title })), subtitle !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: "mt-1 text-sm text-muted-foreground", children: subtitle }))] }), titleAction !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-title-action` : undefined, className: "flex shrink-0 flex-wrap items-center justify-end gap-2", children: titleAction }))] }));
1505
+ }
1506
+ // `formActions` (render-edit.tsx) mixes a non-submit wizard Back button in
1507
+ // with the submit-type Next/Finish button inside one Fragment — only the
1508
+ // submit one is the "primary" action that fw#1918 needs pinned above a
1509
+ // virtual keyboard. Back reads as `type="button"`, same as `secondaryActions`
1510
+ // (Cancel/Delete/…), so it can safely join that group on mobile. A footer-slot
1511
+ // mount can't expose `type="submit"` (its button is opaque app code), so it
1512
+ // opts in via `STICKY_PRIMARY_ACTION_PROP` instead (`ScreenSlots.footerPrimary`).
1513
+ function isPrimaryStickyAction(node) {
1514
+ if (!isValidElement(node))
1515
+ return false;
1516
+ const props = node.props;
1517
+ return props.type === "submit" || props[STICKY_PRIMARY_ACTION_PROP] === true;
1518
+ }
1519
+ // `actions` from render-edit.tsx arrives as a single `<>…</>` Fragment
1520
+ // element (not spread children) — Children.toArray only flattens fragments
1521
+ // nested inside an already-flattened children list, not a Fragment passed
1522
+ // as the whole value, so the Fragment's own children are unwrapped first.
1523
+ function flattenActionNodes(node) {
1524
+ if (isValidElement(node) && node.type === Fragment) {
1525
+ return Children.toArray(node.props.children);
1526
+ }
1527
+ return Children.toArray(node);
1528
+ }
1529
+ function unsavedStatusText(t, unsavedCount) {
1530
+ if (unsavedCount === 0)
1531
+ return t("kumiko.form.noUnsavedChanges");
1532
+ if (unsavedCount === 1)
1533
+ return t("kumiko.form.unsaved.one");
1534
+ return t("kumiko.form.unsaved.other", { count: unsavedCount });
1535
+ }
1536
+ // Footer wrapper for DefaultForm's card and chromeless layouts alike — only
1537
+ // the card-derived horizontal padding/border differs between them.
1538
+ function FormFooter({ actions, secondaryActions, testId, chromeless, stickyActions, fillHeight, unsavedCount, railed = false, }) {
1539
+ const t = useTranslation();
1540
+ const isBelowSm = useIsBelowSmViewport();
1541
+ const keyboardInset = useKeyboardInset();
1542
+ if (actions === undefined && secondaryActions === undefined)
1543
+ return null;
1544
+ // Only split when sticky: the non-sticky (regular, non-wizard) footer must
1545
+ // reproduce the previous DOM exactly (form-action-bar.test.tsx pins
1546
+ // `-actions`/`-actions-secondary` content 1:1 to the `actions`/
1547
+ // `secondaryActions` props).
1548
+ const actionNodes = stickyActions === true ? flattenActionNodes(actions) : undefined;
1549
+ const primaryActionNodes = actionNodes?.filter(isPrimaryStickyAction);
1550
+ const hasPrimaryAction = primaryActionNodes !== undefined && primaryActionNodes.length > 0;
1551
+ // No primary node found among `actions` (e.g. a wizard step with only
1552
+ // secondary buttons): fall back to pinning the whole, unpartitioned
1553
+ // `actions` node instead of splitting it — matches the pre-split fw#1918
1554
+ // behaviour where sticky footers always kept `actions` fixed.
1555
+ const nonPrimaryActionNodes = hasPrimaryAction
1556
+ ? actionNodes?.filter((node) => !isPrimaryStickyAction(node))
1557
+ : undefined;
1558
+ const renderedActions = stickyActions === true ? (hasPrimaryAction ? primaryActionNodes : actions) : actions;
1559
+ const hasNonPrimaryOverflow = nonPrimaryActionNodes !== undefined && nonPrimaryActionNodes.length > 0;
1560
+ const renderedSecondary = stickyActions === true && hasPrimaryAction ? (_jsxs(_Fragment, { children: [secondaryActions, nonPrimaryActionNodes] })) : (secondaryActions);
1561
+ const pinned = isPinnedFooter(stickyActions, fillHeight);
1562
+ const fixedFooterStyle = stickyActions === true && !pinned && isBelowSm && keyboardInset > 0
1563
+ ? { bottom: keyboardInset }
1564
+ : undefined;
1565
+ // Below sm the pinned footer swaps to NarrowPinnedFooter; the subtree remounts, so a
1566
+ // confirm dialog owned by an action button closes on a breakpoint change (rotate, resize).
1567
+ if (pinned && isBelowSm) {
1568
+ return (_jsx(NarrowPinnedFooter, { nodes: [...flattenActionNodes(secondaryActions), ...flattenActionNodes(actions)], overflowLabel: unsavedCount !== undefined && unsavedCount > 0
1569
+ ? `${t("kumiko.list.row-actions.more")}, ${unsavedStatusText(t, unsavedCount)}`
1570
+ : t("kumiko.list.row-actions.more"), unsavedCount: unsavedCount, unsavedStatus: unsavedCount !== undefined && unsavedCount > 0
1571
+ ? unsavedStatusText(t, unsavedCount)
1572
+ : undefined, testId: testId, keyboardInset: keyboardInset }));
1573
+ }
1574
+ return (_jsxs("div", { className: cn(pinned
1575
+ ? cn("flex h-14 shrink-0 items-center justify-end gap-2 border-t border-border bg-card px-6", !railed && "md:pl-10")
1576
+ : "flex flex-col-reverse gap-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-4", !pinned && !chromeless && "px-[var(--card-padding)]", !pinned && !chromeless && cardFooterBorder, !pinned && fillHeight === true && "shrink-0"), children: [unsavedCount !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-unsaved` : undefined, className: cn("mr-auto flex min-w-0 items-center gap-2 text-[13px]",
1577
+ // Only the stacked (flex-col-reverse) footer moves the status above the
1578
+ // actions; the pinned single-row footer keeps it on the left.
1579
+ !pinned && "max-sm:order-last", unsavedCount > 0 ? "text-foreground-secondary" : "text-muted-foreground"), children: [unsavedCount > 0 && (_jsx("span", { className: "size-1.5 shrink-0 rounded-full bg-status-active", "aria-hidden": "true" })), _jsx("span", { className: "truncate", children: unsavedStatusText(t, unsavedCount) })] })), (secondaryActions !== undefined || hasNonPrimaryOverflow) && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions-secondary` : undefined, className: cn(
1580
+ // min-h-11: link-variant buttons (Copy link) are h-auto and would fall
1581
+ // below the 24px touch-target minimum on narrow viewports.
1582
+ "flex flex-wrap items-center gap-2 max-sm:[&_button]:min-h-11 max-sm:[&_button]:px-2 max-sm:[&_button]:text-xs", pinned && hasNonPrimaryOverflow && "mr-auto"), children: renderedSecondary })), renderedActions !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, style: fixedFooterStyle, className: cn("flex items-center gap-2", pinned
1583
+ ? "shrink-0 max-md:[&>button]:min-h-11"
1584
+ : "flex-wrap max-sm:w-full max-sm:[&>button]:flex-1 max-sm:[&>button]:min-h-11 sm:ml-auto",
1585
+ // Below sm (640px): pin only the primary action to the viewport
1586
+ // bottom instead of normal flow, so a virtual keyboard shrinking
1587
+ // the viewport can't push it out of reach (fw#1918). `fixed`
1588
+ // escapes the card's `overflow-hidden` (only transform/filter/
1589
+ // contain ancestors trap it, confirmed against
1590
+ // AppLayout/SidebarInset — neither sets those).
1591
+ stickyActions === true &&
1592
+ !pinned &&
1593
+ cn("max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:p-4 max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)]", STICKY_FOOTER_SAFE_AREA_CLASS)), children: renderedActions }))] }));
1594
+ }
1595
+ const SECTION_NAV_MIN_ITEMS = 3;
1596
+ // "On this page" navigation next to a screen form. The active entry follows
1597
+ // the section nearest the top of the scroll surface; without IntersectionObserver
1598
+ // (happy-dom, old engines) the first entry stays active until one is clicked.
1599
+ function FormSectionNav({ items }) {
1600
+ const t = useTranslation();
1601
+ const [activeId, setActiveId] = useState(items[0]?.id);
1602
+ useEffect(() => {
1603
+ if (typeof IntersectionObserver === "undefined")
1604
+ return;
1605
+ const visibleIds = new Set();
1606
+ const observer = new IntersectionObserver((entries) => {
1607
+ for (const entry of entries) {
1608
+ if (entry.isIntersecting)
1609
+ visibleIds.add(entry.target.id);
1610
+ else
1611
+ visibleIds.delete(entry.target.id);
1612
+ }
1613
+ const firstVisible = items.find((item) => visibleIds.has(item.id));
1614
+ if (firstVisible !== undefined)
1615
+ setActiveId(firstVisible.id);
1616
+ }, { rootMargin: "0px 0px -60% 0px" });
1617
+ for (const item of items) {
1618
+ const target = document.getElementById(item.id);
1619
+ if (target !== null)
1620
+ observer.observe(target);
1621
+ }
1622
+ return () => observer.disconnect();
1623
+ }, [items]);
1624
+ return (_jsxs("nav", { "aria-label": t("kumiko.form.on-this-page"), "data-testid": "form-section-nav", className: "sticky top-0 hidden w-[200px] shrink-0 flex-col gap-0.5 self-start xl:flex", children: [_jsx("span", { className: "pb-1.5 pl-3 text-xs text-muted-foreground", children: t("kumiko.form.on-this-page") }), items.map((item) => {
1625
+ const active = item.id === activeId;
1626
+ return (_jsx("button", { type: "button", "data-testid": `form-section-nav-${item.id}`, ...(active && { "aria-current": "true" }), onClick: () => {
1627
+ setActiveId(item.id);
1628
+ document
1629
+ .getElementById(item.id)
1630
+ ?.scrollIntoView?.({ behavior: "smooth", block: "start" });
1631
+ }, className: cn("border-l-2 px-3 py-[5px] text-left text-sm", active
1632
+ ? "border-primary font-semibold text-primary"
1633
+ : "border-border text-foreground-secondary hover:text-foreground"), children: item.title }, item.id));
1634
+ })] }));
1635
+ }
1636
+ // Form inside the Drawer primitive: the drawer header carries the title, so
1637
+ // only the description (as help text), the scrolling sections and a pinned
1638
+ // footer remain. The scroll area is a sibling of the footer, never its parent.
1639
+ function DrawerFormLayout({ onSubmit, subtitle, actions, secondaryActions, summary, testId, children, }) {
1640
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: "flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-6 py-5", children: [summary !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-summary` : undefined, className: "flex flex-col gap-0.5 rounded-lg bg-muted px-3.5 py-3", children: [_jsx("span", { className: "text-sm font-semibold text-foreground", children: summary.title }), summary.subtitle !== undefined && (_jsx("span", { className: "text-[13px] text-foreground-secondary", children: summary.subtitle }))] })), subtitle !== undefined && (_jsx("p", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: "text-[13px] text-foreground-secondary", children: subtitle })), _jsx(InsideFormContext.Provider, { value: true, children: _jsx(ScreenFormContext.Provider, { value: true, children: _jsx(InsideDrawerProvider, { value: false, children: _jsx(DrawerBodyContext.Provider, { value: true, children: _jsx("div", { className: "flex flex-col gap-5", children: children }) }) }) }) })] }), (actions !== undefined || secondaryActions !== undefined) && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-footer` : undefined, className: "flex h-16 shrink-0 items-center justify-end gap-2 border-t border-border px-6", children: [secondaryActions, actions] }))] }));
1641
+ }
1642
+ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryActions, testId, width, stickyActions, headerRegion, titleAction, fillHeight, chromeless, screenForm, unsavedCount, sectionNav, sideRail, summary, }) {
1643
+ const insideDrawer = useInsideDrawer();
1644
+ const isEmbeddedForm = useContext(EmbeddedFormContext);
1645
+ const contextWidth = useContext(ScreenWidthContext);
1646
+ // Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
1647
+ // der Container trägt Card/Titel selbst, sonst Card-in-Card.
1648
+ if (useContext(BareFormContext)) {
1649
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col gap-4",
1650
+ // Bare forms stack sections without a card; without a divider a
1651
+ // section boundary reads as a layout gap, not structure.
1652
+ "[&>section:not(:first-child)]:border-t"), children: [headerRegion, _jsx(InsideFormContext.Provider, { value: true, children: children }), (secondaryActions !== undefined || actions !== undefined) && (_jsxs("div", { className: "flex items-center justify-end gap-2", children: [secondaryActions, actions] }))] }));
1653
+ }
1654
+ if (insideDrawer) {
1655
+ return (_jsx(DrawerFormLayout, { onSubmit: onSubmit, subtitle: subtitle, actions: actions, secondaryActions: secondaryActions, summary: summary, testId: testId, children: children }));
1656
+ }
1657
+ if (isEmbeddedForm) {
1658
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex w-full flex-col gap-6", children: [_jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), _jsx(FormSections, { chromeless: true, stickyActions: undefined, fillHeight: false, screenForm: true, children: children }), (actions !== undefined || secondaryActions !== undefined) && (_jsx("div", { className: "max-w-4xl border-t border-border", children: _jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: true, stickyActions: undefined, fillHeight: false, ...(unsavedCount !== undefined && { unsavedCount }) }) }))] }));
1659
+ }
1660
+ const isScreenForm = screenForm === true && fillHeight === true && chromeless !== true;
1661
+ const screenFormWidth = width ?? contextWidth;
1662
+ const sections = (_jsx(FormSections, { chromeless: chromeless === true, stickyActions: stickyActions, fillHeight: fillHeight, screenForm: isScreenForm, children: children }));
1663
+ const footer = (_jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: chromeless === true, stickyActions: stickyActions, fillHeight: fillHeight, ...(unsavedCount !== undefined && { unsavedCount }), railed: sideRail !== undefined }));
1664
+ // Screen form: no card. A padded scroll surface holds the form column and,
1665
+ // from xl, the section navigation; the footer stays pinned below it.
1666
+ if (isScreenForm) {
1667
+ const showsSectionNav = sectionNav !== undefined && sectionNav.length >= SECTION_NAV_MIN_ITEMS;
1668
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9", screenFormWidth !== undefined
1669
+ ? screenFormColumnWidthClassName[screenFormWidth]
1670
+ : "max-w-[640px]"), children: [_jsx(InsideScreenFormColumnContext.Provider, { value: true, children: headerRegion }), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), sections] }), showsSectionNav && _jsx(FormSectionNav, { items: sectionNav })] }) })] }), footer] }));
1671
+ }
1672
+ // Pinned footer (fillHeight + stickyActions): the footer is a full-bleed
1673
+ // sibling BELOW the padded shell, so the shell's bottom inset does not float
1674
+ // it above the viewport edge; the shell shrinks (`flex-1`) to make room.
1675
+ const pinnedFooter = isPinnedFooter(stickyActions, fillHeight);
1676
+ const pinnedShellClassName = pinnedFooter ? "h-auto flex-1 pb-6" : undefined;
1677
+ // Tab content (bedienkonzept A1): the head card is the ONLY card on the
1678
+ // screen, so a tabbed projectionDetail's tab content must render as a
1679
+ // sibling of that card, not nest another one around itself — chromeless
1680
+ // skips the card wrapper and its border/padding so sections and footer
1681
+ // sit directly on the page background, same edge as a standalone list
1682
+ // screen. Title/subtitle still render unbordered: RenderEdit's tabs
1683
+ // caller already blanks the per-section title via hideSectionTitles, but
1684
+ // the screen-level subtitle (screen.description) is independent of that
1685
+ // and must survive tabs mode.
1686
+ if (chromeless === true) {
1687
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { maxWidth: "full", ...(fillHeight === true && { fillHeight: true }), className: cn("!p-0", pinnedShellClassName), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col", fillHeight === true && "shrink-0"), children: headerRegion })), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: fillHeight }), sections, !pinnedFooter && footer] }), pinnedFooter && footer] }));
1688
+ }
1689
+ // One card form: title as header (no divider under it), sections divided
1690
+ // between each other, muted action footer. Shell width comes from
1691
+ // FormScreenShell (layout.width, else the app-wide screenWidth default, else
1692
+ // 4xl).
1693
+ return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { ...(width !== undefined && { maxWidth: width }), ...(fillHeight === true && { fillHeight: true }), ...(pinnedShellClassName !== undefined && { className: pinnedShellClassName }), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col gap-6 mb-8", fillHeight === true && "shrink-0"), children: headerRegion })), _jsxs("div", { "data-slot": "card", className: cn(cardSurface(), "overflow-hidden",
1694
+ // No flex-1: a fillHeight card sizes to its content (flex's
1695
+ // initial 0 1 auto) and only claims more than that once its
1696
+ // FormScreenShell ancestor is itself height-constrained and
1697
+ // shrinks it back down via min-h-0 — flex-1 would instead force
1698
+ // it to always fill the remaining height, stretching a short
1699
+ // relatedList tab to the bottom of the panel (fw#2778).
1700
+ // The min-height floor keeps the card from collapsing to ~0 on short
1701
+ // viewports; the shell then overflows and <main> scrolls instead of
1702
+ // the overflow-hidden card clipping the table.
1703
+ fillHeight === true && "min-h-64 flex flex-col", pinnedFooter && "flex-1"), children: [_jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: true, fillHeight: fillHeight }), sections, !pinnedFooter && footer] })] }), pinnedFooter && footer] }));
1704
+ }
1705
+ export function FormScreenShell({ children, className, testId, maxWidth, fillHeight, }) {
1706
+ const contextWidth = useContext(ScreenWidthContext);
1707
+ const width = maxWidth ?? contextWidth ?? "4xl";
1708
+ return (_jsx("div", { "data-testid": testId, className: cn(screenPaddingClassName, "w-full", screenWidthClassName[width], fillHeight === true && "h-full flex flex-col min-h-0", className), children: children }));
1709
+ }
1710
+ function DefaultSection({ id, title, subtitle, children, actions, variant = "default", layout = "stacked", testId, icon, }) {
1711
+ const insideForm = useContext(InsideFormContext);
1712
+ const insideScreenForm = useContext(ScreenFormContext);
1713
+ // h3 statt CardTitle (= div): erhält die Heading-Semantik für
1714
+ // Screenreader-Navigation. Subtitle fließt darunter (kein Divider —
1715
+ // shadcn CardTitle+CardDescription-Muster).
1716
+ // icon only renders alongside a title — a title-less section has nothing
1717
+ // for a lone icon to sit next to, so it stays as-is (no heading grows).
1718
+ // actions render top-right in this same title row, never a
1719
+ // footer — a title-less section still draws the row when actions are
1720
+ // present, so a hideTitle tabs-Section with `actions` isn't stranded.
1721
+ const TitleTag = insideScreenForm ? "h2" : "h3";
1722
+ const titleBlock = title !== undefined || subtitle !== undefined ? (_jsxs("div", { className: "flex flex-col gap-1", children: [title !== undefined && (_jsxs(TitleTag, { "data-testid": testId !== undefined ? `${testId}-title` : undefined, className: "flex items-center gap-2 text-base font-semibold leading-none tracking-tight", children: [icon !== undefined && _jsx(Icon, { name: icon, className: "size-4 text-muted-foreground" }), title] })), subtitle !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-subtitle` : undefined, className: cn("text-sm text-muted-foreground", insideScreenForm && "text-[13px] text-foreground-secondary"), children: subtitle }))] })) : null;
1723
+ const header = titleBlock !== null || actions !== undefined ? (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [titleBlock, actions !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, className: "ml-auto flex shrink-0 items-center gap-2", children: actions }))] })) : null;
1724
+ // Innerhalb eines Forms: divider-loser Abschnitt OHNE eigene Card-Fläche.
1725
+ // Die Trennlinien ZWISCHEN Sections macht der divide-y-Wrapper im Form.
1726
+ if (insideForm) {
1727
+ return (_jsxs("section", { id: id, "data-testid": testId, className: cn(
1728
+ // py-4 (not the standalone card's py-6): the border-t between
1729
+ // sections already carries the section break, so the vertical
1730
+ // gap only needs to read as roughly double the gap-4 field row
1731
+ // spacing, not triple it.
1732
+ insideScreenForm
1733
+ ? "flex scroll-mt-4 flex-col gap-4 [&:not(:first-of-type)]:border-t [&:not(:first-of-type)]:border-border [&:not(:first-of-type)]:pt-7"
1734
+ : "flex flex-col gap-4 px-6 py-4", variant === "destructive" && "border-l-2 border-destructive/40", layout === "settings-list" && "@container max-w-4xl"), children: [header, children] }));
1735
+ }
1736
+ // Standalone: own card, header flows into the body (no divider).
1737
+ // overflow-hidden clips the card's own corner radius correctly for
1738
+ // portaled overlays (Combobox/Select/Tooltip escape to document.body,
1739
+ // unaffected). A non-portaled overlay (e.g. a custom dropdown built
1740
+ // directly into `children`) WOULD get silently clipped — verify this
1741
+ // against any new standalone-section content that renders its own
1742
+ // non-portaled overlay.
1743
+ return (_jsx("div", { "data-slot": "card", "data-testid": testId, className: cn(cardSurface(), "overflow-hidden", variant === "destructive" && "border-destructive/40"), children: _jsxs("div", { className: "flex flex-col gap-4 px-6 py-6", children: [header, children] }) }));
1744
+ }
1745
+ function DefaultFillContainer({ children, testId, grow }) {
1746
+ // flex-1 only on request (`grow`, set under fillScreenHeight): without it the
1747
+ // container sizes to its content and only shrinks (min-h-0), so a two-row
1748
+ // table in a non-fixed-height screen does not stretch (fw#2778).
1749
+ return (_jsx("div", { "data-testid": testId, className: cn("flex min-h-0 flex-col", grow === true && "flex-1"), children: children }));
1750
+ }
1751
+ const DRAWER_FIELD_CELL_WIDTH_CLASS = {
1752
+ money: "w-full sm:w-[200px] [&_label]:whitespace-nowrap",
1753
+ select: "w-full",
1754
+ };
1755
+ const FIELD_CELL_WIDTH_CLASS = {
1756
+ text: "w-full sm:w-60",
1757
+ number: "w-fit min-w-24 shrink-0 [&_label]:whitespace-nowrap [&_[data-slot=number-field]]:w-32",
1758
+ money: "w-40 shrink-0 [&_label]:whitespace-nowrap",
1759
+ date: "w-full sm:w-[200px]",
1760
+ timestamp: "w-full sm:w-[328px]",
1761
+ select: "w-full sm:w-auto sm:min-w-[200px] sm:has-[[data-radio-list]]:w-full",
1762
+ full: "w-full",
1763
+ // self-start: the label shares the top line with neighbouring labels. The
1764
+ // switch (1.15rem) gets (h-9 input − switch) / 2 margin to sit on the input line.
1765
+ toggle: "w-auto min-w-40 shrink-0 self-start [&_[data-slot=switch]]:my-[0.55rem]",
1766
+ };
1767
+ function DefaultGrid({ columns, children, testId, maxRows, flow, list, ariaLabelledBy, }) {
1768
+ const insideDrawerBody = useContext(DrawerBodyContext);
1769
+ const groupProps = ariaLabelledBy !== undefined
1770
+ ? { role: "group", "aria-labelledby": ariaLabelledBy }
1771
+ : {};
1772
+ if (list === true) {
1773
+ return (_jsx("div", { ...groupProps, "data-testid": testId, className: "flex flex-col divide-y divide-border", children: children }));
1774
+ }
1775
+ if (flow === true) {
1776
+ return (_jsx("div", { ...groupProps, "data-testid": testId, className: cn("flex gap-4", insideDrawerBody
1777
+ ? "flex-col items-start"
1778
+ : // items-end aligns inputs when labels wrap to different heights; a
1779
+ // below-control appendix grows only the one cell and would push its
1780
+ // neighbours down, so such rows align to the top instead.
1781
+ "flex-wrap items-end has-[[data-appendix-below]]:items-start"), children: children }));
1782
+ }
1783
+ // "auto": content-sized items in a wrapping row (e.g. a metrics band of
1784
+ // self-sized tiles) instead of N equal-width, container-stretched tracks.
1785
+ // maxRows/scrolling don't apply — the row just wraps.
1786
+ if (columns === "auto") {
1787
+ return (_jsx("div", { ...groupProps, "data-testid": testId, className: "flex flex-wrap items-center gap-4", children: children }));
1788
+ }
1789
+ // Responsive: Mobile (< sm = 640px) bleibt 1-spaltig, ab sm: greift
1790
+ // die Author-deklarierte Spaltenzahl. Inline-style schreibt
1791
+ // CSS-Variable; Tailwind-Klasse liest die Variable mit
1792
+ // `grid-template-columns: var(--grid-cols)`. Saubere Lösung weil
1793
+ // Tailwind JIT keinen dynamischen `grid-cols-${N}` auflösen kann.
1794
+ const rowCount = Math.ceil(Children.toArray(children).length / columns);
1795
+ const clipped = maxRows !== undefined && rowCount > maxRows;
1796
+ // gridAutoRows is a minmax MIN, not a fixed height, so wrapped labels grow
1797
+ // their row instead of being clipped mid-row. maxHeight is therefore only
1798
+ // an approximation of "maxRows rows tall" — a row taller than the minimum
1799
+ // means fewer than maxRows rows end up visible before scrolling kicks in.
1800
+ // Themes may override --kumiko-grid-row-h (default 2.5rem, set in
1801
+ // styles.css) to change that minimum.
1802
+ // Mobile note: the grid collapses to 1 column below `sm` (see className),
1803
+ // but rowCount above is always computed against the desktop `columns` —
1804
+ // inline styles can't express a media query, so the mobile row count is
1805
+ // an under-count. Known, accepted.
1806
+ const style = {
1807
+ "--grid-cols": `repeat(${columns}, minmax(0, 1fr))`,
1808
+ ...(clipped && {
1809
+ gridAutoRows: "minmax(var(--kumiko-grid-row-h, 2.5rem), auto)",
1810
+ maxHeight: `calc(${maxRows} * var(--kumiko-grid-row-h, 2.5rem) + ${maxRows - 1} * var(--kumiko-grid-gap, 1rem))`,
1811
+ overflowY: "auto",
1812
+ }),
1813
+ };
1814
+ return (_jsx("div", { ...groupProps, "data-testid": testId, className: "grid gap-[var(--kumiko-grid-gap,1rem)] grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]", style: style, children: children }));
1815
+ }
1816
+ function DefaultGridCell({ span, width, children }) {
1817
+ const insideDrawerBody = useContext(DrawerBodyContext);
1818
+ if (width !== undefined) {
1819
+ return (_jsx("div", { className: cn("min-w-0 max-w-full", insideDrawerBody
1820
+ ? (DRAWER_FIELD_CELL_WIDTH_CLASS[width] ?? FIELD_CELL_WIDTH_CLASS[width])
1821
+ : FIELD_CELL_WIDTH_CLASS[width]), children: children }));
1822
+ }
1823
+ const s = span !== undefined ? Math.min(span, 12) : 1;
1824
+ // Below `sm` the grid has one explicit column (see DefaultGrid); an unconditional
1825
+ // `span N` there would add implicit auto columns that squeeze the fr column.
1826
+ const style = { "--grid-span": s };
1827
+ return (_jsx("div", { className: "min-w-0 sm:[grid-column:span_var(--grid-span)]", style: style, children: children }));
1828
+ }
1829
+ function DefaultText({ variant = "body", decorative, children, testId }) {
1830
+ switch (variant) {
1831
+ case "code":
1832
+ return (_jsx("code", { "data-testid": testId, className: "relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm", children: children }));
1833
+ case "small":
1834
+ return (_jsx("small", { "data-testid": testId, className: "text-xs text-muted-foreground", children: children }));
1835
+ case "required-mark":
1836
+ return (_jsx("span", { "data-testid": testId, "data-required": true, className: "text-destructive", children: children }));
1837
+ case "muted":
1838
+ return (_jsx("span", { "data-testid": testId, ...(decorative === true && { "aria-hidden": true }), className: "text-sm text-muted-foreground", children: children }));
1839
+ default:
1840
+ return _jsx("span", { "data-testid": testId, children: children });
1841
+ }
1842
+ }
1843
+ // ---- Link (anchor mit Button-/Muted-Optik) ----
1844
+ // isSafeHref lives in @cosmicdrift/kumiko-renderer/headless — shared with
1845
+ // page-render's server-side markdown renderer, both take untrusted
1846
+ // tenant-authored hrefs.
1847
+ // `button` nutzt die Primary-Buttonfläche auf einem semantischen <a> —
1848
+ // der Standard für „weiter zu"-Navigationen nach Success-States (ehem.
1849
+ // authButtonClass), `muted` der dezente Sekundär-Link (ehem.
1850
+ // authMutedLinkClass).
1851
+ // Modified and middle clicks keep the browser default (new tab/window).
1852
+ function isPlainPrimaryClick(event) {
1853
+ return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
1854
+ }
1855
+ function DefaultLink({ href, variant = "default", target, rel, onPress, className, children, testId, dataAttributes, }) {
1856
+ const variantClass = variant === "button"
1857
+ ? buttonVariants({ variant: "default" })
1858
+ : variant === "muted"
1859
+ ? "text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
1860
+ : "text-primary underline-offset-4 hover:underline";
1861
+ return (_jsx("a", { href: isSafeHref(href) ? href : "#", target: target, rel: rel ?? (target === "_blank" ? "noopener noreferrer" : undefined), ...dataAttributes, "data-testid": testId, className: cn(variantClass, className), onClick: onPress === undefined
1862
+ ? undefined
1863
+ : (event) => {
1864
+ if (!isPlainPrimaryClick(event))
1865
+ return;
1866
+ event.preventDefault();
1867
+ onPress();
1868
+ }, children: children }));
1869
+ }
1870
+ function DefaultProgress({ value, tone, ariaLabel, testId }) {
1871
+ return _jsx(ProgressBar, { value: value, tone: tone, ariaLabel: ariaLabel, testId: testId });
1872
+ }
1873
+ function DefaultStepBar({ steps, currentIndex, compactLabel, onStepSelect, doneSteps, selectableSteps, narrowLayout, orientation, heading, description, subtitles, upNext, testId, compactTestId, }) {
1874
+ return (_jsx(StepBar, { steps: steps, currentIndex: currentIndex, compactLabel: compactLabel, onStepSelect: onStepSelect, doneSteps: doneSteps, selectableSteps: selectableSteps, narrowLayout: narrowLayout, orientation: orientation, heading: heading, description: description, subtitles: subtitles, upNext: upNext, testId: testId, compactTestId: compactTestId }));
1875
+ }
1876
+ function DefaultWizardStepGroup({ hidden, inset, children }) {
1877
+ const visibleClass = inset === true ? "flex min-w-0 flex-col gap-4 p-6" : "contents";
1878
+ return (_jsx("fieldset", { disabled: hidden, hidden: hidden, className: hidden ? undefined : visibleClass, children: children }));
1879
+ }
1880
+ function DefaultHeading({ variant = "page", children, testId }) {
1881
+ // Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
1882
+ // Section-Heading = h2 mit uppercase + muted-foreground — derselbe
1883
+ // Look wie der Section-Header in Forms, aber als Standalone-Component
1884
+ // für Demo-Pages und Custom-Screens nutzbar.
1885
+ if (variant === "section") {
1886
+ return (_jsx("h2", { "data-testid": testId, className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: children }));
1887
+ }
1888
+ return (_jsx("h1", { "data-testid": testId, className: "text-2xl font-semibold tracking-tight", children: children }));
1889
+ }
1890
+ import { ConfigCascadeView as DefaultConfigCascadeView } from "../components/config-cascade.js";
1891
+ import { ConfigSourceBadge as DefaultConfigSourceBadge } from "../components/config-source-badge.js";
1892
+ // Generische Card-Chrome (rounded-xl wie die Entity-Card) — slot- + options-
1893
+ // basiert, damit der Contract additiv wächst und Consumer nie migriert werden.
1894
+ export function DefaultCard({ slots, options, className, testId, dataAttributes, children, }) {
1895
+ const screenBody = options?.screenBody ?? false;
1896
+ const padded = screenBody ? false : (options?.padded ?? true);
1897
+ const radius = options?.radius ?? "xl";
1898
+ const footerBordered = options?.footerBordered ?? true;
1899
+ const footerInset = options?.footerInset ?? true;
1900
+ const fillHeight = options?.fillHeight ?? false;
1901
+ const framed = screenBody ? false : (options?.framed ?? true);
1902
+ const s = slots ?? {};
1903
+ const defaultHeader = s.title !== undefined ||
1904
+ s.subtitle !== undefined ||
1905
+ s.headerContent !== undefined ||
1906
+ s.headerActions !== undefined ? (_jsxs("div", { className: cn("flex flex-col gap-3 px-[var(--card-padding)] pt-6 pb-4", fillHeight && "shrink-0"), children: [(s.title !== undefined || s.subtitle !== undefined || s.headerActions !== undefined) && (_jsxs("div", { className: "flex flex-wrap items-start justify-between gap-3", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [s.title !== undefined && (_jsx("h3", { className: "text-base font-semibold leading-none tracking-tight", children: s.title })), s.subtitle !== undefined && (_jsx("p", { className: "text-sm text-muted-foreground", children: s.subtitle }))] }), s.headerActions] })), s.headerContent] })) : null;
1907
+ const header = s.header ?? defaultHeader;
1908
+ const hasHeader = header !== null && header !== undefined;
1909
+ const card = (_jsxs("div", { ...dataAttributes, "data-slot": "card", "data-testid": testId, className: cn(framed ? cardSurface({ radius }) : "flex flex-col",
1910
+ // An unframed card has no radius to clip; overflow-hidden would also
1911
+ // zero its flex-item min-height and cut off content inside a scroller.
1912
+ (framed || fillHeight) && "overflow-hidden",
1913
+ // Same "no flex-1" reasoning as DefaultForm's own fillHeight card
1914
+ // (fw#2722/#2778): sizes to content and only shrinks (min-h-0) once
1915
+ // an ancestor is itself height-constrained.
1916
+ fillHeight && "min-h-0 flex flex-col", className), children: [header, children != null && (_jsx("div", { className: cn("grow", screenBody && "flex flex-col gap-6", padded &&
1917
+ (hasHeader
1918
+ ? "px-[var(--card-padding)] pb-[var(--card-padding)]"
1919
+ : "p-[var(--card-padding)]"),
1920
+ // The one region allowed to shrink and scroll internally — mirrors
1921
+ // FormSections' fillHeight === true && "min-h-0" treatment of the
1922
+ // section it wraps.
1923
+ fillHeight && "min-h-0 flex flex-col"), children: children })), s.footer !== undefined && (_jsx("div", { className: cn(cardFooter, footerInset && cardFooterInset, footerBordered && cardFooterBorder, fillHeight && "shrink-0"), children: s.footer }))] }));
1924
+ return (_jsx(InsideScreenFormColumnContext.Provider, { value: false, children: screenBody ? _jsx(FormScreenShell, { children: card }) : card }));
1925
+ }
1926
+ // otpauth:// enrollment URI → scannable QR (errorCorrectionLevel "H", ~30%
1927
+ // redundancy — more resilient to camera/lighting issues than the default, no
1928
+ // downside for a code this short-lived). Own component (not inline in
1929
+ // values.map) because QRCode.toString is async — a hook inside .map would
1930
+ // violate the rules of hooks.
1931
+ function QrSecretValue({ value }) {
1932
+ const [svg, setSvg] = useState(null);
1933
+ const [failed, setFailed] = useState(false);
1934
+ useEffect(() => {
1935
+ let cancelled = false;
1936
+ setFailed(false);
1937
+ QRCode.toString(value, { type: "svg", errorCorrectionLevel: "H" })
1938
+ .then((result) => {
1939
+ if (!cancelled)
1940
+ setSvg(result);
1941
+ })
1942
+ .catch(() => {
1943
+ // A generic `qr` field may have no separate text display, so fall back to the plain value.
1944
+ if (!cancelled)
1945
+ setFailed(true);
1946
+ });
1947
+ return () => {
1948
+ cancelled = true;
1949
+ };
1950
+ }, [value]);
1951
+ if (failed) {
1952
+ return (_jsx("code", { className: "flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: value }));
1953
+ }
1954
+ if (svg === null)
1955
+ return null;
1956
+ return (_jsx("div", { className: "h-40 w-40",
1957
+ // qrcode's own SVG string output, not user input — safe to inline
1958
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
1959
+ dangerouslySetInnerHTML: { __html: svg } }));
1960
+ }
1961
+ // One-time secret reveal (fw#2548, secretMint confirm phase) — monospaced
1962
+ // per-value display with a copy button that flips to `copiedLabel` on
1963
+ // success. Silent-catch on clipboard error mirrors the copy-link pattern in
1964
+ // create-app.tsx.
1965
+ function DefaultSecretReveal({ values, copyLabel, copiedLabel, testId, }) {
1966
+ const [copiedIndex, setCopiedIndex] = useState(null);
1967
+ // Labels are not unique per reveal; suffix repeats so React keys stay distinct.
1968
+ const seenLabels = new Map();
1969
+ const rowKeys = values.map((v) => {
1970
+ const occurrence = seenLabels.get(v.label) ?? 0;
1971
+ seenLabels.set(v.label, occurrence + 1);
1972
+ return `${v.label}#${occurrence}`;
1973
+ });
1974
+ return (_jsx("div", { "data-testid": testId, className: "flex flex-col gap-3", children: values.map((v, i) => (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("span", { className: "text-sm font-medium text-muted-foreground", children: v.label }), _jsxs("div", { className: "flex items-start gap-2", children: [v.qr === true ? (_jsx(QrSecretValue, { value: v.value })) : v.multiline ? (_jsx("pre", { className: "flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: v.value })) : (_jsx("code", { className: "flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: v.value })), v.copyable && (_jsx(UiButton, { type: "button", variant: "outline", size: "sm", onClick: async () => {
1975
+ try {
1976
+ await navigator.clipboard.writeText(v.value);
1977
+ setCopiedIndex(i);
1978
+ }
1979
+ catch {
1980
+ // clipboard blocked (non-secure context) — no fallback UI needed here
1981
+ }
1982
+ }, children: copiedIndex === i ? copiedLabel : copyLabel }))] })] }, rowKeys[i]))) }));
1983
+ }
1984
+ export const defaultPrimitives = {
1985
+ Button: DefaultButton,
1986
+ Banner: DefaultBanner,
1987
+ Field: DefaultField,
1988
+ Input: DefaultInput,
1989
+ DataTable: DefaultDataTable,
1990
+ EmbeddedListInput,
1991
+ Form: DefaultForm,
1992
+ Section: DefaultSection,
1993
+ Card: DefaultCard,
1994
+ Grid: DefaultGrid,
1995
+ GridCell: DefaultGridCell,
1996
+ Text: DefaultText,
1997
+ Heading: DefaultHeading,
1998
+ Dialog: DefaultDialog,
1999
+ Modal: DefaultModal,
2000
+ Drawer: DefaultDrawer,
2001
+ Lightbox: DefaultLightbox,
2002
+ ConfigSourceBadge: DefaultConfigSourceBadge,
2003
+ ConfigCascadeView: DefaultConfigCascadeView,
2004
+ Link: DefaultLink,
2005
+ Progress: DefaultProgress,
2006
+ StepBar: DefaultStepBar,
2007
+ WizardStepGroup: DefaultWizardStepGroup,
2008
+ Tabs: DefaultTabs,
2009
+ StatusBadge: DefaultStatusBadge,
2010
+ Metric: DefaultMetric,
2011
+ MetricBand: DefaultMetricBand,
2012
+ PageHeader: DefaultPageHeader,
2013
+ JsonView: DefaultJsonView,
2014
+ FillContainer: DefaultFillContainer,
2015
+ ActionOverflowMenu,
2016
+ SecretReveal: DefaultSecretReveal,
2017
+ StickyActionBar,
2018
+ CopyButton,
2019
+ ShareButton,
2020
+ PromoPanel,
2021
+ };