@cosmicdrift/kumiko-renderer 0.374.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.
- package/README.md +2 -2
- package/dist/app/action-form-support.d.ts +9 -2
- package/dist/app/action-form-support.js +24 -2
- package/dist/app/client-actions.d.ts +2 -2
- package/dist/app/column-renderers.js +1 -1
- package/dist/app/config-edit-shim.js +1 -1
- package/dist/app/content-preview.js +1 -6
- package/dist/app/custom-screens.js +1 -1
- package/dist/app/dashboard-body.d.ts +1 -1
- package/dist/app/entity-list-expanded-row.d.ts +1 -1
- package/dist/app/form-schema.d.ts +1 -1
- package/dist/app/form-schema.js +2 -2
- package/dist/app/kumiko-screen.d.ts +1 -1
- package/dist/app/kumiko-screen.js +7 -6
- package/dist/app/list-empty-content.d.ts +1 -1
- package/dist/app/list-facets.d.ts +1 -1
- package/dist/app/nav.js +2 -2
- package/dist/app/query-state-banners.d.ts +1 -1
- package/dist/app/reference-link.d.ts +1 -1
- package/dist/app/row-actions.d.ts +1 -1
- package/dist/app/secret-mint-body.d.ts +1 -1
- package/dist/app/secrets-edit-body.d.ts +1 -1
- package/dist/app/use-record-actions.d.ts +1 -1
- package/dist/app/write-failed-error.d.ts +1 -1
- package/dist/components/availability-options-select.d.ts +1 -1
- package/dist/components/embedded-list-field.d.ts +1 -1
- package/dist/components/embedded-list-field.js +1 -1
- package/dist/components/file-list-field.d.ts +1 -1
- package/dist/components/grid-cell-for-field.d.ts +1 -1
- package/dist/components/multi-select-checkboxes.d.ts +1 -1
- package/dist/components/multi-select-checkboxes.js +1 -1
- package/dist/components/query-options-select.d.ts +1 -1
- package/dist/components/related-list-section.d.ts +1 -1
- package/dist/components/render-edit-logic.d.ts +1 -1
- package/dist/components/render-edit-types.d.ts +2 -2
- package/dist/components/render-edit.d.ts +1 -1
- package/dist/components/render-edit.js +4 -2
- package/dist/components/render-field.d.ts +1 -1
- package/dist/components/render-field.js +4 -1
- package/dist/components/render-list.d.ts +1 -1
- package/dist/components/render-list.js +20 -4
- package/dist/components/write-form-section.d.ts +1 -1
- package/dist/components/write-form-section.js +1 -1
- package/dist/context/dispatcher-context.d.ts +1 -1
- package/dist/format-when.js +1 -1
- package/dist/headless/apex/css.d.ts +3 -0
- package/dist/headless/apex/css.js +298 -0
- package/dist/headless/apex/index.d.ts +236 -0
- package/dist/headless/apex/index.js +377 -0
- package/dist/headless/apex/lightbox.d.ts +4 -0
- package/dist/headless/apex/lightbox.js +81 -0
- package/dist/headless/contracts/asset.d.ts +15 -0
- package/dist/headless/contracts/asset.js +17 -0
- package/dist/headless/contracts/index.d.ts +3 -0
- package/dist/headless/contracts/index.js +1 -0
- package/dist/headless/contracts/locale.d.ts +7 -0
- package/dist/headless/contracts/locale.js +11 -0
- package/dist/headless/contracts/primitives.d.ts +93 -0
- package/dist/headless/contracts/primitives.js +21 -0
- package/dist/headless/dispatcher/index.d.ts +2 -0
- package/dist/headless/dispatcher/index.js +1 -0
- package/dist/headless/dispatcher/types.d.ts +86 -0
- package/dist/headless/dispatcher/types.js +34 -0
- package/dist/headless/form/form-controller.d.ts +2 -0
- package/dist/headless/form/form-controller.js +409 -0
- package/dist/headless/form/index.d.ts +3 -0
- package/dist/headless/form/index.js +2 -0
- package/dist/headless/form/types.d.ts +76 -0
- package/dist/headless/form/types.js +1 -0
- package/dist/headless/form/zod-bridge.d.ts +10 -0
- package/dist/headless/form/zod-bridge.js +103 -0
- package/dist/headless/format/document-values.d.ts +12 -0
- package/dist/headless/format/document-values.js +70 -0
- package/dist/headless/format/escape.d.ts +5 -0
- package/dist/headless/format/escape.js +75 -0
- package/dist/headless/format/html-template.d.ts +9 -0
- package/dist/headless/format/html-template.js +39 -0
- package/dist/headless/format/index.d.ts +12 -0
- package/dist/headless/format/index.js +237 -0
- package/dist/headless/format/money.d.ts +3 -0
- package/dist/headless/format/money.js +39 -0
- package/dist/headless/index.d.ts +14 -0
- package/dist/headless/index.js +9 -0
- package/dist/headless/locale-routing/index.d.ts +41 -0
- package/dist/headless/locale-routing/index.js +145 -0
- package/dist/headless/nav/index.d.ts +2 -0
- package/dist/headless/nav/index.js +1 -0
- package/dist/headless/nav/resolve.d.ts +2 -0
- package/dist/headless/nav/resolve.js +71 -0
- package/dist/headless/nav/types.d.ts +26 -0
- package/dist/headless/nav/types.js +1 -0
- package/dist/headless/store/create-store.d.ts +2 -0
- package/dist/headless/store/create-store.js +43 -0
- package/dist/headless/store/equality.d.ts +1 -0
- package/dist/headless/store/equality.js +34 -0
- package/dist/headless/store/index.d.ts +3 -0
- package/dist/headless/store/index.js +2 -0
- package/dist/headless/store/types.d.ts +7 -0
- package/dist/headless/store/types.js +10 -0
- package/dist/headless/view-model/edit.d.ts +18 -0
- package/dist/headless/view-model/edit.js +501 -0
- package/dist/headless/view-model/embedded-list.d.ts +27 -0
- package/dist/headless/view-model/embedded-list.js +63 -0
- package/dist/headless/view-model/index.d.ts +7 -0
- package/dist/headless/view-model/index.js +3 -0
- package/dist/headless/view-model/list-file-display.d.ts +3 -0
- package/dist/headless/view-model/list-file-display.js +27 -0
- package/dist/headless/view-model/list.d.ts +16 -0
- package/dist/headless/view-model/list.js +229 -0
- package/dist/headless/view-model/types.d.ts +382 -0
- package/dist/headless/view-model/types.js +1 -0
- package/dist/hooks/use-ai-text.d.ts +1 -1
- package/dist/hooks/use-form.d.ts +1 -1
- package/dist/hooks/use-form.js +1 -1
- package/dist/hooks/use-mutation.d.ts +1 -1
- package/dist/hooks/use-query.d.ts +1 -1
- package/dist/hooks/use-store.d.ts +1 -1
- package/dist/hooks/use-store.js +7 -7
- package/dist/hooks/use-stream-handler.d.ts +1 -1
- package/dist/i18n.d.ts +1 -1
- package/dist/i18n.js +1 -1
- package/dist/index.js +4 -4
- package/dist/live/csrf.d.ts +2 -0
- package/dist/live/csrf.js +60 -0
- package/dist/live/dispatcher-live.d.ts +7 -0
- package/dist/live/dispatcher-live.js +286 -0
- package/dist/live/envelopes.d.ts +101 -0
- package/dist/live/envelopes.js +35 -0
- package/dist/live/error-mapping.d.ts +16 -0
- package/dist/live/error-mapping.js +82 -0
- package/dist/live/index.d.ts +7 -0
- package/dist/live/index.js +5 -0
- package/dist/live/locale.d.ts +2 -0
- package/dist/live/locale.js +25 -0
- package/dist/live/sse-stream.d.ts +14 -0
- package/dist/live/sse-stream.js +128 -0
- package/dist/primitives.d.ts +35 -36
- package/dist/primitives.js +1 -1
- package/dist/session/session-ended.d.ts +1 -1
- package/dist/tokens.d.ts +3 -4
- package/dist/tokens.js +4 -5
- package/dist/web/app/app-schema-boot.d.ts +19 -0
- package/dist/web/app/app-schema-boot.js +124 -0
- package/dist/web/app/browser-locale.d.ts +22 -0
- package/dist/web/app/browser-locale.js +78 -0
- package/dist/web/app/client-plugin.d.ts +106 -0
- package/dist/web/app/client-plugin.js +12 -0
- package/dist/web/app/create-app.d.ts +104 -0
- package/dist/web/app/create-app.js +498 -0
- package/dist/web/app/create-public-surface.d.ts +36 -0
- package/dist/web/app/create-public-surface.js +40 -0
- package/dist/web/app/dashboard-body.d.ts +3 -0
- package/dist/web/app/dashboard-body.js +865 -0
- package/dist/web/app/dashboard-refresh.d.ts +6 -0
- package/dist/web/app/dashboard-refresh.js +15 -0
- package/dist/web/app/document-lang-sync.d.ts +4 -0
- package/dist/web/app/document-lang-sync.js +27 -0
- package/dist/web/app/draft-storage.d.ts +11 -0
- package/dist/web/app/draft-storage.js +53 -0
- package/dist/web/app/nav-providers-context.d.ts +13 -0
- package/dist/web/app/nav-providers-context.js +18 -0
- package/dist/web/app/nav.d.ts +40 -0
- package/dist/web/app/nav.js +231 -0
- package/dist/web/app/plain-content-editor.d.ts +3 -0
- package/dist/web/app/plain-content-editor.js +30 -0
- package/dist/web/app/relative-time.d.ts +10 -0
- package/dist/web/app/relative-time.js +56 -0
- package/dist/web/app/resolvers-context.d.ts +12 -0
- package/dist/web/app/resolvers-context.js +10 -0
- package/dist/web/app/rich-content-editor.d.ts +3 -0
- package/dist/web/app/rich-content-editor.js +15 -0
- package/dist/web/app/root-error-fallback.d.ts +2 -0
- package/dist/web/app/root-error-fallback.js +21 -0
- package/dist/web/app/screen-error-fallback.d.ts +6 -0
- package/dist/web/app/screen-error-fallback.js +17 -0
- package/dist/web/app/tiptap-editor.d.ts +3 -0
- package/dist/web/app/tiptap-editor.js +120 -0
- package/dist/web/app/toast-action-notifier.d.ts +5 -0
- package/dist/web/app/toast-action-notifier.js +17 -0
- package/dist/web/components/config-cascade.d.ts +13 -0
- package/dist/web/components/config-cascade.js +155 -0
- package/dist/web/components/config-source-badge.d.ts +6 -0
- package/dist/web/components/config-source-badge.js +55 -0
- package/dist/web/default-color-scheme.d.ts +2 -0
- package/dist/web/default-color-scheme.js +3 -0
- package/dist/web/icons.d.ts +94 -0
- package/dist/web/icons.js +100 -0
- package/dist/web/index.d.ts +78 -0
- package/dist/web/index.js +53 -0
- package/dist/web/layout/app-layout.d.ts +21 -0
- package/dist/web/layout/app-layout.js +8 -0
- package/dist/web/layout/avatar.d.ts +12 -0
- package/dist/web/layout/avatar.js +63 -0
- package/dist/web/layout/default-app-shell.d.ts +41 -0
- package/dist/web/layout/default-app-shell.js +16 -0
- package/dist/web/layout/editor-panel.d.ts +10 -0
- package/dist/web/layout/editor-panel.js +29 -0
- package/dist/web/layout/fill-classes.d.ts +9 -0
- package/dist/web/layout/fill-classes.js +15 -0
- package/dist/web/layout/filter-app-schema-navs.d.ts +7 -0
- package/dist/web/layout/filter-app-schema-navs.js +68 -0
- package/dist/web/layout/header-action-group.d.ts +6 -0
- package/dist/web/layout/header-action-group.js +8 -0
- package/dist/web/layout/header-overflow-menu.d.ts +4 -0
- package/dist/web/layout/header-overflow-menu.js +8 -0
- package/dist/web/layout/language-menu-items.d.ts +8 -0
- package/dist/web/layout/language-menu-items.js +16 -0
- package/dist/web/layout/language-switcher.d.ts +25 -0
- package/dist/web/layout/language-switcher.js +46 -0
- package/dist/web/layout/nav-tree.d.ts +19 -0
- package/dist/web/layout/nav-tree.js +536 -0
- package/dist/web/layout/page-header-slot.d.ts +25 -0
- package/dist/web/layout/page-header-slot.js +50 -0
- package/dist/web/layout/profile-menu.d.ts +13 -0
- package/dist/web/layout/profile-menu.js +6 -0
- package/dist/web/layout/shell-breadcrumb.d.ts +11 -0
- package/dist/web/layout/shell-breadcrumb.js +34 -0
- package/dist/web/layout/shell-header.d.ts +12 -0
- package/dist/web/layout/shell-header.js +159 -0
- package/dist/web/layout/sidebar-brand.d.ts +15 -0
- package/dist/web/layout/sidebar-brand.js +9 -0
- package/dist/web/layout/sidebar-panel.d.ts +41 -0
- package/dist/web/layout/sidebar-panel.js +107 -0
- package/dist/web/layout/sidebar-user.d.ts +8 -0
- package/dist/web/layout/sidebar-user.js +17 -0
- package/dist/web/layout/sidebar.d.ts +17 -0
- package/dist/web/layout/sidebar.js +11 -0
- package/dist/web/layout/target-resolver-stub.d.ts +13 -0
- package/dist/web/layout/target-resolver-stub.js +61 -0
- package/dist/web/layout/target-url.d.ts +12 -0
- package/dist/web/layout/target-url.js +77 -0
- package/dist/web/layout/theme-label.d.ts +15 -0
- package/dist/web/layout/theme-label.js +35 -0
- package/dist/web/layout/theme-menu-item.d.ts +12 -0
- package/dist/web/layout/theme-menu-item.js +16 -0
- package/dist/web/layout/theme-toggle.d.ts +17 -0
- package/dist/web/layout/theme-toggle.js +22 -0
- package/dist/web/layout/topbar.d.ts +8 -0
- package/dist/web/layout/topbar.js +4 -0
- package/dist/web/layout/workspace-shell.d.ts +63 -0
- package/dist/web/layout/workspace-shell.js +220 -0
- package/dist/web/layout/workspace-switcher.d.ts +15 -0
- package/dist/web/layout/workspace-switcher.js +36 -0
- package/dist/web/lib/accept-attr.d.ts +1 -0
- package/dist/web/lib/accept-attr.js +6 -0
- package/dist/web/lib/clamp.d.ts +1 -0
- package/dist/web/lib/clamp.js +3 -0
- package/dist/web/lib/cn.d.ts +6 -0
- package/dist/web/lib/cn.js +9 -0
- package/dist/web/lib/download.d.ts +2 -0
- package/dist/web/lib/download.js +24 -0
- package/dist/web/lib/file-ref-download.d.ts +15 -0
- package/dist/web/lib/file-ref-download.js +62 -0
- package/dist/web/lib/file-upload-form-data.d.ts +7 -0
- package/dist/web/lib/file-upload-form-data.js +11 -0
- package/dist/web/lib/resize-image.d.ts +1 -0
- package/dist/web/lib/resize-image.js +66 -0
- package/dist/web/lib/upload-file-ref.d.ts +9 -0
- package/dist/web/lib/upload-file-ref.js +29 -0
- package/dist/web/primitives/action-menu.d.ts +34 -0
- package/dist/web/primitives/action-menu.js +30 -0
- package/dist/web/primitives/calendar-popover.d.ts +13 -0
- package/dist/web/primitives/calendar-popover.js +76 -0
- package/dist/web/primitives/combobox.d.ts +56 -0
- package/dist/web/primitives/combobox.js +127 -0
- package/dist/web/primitives/date-field.d.ts +18 -0
- package/dist/web/primitives/date-field.js +74 -0
- package/dist/web/primitives/date-input.d.ts +1 -0
- package/dist/web/primitives/date-input.js +5 -0
- package/dist/web/primitives/date-parse.d.ts +11 -0
- package/dist/web/primitives/date-parse.js +159 -0
- package/dist/web/primitives/dialog.d.ts +3 -0
- package/dist/web/primitives/dialog.js +42 -0
- package/dist/web/primitives/drawer.d.ts +3 -0
- package/dist/web/primitives/drawer.js +17 -0
- package/dist/web/primitives/dropdown-menu.d.ts +15 -0
- package/dist/web/primitives/dropdown-menu.js +56 -0
- package/dist/web/primitives/embedded-list-input.d.ts +9 -0
- package/dist/web/primitives/embedded-list-input.js +382 -0
- package/dist/web/primitives/file-thumbnail.d.ts +25 -0
- package/dist/web/primitives/file-thumbnail.js +90 -0
- package/dist/web/primitives/file-upload.d.ts +21 -0
- package/dist/web/primitives/file-upload.js +56 -0
- package/dist/web/primitives/index.d.ts +34 -0
- package/dist/web/primitives/index.js +2021 -0
- package/dist/web/primitives/json-view.d.ts +7 -0
- package/dist/web/primitives/json-view.js +84 -0
- package/dist/web/primitives/layout.d.ts +27 -0
- package/dist/web/primitives/layout.js +30 -0
- package/dist/web/primitives/lightbox.d.ts +3 -0
- package/dist/web/primitives/lightbox.js +36 -0
- package/dist/web/primitives/locale-number.d.ts +2 -0
- package/dist/web/primitives/locale-number.js +54 -0
- package/dist/web/primitives/located-timestamp-input.d.ts +22 -0
- package/dist/web/primitives/located-timestamp-input.js +22 -0
- package/dist/web/primitives/metric.d.ts +5 -0
- package/dist/web/primitives/metric.js +18 -0
- package/dist/web/primitives/modal-shell.d.ts +17 -0
- package/dist/web/primitives/modal-shell.js +9 -0
- package/dist/web/primitives/modal.d.ts +3 -0
- package/dist/web/primitives/modal.js +13 -0
- package/dist/web/primitives/money-input.d.ts +18 -0
- package/dist/web/primitives/money-input.js +122 -0
- package/dist/web/primitives/narrow-pinned-footer.d.ts +12 -0
- package/dist/web/primitives/narrow-pinned-footer.js +153 -0
- package/dist/web/primitives/number-input.d.ts +18 -0
- package/dist/web/primitives/number-input.js +53 -0
- package/dist/web/primitives/option-groups.d.ts +11 -0
- package/dist/web/primitives/option-groups.js +27 -0
- package/dist/web/primitives/page-header.d.ts +3 -0
- package/dist/web/primitives/page-header.js +52 -0
- package/dist/web/primitives/promo-panel.d.ts +3 -0
- package/dist/web/primitives/promo-panel.js +11 -0
- package/dist/web/primitives/segmented-select.d.ts +21 -0
- package/dist/web/primitives/segmented-select.js +62 -0
- package/dist/web/primitives/share-actions.d.ts +4 -0
- package/dist/web/primitives/share-actions.js +56 -0
- package/dist/web/primitives/status-badge.d.ts +3 -0
- package/dist/web/primitives/status-badge.js +5 -0
- package/dist/web/primitives/sticky-action-bar.d.ts +5 -0
- package/dist/web/primitives/sticky-action-bar.js +15 -0
- package/dist/web/primitives/tabs.d.ts +3 -0
- package/dist/web/primitives/tabs.js +107 -0
- package/dist/web/primitives/timestamp-input.d.ts +27 -0
- package/dist/web/primitives/timestamp-input.js +87 -0
- package/dist/web/primitives/toast.d.ts +19 -0
- package/dist/web/primitives/toast.js +63 -0
- package/dist/web/primitives/tz-input.d.ts +11 -0
- package/dist/web/primitives/tz-input.js +8 -0
- package/dist/web/primitives/tz-options.d.ts +4 -0
- package/dist/web/primitives/tz-options.js +17 -0
- package/dist/web/primitives/use-keyboard-inset.d.ts +1 -0
- package/dist/web/primitives/use-keyboard-inset.js +28 -0
- package/dist/web/primitives/use-narrow-viewport.d.ts +4 -0
- package/dist/web/primitives/use-narrow-viewport.js +46 -0
- package/dist/web/react-css-custom-properties.d.ts +6 -0
- package/dist/web/react-css-custom-properties.js +1 -0
- package/dist/web/sse/live-events.d.ts +15 -0
- package/dist/web/sse/live-events.js +148 -0
- package/dist/web/theme-plugin.d.ts +41 -0
- package/dist/web/theme-plugin.js +123 -0
- package/dist/web/tokens.d.ts +24 -0
- package/dist/web/tokens.js +203 -0
- package/dist/web/ui/avatar.d.ts +11 -0
- package/dist/web/ui/avatar.js +25 -0
- package/dist/web/ui/badge.d.ts +9 -0
- package/dist/web/ui/badge.js +26 -0
- package/dist/web/ui/breadcrumb.d.ts +11 -0
- package/dist/web/ui/breadcrumb.js +29 -0
- package/dist/web/ui/button.d.ts +10 -0
- package/dist/web/ui/button.js +37 -0
- package/dist/web/ui/checkbox.d.ts +4 -0
- package/dist/web/ui/checkbox.js +11 -0
- package/dist/web/ui/input.d.ts +3 -0
- package/dist/web/ui/input.js +8 -0
- package/dist/web/ui/label.d.ts +4 -0
- package/dist/web/ui/label.js +10 -0
- package/dist/web/ui/resizable.d.ts +7 -0
- package/dist/web/ui/resizable.js +16 -0
- package/dist/web/ui/separator.d.ts +4 -0
- package/dist/web/ui/separator.js +10 -0
- package/dist/web/ui/sheet.d.ts +14 -0
- package/dist/web/ui/sheet.js +42 -0
- package/dist/web/ui/sidebar.d.ts +73 -0
- package/dist/web/ui/sidebar.js +211 -0
- package/dist/web/ui/skeleton.d.ts +2 -0
- package/dist/web/ui/skeleton.js +7 -0
- package/dist/web/ui/switch.d.ts +6 -0
- package/dist/web/ui/switch.js +10 -0
- package/dist/web/ui/table.d.ts +10 -0
- package/dist/web/ui/table.js +30 -0
- package/dist/web/ui/tabs.d.ts +11 -0
- package/dist/web/ui/tabs.js +31 -0
- package/dist/web/ui/textarea.d.ts +3 -0
- package/dist/web/ui/textarea.js +8 -0
- package/dist/web/ui/tooltip.d.ts +7 -0
- package/dist/web/ui/tooltip.js +19 -0
- package/dist/web/ui/use-mobile.d.ts +1 -0
- package/dist/web/ui/use-mobile.js +16 -0
- package/dist/web/version/update-checker.d.ts +9 -0
- package/dist/web/version/update-checker.js +88 -0
- package/dist/web/widgets/ai-text-field.d.ts +36 -0
- package/dist/web/widgets/ai-text-field.js +137 -0
- package/dist/web/widgets/chart-window.d.ts +58 -0
- package/dist/web/widgets/chart-window.js +190 -0
- package/dist/web/widgets/charts.d.ts +208 -0
- package/dist/web/widgets/charts.js +685 -0
- package/dist/web/widgets/collapsible-section.d.ts +10 -0
- package/dist/web/widgets/collapsible-section.js +17 -0
- package/dist/web/widgets/dashboard-list.d.ts +21 -0
- package/dist/web/widgets/dashboard-list.js +45 -0
- package/dist/web/widgets/detail-list.d.ts +15 -0
- package/dist/web/widgets/detail-list.js +8 -0
- package/dist/web/widgets/drawer.d.ts +65 -0
- package/dist/web/widgets/drawer.js +142 -0
- package/dist/web/widgets/feed-list.d.ts +13 -0
- package/dist/web/widgets/feed-list.js +9 -0
- package/dist/web/widgets/floating-panel.d.ts +30 -0
- package/dist/web/widgets/floating-panel.js +242 -0
- package/dist/web/widgets/form-fields.d.ts +93 -0
- package/dist/web/widgets/form-fields.js +50 -0
- package/dist/web/widgets/index.d.ts +27 -0
- package/dist/web/widgets/index.js +30 -0
- package/dist/web/widgets/infinity-list.d.ts +46 -0
- package/dist/web/widgets/infinity-list.js +211 -0
- package/dist/web/widgets/mode-switch.d.ts +21 -0
- package/dist/web/widgets/mode-switch.js +25 -0
- package/dist/web/widgets/photo-slots.d.ts +29 -0
- package/dist/web/widgets/photo-slots.js +52 -0
- package/dist/web/widgets/plan-card.d.ts +36 -0
- package/dist/web/widgets/plan-card.js +13 -0
- package/dist/web/widgets/progress-bar.d.ts +19 -0
- package/dist/web/widgets/progress-bar.js +24 -0
- package/dist/web/widgets/progress-list.d.ts +19 -0
- package/dist/web/widgets/progress-list.js +11 -0
- package/dist/web/widgets/public-shell.d.ts +22 -0
- package/dist/web/widgets/public-shell.js +15 -0
- package/dist/web/widgets/query-table.d.ts +29 -0
- package/dist/web/widgets/query-table.js +30 -0
- package/dist/web/widgets/result-panel.d.ts +69 -0
- package/dist/web/widgets/result-panel.js +30 -0
- package/dist/web/widgets/section-card.d.ts +12 -0
- package/dist/web/widgets/section-card.js +9 -0
- package/dist/web/widgets/sheet-parts.d.ts +9 -0
- package/dist/web/widgets/sheet-parts.js +11 -0
- package/dist/web/widgets/side-by-side-table.d.ts +31 -0
- package/dist/web/widgets/side-by-side-table.js +11 -0
- package/dist/web/widgets/stat.d.ts +54 -0
- package/dist/web/widgets/stat.js +65 -0
- package/dist/web/widgets/states.d.ts +23 -0
- package/dist/web/widgets/states.js +20 -0
- package/dist/web/widgets/status-badge.d.ts +15 -0
- package/dist/web/widgets/status-badge.js +24 -0
- package/dist/web/widgets/step-bar.d.ts +35 -0
- package/dist/web/widgets/step-bar.js +60 -0
- package/dist/web/widgets/upload-zone.d.ts +24 -0
- package/dist/web/widgets/upload-zone.js +108 -0
- package/dist/web/widgets/use-draft.d.ts +20 -0
- package/dist/web/widgets/use-draft.js +21 -0
- package/dist/web/widgets/use-pointer-drag.d.ts +21 -0
- package/dist/web/widgets/use-pointer-drag.js +53 -0
- package/package.json +105 -7
- package/src/changes.json +53 -0
- package/src/web/fonts/OFL.txt +93 -0
- package/src/web/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/src/web/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/src/web/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/src/web/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/src/web/fonts/ibm-plex-sans-latin-400.woff2 +0 -0
- package/src/web/fonts/ibm-plex-sans-latin-500.woff2 +0 -0
- package/src/web/fonts/ibm-plex-sans-latin-600.woff2 +0 -0
- package/src/web/fonts/ibm-plex-sans-latin-ext-400.woff2 +0 -0
- package/src/web/fonts/ibm-plex-sans-latin-ext-500.woff2 +0 -0
- package/src/web/fonts/ibm-plex-sans-latin-ext-600.woff2 +0 -0
- 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
|
+
};
|