@cosmicdrift/kumiko-renderer 0.326.1 → 0.328.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.
@@ -49,16 +49,21 @@ import {
49
49
  import { RenderList } from "../components/render-list";
50
50
  import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-context";
51
51
  import { useUserRoles } from "../context/user-roles-context";
52
- import { type ListSort, useListUrlState } from "../hooks/use-list-url-state";
52
+ import { type ListSort, PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state";
53
53
  import { type UseQueryResult, useQuery } from "../hooks/use-query";
54
- import { useOptionalTimeZone, useTranslation } from "../i18n";
54
+ import { useLocale, useOptionalTimeZone, useTranslation } from "../i18n";
55
+ import { InsideDrawerProvider, useInsideDrawer } from "../inside-drawer";
56
+ import { PageHeaderSlotAvailableProvider, usePageHeaderSlotAvailable } from "../page-header-slot";
55
57
  import {
56
58
  type DataTableDateRangeFacet,
57
59
  type DataTableFacet,
58
60
  type DataTableRowAction,
61
+ type StatusTone,
62
+ statusToneForOptionTone,
59
63
  statusToneForValue,
60
64
  usePrimitives,
61
65
  } from "../primitives";
66
+ import { screenFillsHeight } from "../screen-fills-height";
62
67
  import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
63
68
  import { useAppFeatures } from "./app-features-context";
64
69
  import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
@@ -200,8 +205,11 @@ export function KumikoScreen({
200
205
  }
201
206
 
202
207
  const body = renderScreenBody({ schema, screen, translate, entityId, onRowClick, onCopyLink });
203
- // An embedded screen (e.g. a dashboard panel) keeps its parent's host.
204
- if (outerHost !== undefined) return body;
208
+ // An embedded screen (e.g. a dashboard panel) keeps its parent's host and
209
+ // must not portal its page header into the shell that hosts the outer screen.
210
+ if (outerHost !== undefined) {
211
+ return <PageHeaderSlotAvailableProvider value={false}>{body}</PageHeaderSlotAvailableProvider>;
212
+ }
205
213
  return <ReturnHostProvider value={ownHost}>{body}</ReturnHostProvider>;
206
214
  }
207
215
 
@@ -385,6 +393,12 @@ function useNavigateToListAfter(schema: FeatureSchema, entityName: string): () =
385
393
  // Schema-Screens kommen mit qualifizierten ids ("publicstatus:screen:
386
394
  // component-edit") aus der Registry; lastSegment strippt den Prefix
387
395
  // für nav.navigate (siehe ./qn.ts für Doku).
396
+ function pageSizeOptionsFor(currentLimit: number): readonly number[] {
397
+ return PAGE_SIZE_OPTIONS.includes(currentLimit)
398
+ ? PAGE_SIZE_OPTIONS
399
+ : [...PAGE_SIZE_OPTIONS, currentLimit].sort((a, b) => a - b);
400
+ }
401
+
388
402
  function useNavigateToCreateFor(
389
403
  schema: FeatureSchema,
390
404
  entityName: string,
@@ -580,6 +594,42 @@ function isMoneyValue(value: unknown): value is MoneyValue {
580
594
  );
581
595
  }
582
596
 
597
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
598
+
599
+ function formatSummaryValue(value: unknown, locale: string): string {
600
+ if (isMoneyValue(value)) {
601
+ return new Intl.NumberFormat(locale, { style: "currency", currency: value.currency }).format(
602
+ value.amount,
603
+ );
604
+ }
605
+ if (typeof value === "number") return value.toLocaleString(locale);
606
+ if (typeof value === "string" && ISO_DATE_PATTERN.test(value)) {
607
+ return new Date(`${value}T00:00:00Z`).toLocaleDateString(locale, {
608
+ year: "numeric",
609
+ month: "2-digit",
610
+ day: "2-digit",
611
+ timeZone: "UTC",
612
+ });
613
+ }
614
+ return value === null || value === undefined ? "" : String(value);
615
+ }
616
+
617
+ function resolveActionFormSummary(
618
+ summary: ActionFormScreenDefinition["summary"],
619
+ prefill: Readonly<Record<string, unknown>> | undefined,
620
+ translate: Translate,
621
+ locale: string,
622
+ ): { readonly title: string; readonly subtitle?: string } | undefined {
623
+ if (summary === undefined) return undefined;
624
+ const params = Object.fromEntries(
625
+ Object.entries(prefill ?? {}).map(([name, value]) => [name, formatSummaryValue(value, locale)]),
626
+ );
627
+ return {
628
+ title: translate(summary.title, params),
629
+ ...(summary.subtitle !== undefined && { subtitle: translate(summary.subtitle, params) }),
630
+ };
631
+ }
632
+
583
633
  function parseJsonOrRaw(raw: string): unknown {
584
634
  try {
585
635
  return JSON.parse(raw);
@@ -1021,6 +1071,7 @@ function EntityEditCreateBody({
1021
1071
  onSubmit={handleSubmitted}
1022
1072
  onControlsReady={handleControlsReady}
1023
1073
  onCancel={handleCancel}
1074
+ {...(screenFillsHeight(screen) && { fillScreenHeight: true })}
1024
1075
  {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
1025
1076
  {...(translate !== undefined && { translate })}
1026
1077
  />
@@ -1328,6 +1379,7 @@ function EntityEditUpdateForm({
1328
1379
  {...(screen.allowDelete !== false && { onDelete: handleDelete })}
1329
1380
  onCancel={handleCancel}
1330
1381
  onReload={() => void onReload()}
1382
+ {...(screenFillsHeight(screen) && { fillScreenHeight: true })}
1331
1383
  {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
1332
1384
  {...(translate !== undefined && { translate })}
1333
1385
  {...(onCopyLink !== undefined && { onCopyLink })}
@@ -1639,9 +1691,20 @@ function DrawerHost({
1639
1691
  readonly onClose: () => void;
1640
1692
  readonly onSuccess: () => void;
1641
1693
  }): ReactNode {
1642
- const { Drawer, Banner, Text } = usePrimitives();
1694
+ const { Drawer, Banner, Text, Dialog } = usePrimitives();
1643
1695
  const t = useTranslation();
1644
1696
  const effectiveTranslate = translate ?? t;
1697
+ const [hasUnsavedInput, setHasUnsavedInput] = useState(false);
1698
+ const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
1699
+ const closeAndReset = useCallback(() => {
1700
+ setHasUnsavedInput(false);
1701
+ setConfirmDiscardOpen(false);
1702
+ onClose();
1703
+ }, [onClose]);
1704
+ const requestClose = useCallback(() => {
1705
+ if (hasUnsavedInput) setConfirmDiscardOpen(true);
1706
+ else closeAndReset();
1707
+ }, [hasUnsavedInput, closeAndReset]);
1645
1708
  // Drawer is an optional Core-Primitive (additive rollout) — same "skip +
1646
1709
  // warn once" precedent as rowActions without a mounted DispatcherProvider
1647
1710
  // above, instead of crashing when a web app hasn't upgraded its
@@ -1665,33 +1728,58 @@ function DrawerHost({
1665
1728
  const allowed = screenAccessAllows(drawerScreen?.access, userRoles);
1666
1729
 
1667
1730
  return (
1668
- <Drawer
1669
- open={true}
1670
- onOpenChange={(open) => {
1671
- if (!open) onClose();
1672
- }}
1673
- title={effectiveTranslate(drawerAction.label)}
1674
- testId={`toolbar-drawer-${drawerAction.id}`}
1675
- >
1676
- {drawerScreen === undefined ? (
1677
- <Banner padded variant="error" testId="kumiko-toolbar-drawer-not-found">
1678
- Screen not found: <Text variant="code">{drawerAction.screen}</Text>
1679
- </Banner>
1680
- ) : !allowed ? (
1681
- <Banner padded variant="error" testId="kumiko-toolbar-drawer-access-denied">
1682
- Access denied: <Text variant="code">{drawerAction.screen}</Text>
1683
- </Banner>
1684
- ) : (
1685
- <ActionFormBody
1686
- schema={schema}
1687
- screen={drawerScreen}
1688
- {...(translate !== undefined && { translate })}
1689
- {...(drawerInitialValues !== undefined && { initialOverrides: drawerInitialValues })}
1690
- onSuccess={onSuccess}
1691
- onCancelOverride={onClose}
1692
- />
1693
- )}
1694
- </Drawer>
1731
+ <>
1732
+ <Drawer
1733
+ open={true}
1734
+ onOpenChange={(open) => {
1735
+ if (!open) requestClose();
1736
+ }}
1737
+ title={effectiveTranslate(drawerAction.label)}
1738
+ testId={`toolbar-drawer-${drawerAction.id}`}
1739
+ >
1740
+ <PageHeaderSlotAvailableProvider value={false}>
1741
+ <InsideDrawerProvider value={true}>
1742
+ {drawerScreen === undefined ? (
1743
+ <Banner padded variant="error" testId="kumiko-toolbar-drawer-not-found">
1744
+ Screen not found: <Text variant="code">{drawerAction.screen}</Text>
1745
+ </Banner>
1746
+ ) : !allowed ? (
1747
+ <Banner padded variant="error" testId="kumiko-toolbar-drawer-access-denied">
1748
+ Access denied: <Text variant="code">{drawerAction.screen}</Text>
1749
+ </Banner>
1750
+ ) : (
1751
+ <ActionFormBody
1752
+ schema={schema}
1753
+ screen={drawerScreen}
1754
+ {...(translate !== undefined && { translate })}
1755
+ {...(drawerInitialValues !== undefined && {
1756
+ initialOverrides: drawerInitialValues,
1757
+ })}
1758
+ onSuccess={() => {
1759
+ setHasUnsavedInput(false);
1760
+ onSuccess();
1761
+ }}
1762
+ onCancelOverride={requestClose}
1763
+ onDirtyChange={setHasUnsavedInput}
1764
+ submitLabelFallback={drawerAction.label}
1765
+ />
1766
+ )}
1767
+ </InsideDrawerProvider>
1768
+ </PageHeaderSlotAvailableProvider>
1769
+ </Drawer>
1770
+ <Dialog
1771
+ open={confirmDiscardOpen}
1772
+ onOpenChange={setConfirmDiscardOpen}
1773
+ title={t("kumiko.drawer.discard.title")}
1774
+ description={t("kumiko.drawer.discard.body")}
1775
+ confirmLabel={t("kumiko.drawer.discard.confirm")}
1776
+ cancelLabel={t("kumiko.drawer.discard.cancel")}
1777
+ variant="danger"
1778
+ initialFocus="cancel"
1779
+ onConfirm={closeAndReset}
1780
+ testId="drawer-discard-dialog"
1781
+ />
1782
+ </>
1695
1783
  );
1696
1784
  }
1697
1785
 
@@ -1765,7 +1853,7 @@ function EntityListBody({
1765
1853
  // wenn URL keinen sort hat — Author-Default vs User-Choice.
1766
1854
  const urlState = useListUrlState(screen.id);
1767
1855
  const effectiveSort = urlState.sort ?? screen.defaultSort ?? null;
1768
- const limit = screen.pageSize ?? 50;
1856
+ const limit = urlState.pageSize ?? screen.pageSize ?? 50;
1769
1857
  const paginationMode = screen.pagination ?? "pages";
1770
1858
  const usePager = paginationMode === "pages";
1771
1859
  const useInfinite = paginationMode === "infinite";
@@ -2009,6 +2097,7 @@ function EntityListBody({
2009
2097
  label: effectiveTranslate(action.label),
2010
2098
  ...(action.style !== undefined && { style: action.style }),
2011
2099
  confirmRequired: false,
2100
+ ...(navigateAction.rowClick === true && { rowClick: true }),
2012
2101
  ...(actionIcon !== undefined && { icon: actionIcon }),
2013
2102
  onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
2014
2103
  ...(actionVisible !== undefined && {
@@ -2166,6 +2255,8 @@ function EntityListBody({
2166
2255
  limit,
2167
2256
  total,
2168
2257
  onPageChange: urlState.setPage,
2258
+ pageSizeOptions: pageSizeOptionsFor(limit),
2259
+ onPageSizeChange: urlState.setPageSize,
2169
2260
  }
2170
2261
  : undefined;
2171
2262
 
@@ -2187,6 +2278,7 @@ function EntityListBody({
2187
2278
  sort={effectiveSort}
2188
2279
  onSortChange={urlState.setSort}
2189
2280
  screenPadding
2281
+ {...(screenFillsHeight(screen) && { scrollBody: true })}
2190
2282
  {...(pager !== undefined && { pager })}
2191
2283
  {...(rowActions !== undefined && { rowActions })}
2192
2284
  {...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
@@ -2275,7 +2367,7 @@ function ProjectionListBody({
2275
2367
  // screen) must not smuggle a param the query's Zod schema doesn't accept.
2276
2368
  const activeSearch = searchable ? urlState.q : "";
2277
2369
  const activeSort = sortable ? (urlState.sort ?? screen.defaultSort ?? null) : null;
2278
- const limit = screen.pageSize ?? 50;
2370
+ const limit = urlState.pageSize ?? screen.pageSize ?? 50;
2279
2371
  // Pages-mode only (see header comment) — an author-set pagination:
2280
2372
  // "infinite" on a paginated projectionList is silently a no-op today.
2281
2373
  const usePager = paginated && (screen.pagination ?? "pages") === "pages";
@@ -2494,7 +2586,14 @@ function ProjectionListBody({
2494
2586
  const total = rowsQuery.data?.total;
2495
2587
  const pager =
2496
2588
  usePager && total !== undefined
2497
- ? { page: urlState.page, limit, total, onPageChange: urlState.setPage }
2589
+ ? {
2590
+ page: urlState.page,
2591
+ limit,
2592
+ total,
2593
+ onPageChange: urlState.setPage,
2594
+ pageSizeOptions: pageSizeOptionsFor(limit),
2595
+ onPageSizeChange: urlState.setPageSize,
2596
+ }
2498
2597
  : undefined;
2499
2598
 
2500
2599
  return (
@@ -2511,6 +2610,7 @@ function ProjectionListBody({
2511
2610
  sort={activeSort}
2512
2611
  onSortChange={urlState.setSort}
2513
2612
  screenPadding
2613
+ {...(screenFillsHeight(listScreen) && { scrollBody: true })}
2514
2614
  {...(pager !== undefined && { pager })}
2515
2615
  {...(rowActions !== undefined && { rowActions })}
2516
2616
  {...(toolbarActions !== undefined && { toolbarActions })}
@@ -2606,6 +2706,19 @@ function isAbsoluteHttpUrl(value: string): boolean {
2606
2706
  return /^https?:\/\//i.test(value) && isSafeHref(value);
2607
2707
  }
2608
2708
 
2709
+ function headerStatusTone(
2710
+ header: NonNullable<ProjectionDetailScreenDefinition["header"]>,
2711
+ record: Readonly<Record<string, unknown>>,
2712
+ ): StatusTone | undefined {
2713
+ if (header.status === undefined) return undefined;
2714
+ const value = String(record[header.status] ?? "");
2715
+ const declared =
2716
+ header.statusTones !== undefined && Object.hasOwn(header.statusTones, value)
2717
+ ? header.statusTones[value]
2718
+ : undefined;
2719
+ return declared !== undefined ? statusToneForOptionTone(declared) : statusToneForValue(value);
2720
+ }
2721
+
2609
2722
  function resolveSubtitleHref(
2610
2723
  header: ProjectionDetailScreenDefinition["header"],
2611
2724
  record: Readonly<Record<string, unknown>>,
@@ -2647,13 +2760,17 @@ function HeaderActionsBar({
2647
2760
  Dialog,
2648
2761
  ActionOverflowMenu,
2649
2762
  onError,
2763
+ collapseAfterPrimary = false,
2650
2764
  }: {
2765
+ /** Header slot layout: only the primary action stays a button, the rest goes into the menu. */
2766
+ readonly collapseAfterPrimary?: boolean;
2651
2767
  readonly actions: readonly RenderEditAction[];
2652
2768
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
2653
2769
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
2654
2770
  readonly ActionOverflowMenu: ReturnType<typeof usePrimitives>["ActionOverflowMenu"];
2655
2771
  readonly onError: (text: string | null) => void;
2656
2772
  }): ReactNode {
2773
+ const t = useTranslation();
2657
2774
  const [pendingAction, setPendingAction] = useState<RenderEditAction | null>(null);
2658
2775
  const trigger = async (action: RenderEditAction): Promise<void> => {
2659
2776
  onError(null);
@@ -2663,7 +2780,8 @@ function HeaderActionsBar({
2663
2780
  onError(e instanceof Error ? e.message : String(e));
2664
2781
  }
2665
2782
  };
2666
- if (actions.length <= 2 || ActionOverflowMenu === undefined) {
2783
+ const maxPlainButtons = collapseAfterPrimary ? 1 : 2;
2784
+ if (actions.length <= maxPlainButtons || ActionOverflowMenu === undefined) {
2667
2785
  return (
2668
2786
  <>
2669
2787
  {actions.map((action) => (
@@ -2672,6 +2790,7 @@ function HeaderActionsBar({
2672
2790
  action={action}
2673
2791
  Button={Button}
2674
2792
  Dialog={Dialog}
2793
+ hideIcon
2675
2794
  onError={onError}
2676
2795
  />
2677
2796
  ))}
@@ -2688,11 +2807,12 @@ function HeaderActionsBar({
2688
2807
  action={primary}
2689
2808
  Button={Button}
2690
2809
  Dialog={Dialog}
2810
+ hideIcon
2691
2811
  onError={onError}
2692
2812
  />
2693
2813
  )}
2694
2814
  <ActionOverflowMenu
2695
- label="More actions"
2815
+ label={t("kumiko.list.row-actions.more")}
2696
2816
  testId="kumiko-screen-projection-detail-actions-overflow"
2697
2817
  items={rest.map((action) => ({
2698
2818
  id: action.id,
@@ -2752,10 +2872,13 @@ function ProjectionDetailBody({
2752
2872
  Tabs,
2753
2873
  StatusBadge,
2754
2874
  Metric,
2875
+ MetricBand,
2876
+ PageHeader,
2755
2877
  Link,
2756
2878
  ActionOverflowMenu,
2757
2879
  } = usePrimitives();
2758
2880
  const t = useTranslation();
2881
+ const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
2759
2882
  const effectiveTranslate = translate ?? t;
2760
2883
  const nav = useNav();
2761
2884
  const idParam = screen.idParam ?? "id";
@@ -3002,7 +3125,7 @@ function ProjectionDetailBody({
3002
3125
  {StatusBadge !== undefined ? (
3003
3126
  <StatusBadge
3004
3127
  value={String(record[header.status] ?? "")}
3005
- tone={statusToneForValue(String(record[header.status] ?? ""))}
3128
+ tone={headerStatusTone(header, record)}
3006
3129
  testId="kumiko-screen-projection-detail-status"
3007
3130
  />
3008
3131
  ) : (
@@ -3031,58 +3154,101 @@ function ProjectionDetailBody({
3031
3154
  ) : undefined;
3032
3155
  // slots.header shares the header card with the actions (one card, actions
3033
3156
  // top right) instead of rendering as an unframed block above it.
3157
+ const metricItems = screen.metrics?.map((metric) => {
3158
+ const field = metricField(metric);
3159
+ const labelKey = metricLabelKey(metric, screen.fieldLabels);
3160
+ const label = labelKey !== undefined ? effectiveTranslate(labelKey) : field;
3161
+ const value = String(record[field] ?? "");
3162
+ const testId = `kumiko-screen-projection-detail-metric-${field}`;
3163
+ const navigate = metricNavigateSpec(metric);
3164
+ const onPress =
3165
+ navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
3166
+ ? () => runMetricNavigate(nav, navigate, record, host)
3167
+ : undefined;
3168
+ return Metric !== undefined ? (
3169
+ <Metric
3170
+ key={field}
3171
+ label={label}
3172
+ value={value}
3173
+ testId={testId}
3174
+ {...(onPress !== undefined && { onPress })}
3175
+ />
3176
+ ) : (
3177
+ <GridCell key={field}>
3178
+ <Text variant="small" testId={`${testId}-label`}>
3179
+ {label}
3180
+ </Text>
3181
+ <Text testId={`${testId}-value`}>{value}</Text>
3182
+ </GridCell>
3183
+ );
3184
+ });
3185
+ // Metrics are a dl band when the primitive exists; the old Grid stays as the fallback.
3186
+ const metricsBlock = (subtitle?: ReactNode): ReactNode =>
3187
+ MetricBand !== undefined ? (
3188
+ <MetricBand testId="kumiko-screen-projection-detail-metrics" subtitle={subtitle}>
3189
+ {metricItems}
3190
+ </MetricBand>
3191
+ ) : (
3192
+ <Grid columns={screen.metrics?.length ?? 1} testId="kumiko-screen-projection-detail-metrics">
3193
+ {metricItems}
3194
+ </Grid>
3195
+ );
3196
+ const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
3197
+ const actionErrorBanner = actionError !== null && (
3198
+ <Banner variant="error" testId="render-edit-action-error">
3199
+ {actionError}
3200
+ </Banner>
3201
+ );
3034
3202
  const renderHeaderContent = (headerSlot: ReactNode | undefined): ReactNode => (
3035
3203
  <>
3036
- {(hasHeaderCard || headerSlot !== undefined) && (
3037
- <Card
3038
- slots={{
3039
- ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
3040
- ...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
3041
- ...(headerSlot !== undefined && { headerContent: headerSlot }),
3042
- ...(hasHeaderActions && { headerActions: headerActionsContent }),
3043
- }}
3044
- >
3045
- {hasMetrics && (
3046
- <Grid
3047
- columns={screen.metrics?.length ?? 1}
3048
- testId="kumiko-screen-projection-detail-metrics"
3049
- >
3050
- {screen.metrics?.map((metric) => {
3051
- const field = metricField(metric);
3052
- const labelKey = metricLabelKey(metric, screen.fieldLabels);
3053
- const label = labelKey !== undefined ? effectiveTranslate(labelKey) : field;
3054
- const value = String(record[field] ?? "");
3055
- const testId = `kumiko-screen-projection-detail-metric-${field}`;
3056
- const navigate = metricNavigateSpec(metric);
3057
- const onPress =
3058
- navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
3059
- ? () => runMetricNavigate(nav, navigate, record, host)
3060
- : undefined;
3061
- return Metric !== undefined ? (
3062
- <Metric
3063
- key={field}
3064
- label={label}
3065
- value={value}
3066
- testId={testId}
3067
- {...(onPress !== undefined && { onPress })}
3204
+ {usesPageHeaderSlot && headerSlot === undefined ? (
3205
+ <>
3206
+ <PageHeader
3207
+ {...(header !== undefined && { title: String(record[header.title] ?? "") })}
3208
+ {...(header?.status !== undefined &&
3209
+ StatusBadge !== undefined && {
3210
+ status: (
3211
+ <StatusBadge
3212
+ value={String(record[header.status] ?? "")}
3213
+ tone={headerStatusTone(header, record)}
3214
+ testId="kumiko-screen-projection-detail-status"
3068
3215
  />
3069
- ) : (
3070
- <GridCell key={field}>
3071
- <Text variant="small" testId={`${testId}-label`}>
3072
- {label}
3073
- </Text>
3074
- <Text testId={`${testId}-value`}>{value}</Text>
3075
- </GridCell>
3076
- );
3216
+ ),
3077
3217
  })}
3078
- </Grid>
3079
- )}
3080
- {actionError !== null && (
3081
- <Banner variant="error" testId="render-edit-action-error">
3082
- {actionError}
3083
- </Banner>
3084
- )}
3085
- </Card>
3218
+ {...(hasHeaderActions && {
3219
+ actions: (
3220
+ <Grid columns="auto" testId="kumiko-screen-projection-detail-actions">
3221
+ <HeaderActionsBar
3222
+ actions={headerActionsList}
3223
+ Button={Button}
3224
+ Dialog={Dialog}
3225
+ ActionOverflowMenu={ActionOverflowMenu}
3226
+ onError={setActionError}
3227
+ collapseAfterPrimary
3228
+ />
3229
+ </Grid>
3230
+ ),
3231
+ })}
3232
+ />
3233
+ {(hasMetrics || headerSubtitleSlot !== undefined) &&
3234
+ (MetricBand !== undefined || hasMetrics) &&
3235
+ metricsBlock(headerSubtitleSlot)}
3236
+ {actionErrorBanner}
3237
+ </>
3238
+ ) : (
3239
+ (hasHeaderCard || headerSlot !== undefined) && (
3240
+ <Card
3241
+ slots={{
3242
+ ...(headerTitleSlot !== undefined && { title: headerTitleSlot }),
3243
+ ...(headerSubtitleSlot !== undefined && { subtitle: headerSubtitleSlot }),
3244
+ ...(headerSlot !== undefined && { headerContent: headerSlot }),
3245
+ ...(hasHeaderActions && { headerActions: headerActionsContent }),
3246
+ }}
3247
+ >
3248
+ {hasMetrics && metricsBlock()}
3249
+ {actionErrorBanner}
3250
+ </Card>
3251
+ )
3086
3252
  )}
3087
3253
  {hasTabs && activeSection !== undefined && (
3088
3254
  <Tabs
@@ -3120,6 +3286,7 @@ function ProjectionDetailBody({
3120
3286
  onRelatedListDrawerAction={openDrawer}
3121
3287
  {...(translate !== undefined && { translate })}
3122
3288
  {...(hasTabs && { hideSectionTitles: true })}
3289
+ {...(screenFillsHeight(screen) && { fillScreenHeight: true })}
3123
3290
  {...((hasHeaderCard || hasTabs || screen.slots?.header !== undefined) && {
3124
3291
  headerRegion: renderHeaderContent,
3125
3292
  })}
@@ -3238,6 +3405,8 @@ function ActionFormBody({
3238
3405
  initialOverrides,
3239
3406
  onSuccess,
3240
3407
  onCancelOverride,
3408
+ onDirtyChange,
3409
+ submitLabelFallback,
3241
3410
  }: {
3242
3411
  readonly schema: FeatureSchema;
3243
3412
  readonly screen: ActionFormScreenDefinition;
@@ -3255,9 +3424,16 @@ function ActionFormBody({
3255
3424
  /** Drawer-hosted usage: replaces the cancelTarget/redirect-based Cancel
3256
3425
  * handler so Cancel closes the drawer instead of navigating. */
3257
3426
  readonly onCancelOverride?: () => void;
3427
+ /** Drawer-hosted usage: reports unsaved input so the host can confirm
3428
+ * before discarding it. */
3429
+ readonly onDirtyChange?: (dirty: boolean) => void;
3430
+ /** Drawer-hosted usage: submit label when the screen declares none (the opening action's label). */
3431
+ readonly submitLabelFallback?: string;
3258
3432
  }): ReactNode {
3259
3433
  const nav = useNav();
3260
3434
  const appFeatures = useAppFeatures();
3435
+ const insideDrawer = useInsideDrawer();
3436
+ const locale = useLocale().locale();
3261
3437
  const { Banner } = usePrimitives();
3262
3438
  // Unused when drawer-hosted — onSuccess/onCancelOverride win below first.
3263
3439
  const returnTarget = useReturnTarget(screen.id);
@@ -3300,6 +3476,12 @@ function ActionFormBody({
3300
3476
  handoffValues,
3301
3477
  ],
3302
3478
  );
3479
+ const t = useTranslation();
3480
+ const effectiveTranslate = translate ?? t;
3481
+ const summary = useMemo(
3482
+ () => resolveActionFormSummary(screen.summary, initialOverrides, effectiveTranslate, locale),
3483
+ [screen.summary, initialOverrides, effectiveTranslate, locale],
3484
+ );
3303
3485
  const handleSubmitted = useCallback(
3304
3486
  (result: SubmitResult<unknown>) => {
3305
3487
  if (!result.isSuccess) return;
@@ -3363,8 +3545,13 @@ function ActionFormBody({
3363
3545
  payloadMode="values"
3364
3546
  onSubmit={handleSubmitted}
3365
3547
  {...(handleCancel !== undefined && { onCancel: handleCancel })}
3366
- {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
3548
+ {...(onDirtyChange !== undefined && { onDirtyChange })}
3549
+ {...((insideDrawer || screenFillsHeight(screen)) && { fillScreenHeight: true })}
3550
+ {...((screen.submitLabel ?? submitLabelFallback) !== undefined && {
3551
+ submitLabel: screen.submitLabel ?? submitLabelFallback,
3552
+ })}
3367
3553
  {...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle })}
3554
+ {...(summary !== undefined && { summary })}
3368
3555
  {...(translate !== undefined && { translate })}
3369
3556
  />
3370
3557
  );
@@ -81,5 +81,6 @@ export function synthesizeProjectionDetailScreen(
81
81
  ...(screen.fieldLabels !== undefined && { fieldLabels: screen.fieldLabels }),
82
82
  ...(screen.slots !== undefined && { slots: screen.slots }),
83
83
  ...(screen.access !== undefined && { access: screen.access }),
84
+ ...(screen.fillHeight !== undefined && { fillHeight: screen.fillHeight }),
84
85
  };
85
86
  }
@@ -61,7 +61,10 @@ export function synthesizeProjectionScreen(
61
61
  ...(screen.pageSize !== undefined && { pageSize: screen.pageSize }),
62
62
  ...(screen.defaultSort !== undefined && { defaultSort: screen.defaultSort }),
63
63
  ...(screen.searchable !== undefined && { searchable: screen.searchable }),
64
+ ...(screen.createLabel !== undefined && { createLabel: screen.createLabel }),
65
+ ...(screen.searchPlaceholder !== undefined && { searchPlaceholder: screen.searchPlaceholder }),
64
66
  ...(screen.slots !== undefined && { slots: screen.slots }),
65
67
  ...(screen.access !== undefined && { access: screen.access }),
68
+ ...(screen.fillHeight !== undefined && { fillHeight: screen.fillHeight }),
66
69
  };
67
70
  }
@@ -135,6 +135,7 @@ function buildNavigateRowAction(
135
135
  label: translate(action.label),
136
136
  ...(action.style !== undefined && { style: action.style }),
137
137
  confirmRequired: false,
138
+ ...(action.rowClick === true && { rowClick: true }),
138
139
  ...(actionIcon !== undefined && { icon: actionIcon }),
139
140
  onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, action, row, host),
140
141
  ...(visible !== undefined && {
@@ -152,6 +153,11 @@ type OpenDrawer = (
152
153
  // builders re-run inside a useMemo on every dep change.
153
154
  const warnedDrawerActionIds = new Set<string>();
154
155
 
156
+ /** @internal test-only */
157
+ export function resetDrawerActionWarningsForTests(): void {
158
+ warnedDrawerActionIds.clear();
159
+ }
160
+
155
161
  function warnDrawerActionDropped(
156
162
  actionKind: "rowAction" | "toolbarAction",
157
163
  actionId: string,