@cosmicdrift/kumiko-renderer 0.330.2 → 0.332.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.
@@ -0,0 +1,15 @@
1
+ import type { RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { EditRelatedListSectionViewModel, ListRowViewModel, Translate } from "@cosmicdrift/kumiko-headless";
3
+ import { type ReactNode } from "react";
4
+ export declare function EntityListExpandedRow({ section, row, featureName, translate, expansionNonce, openDrawer, onAfterWrite, onSubListInvalidated, }: {
5
+ readonly section: EditRelatedListSectionViewModel;
6
+ readonly row: ListRowViewModel;
7
+ readonly featureName: string;
8
+ readonly translate?: Translate;
9
+ readonly expansionNonce: number;
10
+ readonly openDrawer: (action: RowActionDrawer, initialValues: Readonly<Record<string, unknown>> | undefined) => void;
11
+ /** Reloads the parent list after a write in the sub-list. */
12
+ readonly onAfterWrite: () => void | Promise<void>;
13
+ /** Asks the host to remount every open sub-list. */
14
+ readonly onSubListInvalidated: () => void;
15
+ }): ReactNode;
@@ -0,0 +1,49 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useMemo, useState } from "react";
3
+ import { RelatedListSection } from "../components/related-list-section.js";
4
+ import { RenderEditActionButton } from "../components/render-edit-action-button.js";
5
+ import { useOptionalDispatcher } from "../context/dispatcher-context.js";
6
+ import { useTranslation } from "../i18n.js";
7
+ import { usePrimitives } from "../primitives.js";
8
+ import { useNav } from "./nav.js";
9
+ import { useReturnHost } from "./return-to.js";
10
+ import { buildRecordActions } from "./row-actions.js";
11
+ // Content of an entityList row's expansion area: the row's record is the
12
+ // parent of a relatedList. `expansionNonce` in the key remounts the sub-list
13
+ // after a write that ran outside of it (drawer submit, title-row action).
14
+ export function EntityListExpandedRow({ section, row, featureName, translate, expansionNonce, openDrawer, onAfterWrite, onSubListInvalidated, }) {
15
+ const { Banner, Button, Dialog } = usePrimitives();
16
+ const t = useTranslation();
17
+ const effectiveTranslate = translate ?? t;
18
+ const nav = useNav();
19
+ const host = useReturnHost();
20
+ const dispatcher = useOptionalDispatcher();
21
+ const [actionError, setActionError] = useState(null);
22
+ const onTitleActionWritten = useCallback(async () => {
23
+ onSubListInvalidated();
24
+ await onAfterWrite();
25
+ }, [onSubListInvalidated, onAfterWrite]);
26
+ const titleActions = useMemo(() => section.actions !== undefined && section.actions.length > 0
27
+ ? buildRecordActions({
28
+ actions: section.actions,
29
+ record: row.values,
30
+ translate: effectiveTranslate,
31
+ nav,
32
+ host,
33
+ dispatcher,
34
+ openDrawer,
35
+ onWriteSuccess: onTitleActionWritten,
36
+ })
37
+ : undefined, [
38
+ section.actions,
39
+ row.values,
40
+ effectiveTranslate,
41
+ nav,
42
+ host,
43
+ dispatcher,
44
+ openDrawer,
45
+ onTitleActionWritten,
46
+ ]);
47
+ const titleActionButtons = titleActions?.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, onError: setActionError }, action.id)));
48
+ return (_jsxs(_Fragment, { children: [actionError !== null && (_jsx(Banner, { variant: "error", testId: `row-${row.id}-expansion-action-error`, children: actionError })), _jsx(RelatedListSection, { embedded: true, section: section, parentId: String(row.values["id"] ?? row.id), record: row.values, featureName: featureName, translate: effectiveTranslate, onOpenDrawer: openDrawer, onAfterWrite: onAfterWrite, ...(titleActionButtons !== undefined && { actions: titleActionButtons }) }, expansionNonce)] }));
49
+ }
@@ -1,9 +1,9 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { evalFieldCondition, isWriteFormEditSection, metricField, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
- import { fieldLabelKey, fieldOptionLabelKey, isSafeHref } from "@cosmicdrift/kumiko-headless";
3
+ import { computeRelatedListSectionViewModel, fieldLabelKey, fieldOptionLabelKey, isSafeHref, } from "@cosmicdrift/kumiko-headless";
4
4
  import { resolveActionIcon } from "@cosmicdrift/kumiko-types/action-icon";
5
5
  import { TENANT_CURRENCY_CONFIG_KEY } from "@cosmicdrift/kumiko-types/fields";
6
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { extractCreatedId, extractIdField } from "../components/reference-create-dialog.js";
8
8
  import { RenderEdit, resolveRecordTitle, } from "../components/render-edit.js";
9
9
  import { needsActionConfirm, RenderEditActionButton, RenderEditActionConfirmDialog, } from "../components/render-edit-action-button.js";
@@ -14,7 +14,7 @@ import { PAGE_SIZE_OPTIONS, useListUrlState } from "../hooks/use-list-url-state.
14
14
  import { useQuery } from "../hooks/use-query.js";
15
15
  import { useLocale, useOptionalTimeZone, useTranslation } from "../i18n.js";
16
16
  import { InsideDrawerProvider, useInsideDrawer } from "../inside-drawer.js";
17
- import { PageHeaderSlotAvailableProvider, usePageHeaderSlotAvailable, } from "../page-header-slot.js";
17
+ import { PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "../page-header-slot.js";
18
18
  import { statusToneForOptionTone, statusToneForValue, usePrimitives, } from "../primitives.js";
19
19
  import { screenFillsHeight } from "../screen-fills-height.js";
20
20
  import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim.js";
@@ -22,6 +22,7 @@ import { useAppFeatures } from "./app-features-context.js";
22
22
  import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim.js";
23
23
  import { useCustomScreenComponent } from "./custom-screens.js";
24
24
  import { useDashboardBody } from "./dashboard-body.js";
25
+ import { EntityListExpandedRow } from "./entity-list-expanded-row.js";
25
26
  import { buildFormSchema } from "./form-schema.js";
26
27
  import { layoutFieldNames } from "./layout-fields.js";
27
28
  import { buildDateRangePayload, buildFilterFacets, buildFilterPayload, clampDateRange, mergeReferenceFacetOptions, readDateRange, resolveDateRangeFacets, resolveProjectionFacetSpecs, } from "./list-facets.js";
@@ -1281,6 +1282,36 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1281
1282
  refreshRowsAfterWrite,
1282
1283
  openDrawer,
1283
1284
  ]);
1285
+ // Expandable rows: open state is keyed by row id, so it survives refetch,
1286
+ // sort and page changes. Several rows can be open at once.
1287
+ const expandableRow = screen.expandableRow;
1288
+ const expandableSection = useMemo(() => expandableRow !== undefined
1289
+ ? computeRelatedListSectionViewModel(expandableRow, effectiveTranslate)
1290
+ : undefined, [expandableRow, effectiveTranslate]);
1291
+ const [expandedRowIds, setExpandedRowIds] = useState(() => new Set());
1292
+ const toggleRowExpanded = useCallback((rowId) => {
1293
+ setExpandedRowIds((prev) => {
1294
+ const next = new Set(prev);
1295
+ if (next.has(rowId))
1296
+ next.delete(rowId);
1297
+ else
1298
+ next.add(rowId);
1299
+ return next;
1300
+ });
1301
+ }, []);
1302
+ // Bumped after a write that ran outside the sub-list (drawer submit,
1303
+ // title-row action) so open sub-lists remount and reload.
1304
+ const [expansionNonce, setExpansionNonce] = useState(0);
1305
+ const invalidateExpansions = useCallback(() => setExpansionNonce((n) => n + 1), []);
1306
+ const renderExpandedRow = useCallback((row) => expandableSection === undefined ? null : (_jsx(EntityListExpandedRow, { section: expandableSection, row: row, featureName: featureName, ...(translate !== undefined && { translate }), expansionNonce: expansionNonce, openDrawer: openDrawer, onAfterWrite: refreshRowsAfterWrite, onSubListInvalidated: invalidateExpansions })), [
1307
+ expandableSection,
1308
+ featureName,
1309
+ translate,
1310
+ expansionNonce,
1311
+ openDrawer,
1312
+ refreshRowsAfterWrite,
1313
+ invalidateExpansions,
1314
+ ]);
1284
1315
  if (rowsQuery.loading && rowsQuery.data === null) {
1285
1316
  return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
1286
1317
  }
@@ -1336,8 +1367,13 @@ function EntityListBody({ schema, screen, entity, translate, onRowClick, }) {
1336
1367
  filterValues: urlState.filters,
1337
1368
  onFilterChange: urlState.setFilter,
1338
1369
  onFilterReset: urlState.clearFilters,
1370
+ }), ...(expandableSection !== undefined && {
1371
+ expandedRowIds,
1372
+ onToggleRowExpanded: toggleRowExpanded,
1373
+ renderExpandedRow,
1339
1374
  }) }), _jsx(DrawerHost, { schema: schema, drawerAction: drawerAction, drawerScreen: drawerScreen, ...(drawerInitialValues !== undefined && { drawerInitialValues }), userRoles: userRoles, ...(translate !== undefined && { translate }), onClose: closeDrawer, onSuccess: () => {
1340
1375
  closeDrawer();
1376
+ invalidateExpansions();
1341
1377
  void rowsQuery.refetch();
1342
1378
  } })] }));
1343
1379
  }
@@ -1585,6 +1621,46 @@ function runMetricNavigate(nav, navigate, record, host) {
1585
1621
  function isAbsoluteHttpUrl(value) {
1586
1622
  return /^https?:\/\//i.test(value) && isSafeHref(value);
1587
1623
  }
1624
+ // hrefFor throws for an entity without a detailFor screen; outside
1625
+ // createKumikoApp the feature list is empty, so navigateTargetAllows can't
1626
+ // rule that out beforehand. The NavApi owns target resolution, so asking it
1627
+ // and falling back to plain text covers both cases.
1628
+ function hrefForNavigate(nav, navigate, record) {
1629
+ const entityId = navigate.entityId !== undefined ? String(record[navigate.entityId] ?? "") : undefined;
1630
+ try {
1631
+ if (navigate.entity !== undefined) {
1632
+ return entityId === undefined || entityId === ""
1633
+ ? undefined
1634
+ : nav.hrefFor({ entity: navigate.entity, id: entityId });
1635
+ }
1636
+ if (navigate.screen !== undefined) {
1637
+ return nav.hrefFor({
1638
+ screenId: navigate.screen,
1639
+ ...(entityId !== undefined && entityId !== "" && { entityId }),
1640
+ });
1641
+ }
1642
+ }
1643
+ catch {
1644
+ return undefined;
1645
+ }
1646
+ return undefined;
1647
+ }
1648
+ function buildSubtitleParts(parts, record, nav, appFeatures, userRoles, host) {
1649
+ return parts.flatMap((part) => {
1650
+ const field = typeof part === "string" ? part : part.field;
1651
+ const text = String(record[field] ?? "");
1652
+ if (text === "")
1653
+ return [];
1654
+ const navigate = typeof part === "string" ? undefined : part.navigate;
1655
+ if (navigate === undefined || !navigateTargetAllows(navigate, appFeatures, userRoles)) {
1656
+ return [{ field, text }];
1657
+ }
1658
+ const href = hrefForNavigate(nav, navigate, record);
1659
+ if (href === undefined)
1660
+ return [{ field, text }];
1661
+ return [{ field, text, href, onPress: () => runMetricNavigate(nav, navigate, record, host) }];
1662
+ });
1663
+ }
1588
1664
  function headerStatusTone(header, record) {
1589
1665
  if (header.status === undefined)
1590
1666
  return undefined;
@@ -1623,7 +1699,7 @@ function primaryHeaderAction(actions) {
1623
1699
  // state for menu items: RenderEditActionButton already owns a per-button
1624
1700
  // confirm dialog for the always-visible primary action, but a menu item has
1625
1701
  // no button of its own to carry one.
1626
- function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, }) {
1702
+ function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError, collapseAfterPrimary = false, compact = false, PageHeader, }) {
1627
1703
  const t = useTranslation();
1628
1704
  const [pendingAction, setPendingAction] = useState(null);
1629
1705
  const trigger = async (action) => {
@@ -1635,38 +1711,51 @@ function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu, onError
1635
1711
  onError(e instanceof Error ? e.message : String(e));
1636
1712
  }
1637
1713
  };
1714
+ const toMenuItem = (action) => ({
1715
+ id: action.id,
1716
+ label: action.label,
1717
+ ...(action.icon !== undefined && { icon: action.icon }),
1718
+ variant: action.style === "danger" ? "danger" : "default",
1719
+ onSelect: () => {
1720
+ if (needsActionConfirm(action)) {
1721
+ setPendingAction(action);
1722
+ }
1723
+ else {
1724
+ void trigger(action);
1725
+ }
1726
+ },
1727
+ });
1728
+ const pendingActionDialog = pendingAction !== null && (_jsx(RenderEditActionConfirmDialog, { action: pendingAction, open: true, onOpenChange: (open) => {
1729
+ if (!open)
1730
+ setPendingAction(null);
1731
+ }, onConfirm: async () => {
1732
+ const action = pendingAction;
1733
+ setPendingAction(null);
1734
+ await trigger(action);
1735
+ }, Dialog: Dialog }));
1736
+ if (compact && PageHeader !== undefined) {
1737
+ const primary = primaryHeaderAction(actions);
1738
+ // A bare icon for a destructive or confirm-gated action (e.g. "x" for terminate)
1739
+ // reads as "close"; such actions go to the labelled menu instead.
1740
+ const iconPrimary = primary?.icon !== undefined && primary.style !== "danger" && !needsActionConfirm(primary)
1741
+ ? primary
1742
+ : undefined;
1743
+ const overflowActions = actions.filter((a) => a !== iconPrimary);
1744
+ return (_jsxs(_Fragment, { children: [iconPrimary !== undefined && (_jsx(RenderEditActionButton, { action: iconPrimary, Button: Button, Dialog: Dialog, iconOnly: true, onError: onError }, iconPrimary.id)), overflowActions.length > 0 && (_jsx(PageHeader, { overflowItems: overflowActions.map(toMenuItem) })), pendingActionDialog] }));
1745
+ }
1638
1746
  const maxPlainButtons = collapseAfterPrimary ? 1 : 2;
1639
1747
  if (actions.length <= maxPlainButtons || ActionOverflowMenu === undefined) {
1640
1748
  return (_jsx(_Fragment, { children: actions.map((action) => (_jsx(RenderEditActionButton, { action: action, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, action.id))) }));
1641
1749
  }
1642
1750
  const primary = primaryHeaderAction(actions);
1643
1751
  const rest = actions.filter((a) => a.id !== primary?.id);
1644
- return (_jsxs(_Fragment, { children: [primary !== undefined && (_jsx(RenderEditActionButton, { action: primary, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, primary.id)), _jsx(ActionOverflowMenu, { label: t("kumiko.list.row-actions.more"), testId: "kumiko-screen-projection-detail-actions-overflow", items: rest.map((action) => ({
1645
- id: action.id,
1646
- label: action.label,
1647
- ...(action.icon !== undefined && { icon: action.icon }),
1648
- variant: action.style === "danger" ? "danger" : "default",
1649
- onSelect: () => {
1650
- if (needsActionConfirm(action)) {
1651
- setPendingAction(action);
1652
- }
1653
- else {
1654
- void trigger(action);
1655
- }
1656
- },
1657
- })) }), pendingAction !== null && (_jsx(RenderEditActionConfirmDialog, { action: pendingAction, open: true, onOpenChange: (open) => {
1658
- if (!open)
1659
- setPendingAction(null);
1660
- }, onConfirm: async () => {
1661
- const action = pendingAction;
1662
- setPendingAction(null);
1663
- await trigger(action);
1664
- }, Dialog: Dialog }))] }));
1752
+ return (_jsxs(_Fragment, { children: [primary !== undefined && (_jsx(RenderEditActionButton, { action: primary, Button: Button, Dialog: Dialog, hideIcon: true, onError: onError }, primary.id)), _jsx(ActionOverflowMenu, { label: t("kumiko.list.row-actions.more"), testId: "kumiko-screen-projection-detail-actions-overflow", items: rest.map(toMenuItem) }), pendingActionDialog] }));
1665
1753
  }
1666
1754
  function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1667
1755
  const { Banner, Button, Dialog, Text, Grid, GridCell, Card, Tabs, StatusBadge, Metric, MetricBand, PageHeader, Link, ActionOverflowMenu, } = usePrimitives();
1668
1756
  const t = useTranslation();
1669
1757
  const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
1758
+ const pageHeaderCompactContext = usePageHeaderCompact();
1670
1759
  const effectiveTranslate = translate ?? t;
1671
1760
  const nav = useNav();
1672
1761
  const idParam = screen.idParam ?? "id";
@@ -1851,7 +1940,10 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1851
1940
  // Plain Text, not Heading — the Card title slot already renders as its own
1852
1941
  // heading element (h3); nesting another heading (h1) inside it is invalid HTML.
1853
1942
  const headerTitleSlot = header !== undefined ? (header.status !== undefined ? (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }), StatusBadge !== undefined ? (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-status", children: String(record[header.status] ?? "") }))] })) : (_jsx(Text, { testId: "kumiko-screen-projection-detail-title", children: String(record[header.title] ?? "") }))) : undefined;
1854
- const headerSubtitleSlot = header?.subtitle !== undefined ? (subtitleHref !== undefined ? (_jsx(Link, { href: subtitleHref, target: "_blank", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") })) : (_jsx(Text, { variant: "muted", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") }))) : undefined;
1943
+ const subtitleParts = header !== undefined && typeof header.subtitle === "object"
1944
+ ? buildSubtitleParts(header.subtitle, record, nav, appFeatures, userRoles, host)
1945
+ : undefined;
1946
+ const headerSubtitleSlot = subtitleParts !== undefined ? (subtitleParts.length > 0 ? (_jsx(Text, { testId: "kumiko-screen-projection-detail-subtitle", children: subtitleParts.map((part, index) => (_jsxs(Fragment, { children: [index > 0 && (_jsx(Text, { variant: "muted", decorative: true, children: " · " })), part.href !== undefined ? (_jsx(Link, { href: part.href, ...(part.onPress !== undefined && { onPress: part.onPress }), className: "underline", testId: `kumiko-screen-projection-detail-subtitle-${part.field}`, children: part.text })) : (_jsx(Text, { testId: `kumiko-screen-projection-detail-subtitle-${part.field}`, children: part.text }))] }, part.field))) })) : undefined) : typeof header?.subtitle === "string" ? (subtitleHref !== undefined ? (_jsx(Link, { href: subtitleHref, target: "_blank", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") })) : (_jsx(Text, { variant: "muted", testId: "kumiko-screen-projection-detail-subtitle", children: String(record[header.subtitle] ?? "") }))) : undefined;
1855
1947
  // slots.header shares the header card with the actions (one card, actions
1856
1948
  // top right) instead of rendering as an unframed block above it.
1857
1949
  const metricItems = screen.metrics?.map((metric) => {
@@ -1869,12 +1961,13 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1869
1961
  // Metrics are a dl band when the primitive exists; the old Grid stays as the fallback.
1870
1962
  const metricsBlock = (subtitle, lead) => MetricBand !== undefined ? (_jsx(MetricBand, { testId: "kumiko-screen-projection-detail-metrics", subtitle: subtitle, ...(lead !== undefined && { lead }), children: metricItems })) : (_jsx(Grid, { columns: screen.metrics?.length ?? 1, testId: "kumiko-screen-projection-detail-metrics", children: metricItems }));
1871
1963
  const usesPageHeaderSlot = pageHeaderSlotAvailable && PageHeader !== undefined;
1964
+ const pageHeaderCompact = pageHeaderCompactContext && usesPageHeaderSlot;
1872
1965
  const actionErrorBanner = actionError !== null && (_jsx(Banner, { variant: "error", testId: "render-edit-action-error", children: actionError }));
1873
1966
  const renderHeaderContent = (headerSlot) => (_jsxs(_Fragment, { children: [usesPageHeaderSlot ? (_jsxs(_Fragment, { children: [_jsx(PageHeader, { ...(header !== undefined && { title: String(record[header.title] ?? "") }), ...(header === undefined && recordTitle !== undefined && { title: recordTitle }), ...(header?.status !== undefined &&
1874
1967
  StatusBadge !== undefined && {
1875
1968
  status: (_jsx(StatusBadge, { value: String(record[header.status] ?? ""), tone: headerStatusTone(header, record), testId: "kumiko-screen-projection-detail-status" })),
1876
1969
  }), ...(hasHeaderActions && {
1877
- actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true }) })),
1970
+ actions: (_jsx(Grid, { columns: "auto", testId: "kumiko-screen-projection-detail-actions", children: _jsx(HeaderActionsBar, { actions: headerActionsList, Button: Button, Dialog: Dialog, ActionOverflowMenu: ActionOverflowMenu, onError: setActionError, collapseAfterPrimary: true, compact: pageHeaderCompact, PageHeader: PageHeader }) })),
1878
1971
  }) }), MetricBand === undefined && headerSlot, (hasMetrics || headerSubtitleSlot !== undefined || headerSlot !== undefined) &&
1879
1972
  (MetricBand !== undefined || hasMetrics) &&
1880
1973
  metricsBlock(headerSubtitleSlot, MetricBand !== undefined ? headerSlot : undefined), actionErrorBanner] })) : ((hasHeaderCard || headerSlot !== undefined || recordTitle !== undefined) && (_jsxs(Card, { slots: {
@@ -0,0 +1,12 @@
1
+ import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
2
+ import type { ReactNode } from "react";
3
+ type QueryOptionsSelectProps = {
4
+ readonly field: EditFieldViewModel;
5
+ readonly query: string;
6
+ readonly payload: Readonly<Record<string, string | number | boolean>>;
7
+ readonly id: string;
8
+ readonly hasError: boolean;
9
+ readonly onChange: (value: unknown) => void;
10
+ };
11
+ export declare function QueryOptionsSelect({ field, query, payload, id, hasError, onChange, }: QueryOptionsSelectProps): ReactNode;
12
+ export {};
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useQuery } from "../hooks/use-query.js";
3
+ import { usePrimitives } from "../primitives.js";
4
+ // Select whose options come from a query (SelectFieldDef.optionsQuery), so it
5
+ // has to be a mounted component for useQuery. useQuery re-fetches on the
6
+ // serialized payload, hence no payload memoization here.
7
+ export function QueryOptionsSelect({ field, query, payload, id, hasError, onChange, }) {
8
+ const { Input } = usePrimitives();
9
+ const { data, loading } = useQuery(query, payload);
10
+ const current = typeof field.value === "string" ? field.value : "";
11
+ const rows = data?.rows ?? [];
12
+ // A stored value missing from the result (still loading, or dropped from the
13
+ // catalog) must stay visible instead of silently turning into "nothing selected".
14
+ const options = current !== "" && !rows.some((row) => row.value === current)
15
+ ? [{ value: current, label: current }, ...rows]
16
+ : rows;
17
+ // Keeps the presentation stable: without it the empty loading state would pick
18
+ // radio/dropdown by option count and jump once the rows arrive.
19
+ const display = field.display === "radio" || field.display === "dropdown" ? field.display : "dropdown";
20
+ return (_jsx(Input, { kind: "select", id: id, name: field.field, disabled: field.readOnly || loading, required: field.required, hasError: hasError, value: current, onChange: (v) => onChange(v), options: options, display: display }));
21
+ }
@@ -1,7 +1,7 @@
1
1
  import type { RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import type { EditRelatedListSectionViewModel, Translate } from "@cosmicdrift/kumiko-headless";
3
3
  import { type ReactNode } from "react";
4
- export declare function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, }: {
4
+ export declare function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, embedded, onAfterWrite, }: {
5
5
  readonly section: EditRelatedListSectionViewModel;
6
6
  readonly parentId: string;
7
7
  /** The enclosing projectionDetail's own record (the "Akte") — evaluates
@@ -22,4 +22,12 @@ export declare function RelatedListSection({ section, parentId, record, featureN
22
22
  * (non-`hideTitle`) branch below — `hideTitle` uses `FillContainer`, which
23
23
  * has no title row to render actions into (see the comment on that branch). */
24
24
  readonly actions?: ReactNode;
25
+ /** Mounted inside a host row (entityList `expandableRow`): unframed, in
26
+ * document flow (no Section card, no scrollBody), with its own title row
27
+ * carrying `actions`. */
28
+ readonly embedded?: boolean;
29
+ /** Runs after this section's own refetch once a write action (row action,
30
+ * toolbar action, emptyState action) succeeded — lets a host reload data
31
+ * the write also changed. */
32
+ readonly onAfterWrite?: () => void | Promise<void>;
25
33
  }): ReactNode;
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { normalizeListColumn, parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { useCallback, useMemo, useState } from "react";
4
4
  import { useAppFeatures } from "../app/app-features-context.js";
5
5
  import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
@@ -22,17 +22,22 @@ const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
22
22
  // Minimal EntityDefinition from the section's own columns — same shape as
23
23
  // projection-list-shim's synthesizeProjectionEntity, but sortable is read
24
24
  // per-column (ListColumnSpec.sortable) instead of screen-wide, since a
25
- // relatedList section has no single Zod schema to derive it from.
26
- function synthesizeRelatedListEntity(columns) {
25
+ // relatedList section has no single Zod schema to derive it from. With a
26
+ // declared `entity`, a column naming one of its fields takes that field's
27
+ // definition, so RenderList formats it exactly like an entityList column;
28
+ // only `sortable` stays per-column because the sort runs locally.
29
+ function synthesizeRelatedListEntity(columns, sourceEntity) {
27
30
  const fields = {};
28
31
  for (const col of columns) {
29
32
  const normalized = normalizeListColumn(col);
30
- fields[normalized.field] = {
31
- type: normalized.valueType ?? "text",
32
- sortable: normalized.sortable === true,
33
- };
33
+ const sortable = normalized.sortable === true;
34
+ const sourceField = sourceEntity?.fields[normalized.field];
35
+ fields[normalized.field] =
36
+ sourceField !== undefined
37
+ ? { ...sourceField, sortable }
38
+ : { type: normalized.valueType ?? "text", sortable };
34
39
  }
35
- return { fields };
40
+ return { ...sourceEntity, fields };
36
41
  }
37
42
  // Mirrors row-actions.ts's own warnDrawerActionDropped (module-private
38
43
  // there, so not reusable directly) — a drawer-kind emptyState.action with no
@@ -42,8 +47,8 @@ function warnRelatedListEmptyStateDrawerDropped(actionId) {
42
47
  // biome-ignore lint/suspicious/noConsole: dev-time warning, same pattern as row-actions.ts
43
48
  console.warn(`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`);
44
49
  }
45
- export function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, }) {
46
- const { Banner, Section, FillContainer, Text, Button, Dialog } = usePrimitives();
50
+ export function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, embedded, onAfterWrite, }) {
51
+ const { Banner, Section, Card, FillContainer, Text, Button, Dialog } = usePrimitives();
47
52
  const [emptyStateActionError, setEmptyStateActionError] = useState(null);
48
53
  const t = useTranslation();
49
54
  const effectiveTranslate = translate ?? t;
@@ -59,7 +64,15 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
59
64
  return findEditScreenFor(targetEntity, appFeatures, userRoles);
60
65
  }, [appFeatures, section.rowClick, userRoles]);
61
66
  const defaultEditRowAction = useMemo(() => buildDefaultEditRowAction(defaultEditScreen, section.rowClick?.idColumn ?? "id"), [defaultEditScreen, section.rowClick]);
62
- const entity = useMemo(() => synthesizeRelatedListEntity(section.columns), [section.columns]);
67
+ // RenderList derives header and option-label keys from (featureName,
68
+ // screen.entity), so a declared entity also supplies both.
69
+ const sourceTarget = useMemo(() => (section.entity !== undefined ? parseRefTarget(section.entity, featureName) : undefined), [section.entity, featureName]);
70
+ const sourceEntity = sourceTarget !== undefined
71
+ ? appFeatures.find((f) => f.featureName === sourceTarget.featureName)?.entities[sourceTarget.entityName]
72
+ : undefined;
73
+ const listFeatureName = sourceEntity !== undefined ? sourceTarget?.featureName : undefined;
74
+ const listEntityName = sourceEntity !== undefined ? sourceTarget?.entityName : undefined;
75
+ const entity = useMemo(() => synthesizeRelatedListEntity(section.columns, sourceEntity), [section.columns, sourceEntity]);
63
76
  const listScreen = useMemo(() => ({
64
77
  // Empty id → RenderList's own toolbarTitle resolves to "" (its
65
78
  // `screen:${id}.title` lookup misses and falls back to `id`) — this
@@ -67,9 +80,9 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
67
80
  // so RenderList's toolbar carries none.
68
81
  id: "",
69
82
  type: "entityList",
70
- entity: RELATED_LIST_PSEUDO_ENTITY,
83
+ entity: listEntityName ?? RELATED_LIST_PSEUDO_ENTITY,
71
84
  columns: section.columns,
72
- }), [section.columns]);
85
+ }), [section.columns, listEntityName]);
73
86
  // Local state, not URL state: a section `id` is optional, so there is no
74
87
  // stable URL key to namespace against — the section's sort is local for the
75
88
  // same reason. Consequence: search/filters reset on reload.
@@ -107,6 +120,11 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
107
120
  filterPayload,
108
121
  ]);
109
122
  const rowsQuery = useQuery(section.query, payload);
123
+ const ownRefetch = rowsQuery.refetch;
124
+ const refetchSelfAndHost = useCallback(async () => {
125
+ await ownRefetch();
126
+ await onAfterWrite?.();
127
+ }, [ownRefetch, onAfterWrite]);
110
128
  const onFilterChange = useCallback((field, values) => setFilters((prev) => ({ ...prev, [field]: values })), []);
111
129
  const onFilterReset = useCallback(() => setFilters({}), []);
112
130
  // Sorted client-side over the already-loaded rows — this section has no
@@ -150,7 +168,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
150
168
  translate: effectiveTranslate,
151
169
  dispatcher,
152
170
  nav,
153
- refetch: rowsQuery.refetch,
171
+ refetch: refetchSelfAndHost,
154
172
  openDrawer: onOpenDrawer,
155
173
  defaultEditRowAction,
156
174
  host,
@@ -159,7 +177,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
159
177
  effectiveTranslate,
160
178
  dispatcher,
161
179
  nav,
162
- rowsQuery.refetch,
180
+ refetchSelfAndHost,
163
181
  onOpenDrawer,
164
182
  defaultEditRowAction,
165
183
  host,
@@ -171,7 +189,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
171
189
  translate: effectiveTranslate,
172
190
  dispatcher,
173
191
  nav,
174
- refetch: rowsQuery.refetch,
192
+ refetch: refetchSelfAndHost,
175
193
  navigatePrefill: section.parentFilter !== undefined
176
194
  ? { [section.parentFilter.field]: parentId }
177
195
  : { [section.parentParam ?? "id"]: parentId },
@@ -190,7 +208,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
190
208
  dispatcher,
191
209
  nav,
192
210
  host,
193
- rowsQuery.refetch,
211
+ refetchSelfAndHost,
194
212
  onOpenDrawer,
195
213
  ]);
196
214
  // emptyState.action is a RowAction — the same record-bound action shape
@@ -216,7 +234,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
216
234
  host,
217
235
  dispatcher,
218
236
  openDrawer: onOpenDrawer ?? (() => { }),
219
- onWriteSuccess: rowsQuery.refetch,
237
+ onWriteSuccess: refetchSelfAndHost,
220
238
  })?.[0];
221
239
  }, [
222
240
  section.emptyState,
@@ -226,7 +244,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
226
244
  host,
227
245
  dispatcher,
228
246
  onOpenDrawer,
229
- rowsQuery.refetch,
247
+ refetchSelfAndHost,
230
248
  ]);
231
249
  const emptyStateContent = section.emptyState !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: section.emptyState.title }), section.emptyState.description !== undefined && (_jsx(Text, { variant: "small", children: section.emptyState.description })), emptyStateActionButton !== undefined && (_jsx(RenderEditActionButton, { action: emptyStateActionButton, Button: Button, Dialog: Dialog, onError: setEmptyStateActionError }))] })) : undefined;
232
250
  // A truncated fetch means `sortedRows` is a sort of a partial set, not of
@@ -242,7 +260,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
242
260
  (rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
243
261
  const content = rowsQuery.loading && rowsQuery.data === null ? (_jsx(Banner, { padded: true, variant: "loading", testId: "related-list-loading", children: "Loading\u2026" })) : rowsQuery.error ? (_jsx(Banner, { padded: true, variant: "error", testId: "related-list-error", children: dispatcherErrorText(rowsQuery.error, effectiveTranslate) })) : (_jsxs(_Fragment, { children: [truncated && (_jsx(Banner, { variant: "info", testId: "related-list-truncated", children: effectiveTranslate("kumiko.list.related-list-truncated", {
244
262
  count: sortedRows.length,
245
- }) })), emptyStateActionError !== null && (_jsx(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: emptyStateActionError })), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: sortedRows, featureName: featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
263
+ }) })), emptyStateActionError !== null && (_jsx(Banner, { variant: "error", testId: "related-list-empty-state-action-error", children: emptyStateActionError })), _jsx(PageHeaderSlotAvailableProvider, { value: false, children: _jsx(RenderList, { screen: listScreen, entity: entity, rows: sortedRows, featureName: listFeatureName ?? featureName, translate: effectiveTranslate, ...(section.description !== undefined && { description: section.description }), ...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun }), sort: sort, onSortChange: setSort, ...(section.searchable === true && {
246
264
  searchable: true,
247
265
  searchValue: search,
248
266
  onSearchChange: setSearch,
@@ -255,7 +273,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
255
273
  scrollBody: true,
256
274
  screenPadding: false,
257
275
  chromeless: true,
258
- }) }) })] }));
276
+ }), ...(embedded === true && { screenPadding: false, chromeless: true }) }) })] }));
259
277
  // hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
260
278
  // component's own return value in the same Card frame every other tab
261
279
  // kind gets — no card here, or the two would nest. `scrollBody` caps the
@@ -266,6 +284,15 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
266
284
  // Section card and document-flow height since they render a visible title
267
285
  // and aren't confined to a tab panel.
268
286
  const bridges = _jsx(ReferenceFacetBridges, { specs: facetSpecs, onOptions: handleFacetOptions });
287
+ if (embedded === true) {
288
+ const countSuffix = rowsQuery.data !== null ? ` · ${sortedRows.length}` : "";
289
+ return (_jsxs(_Fragment, { children: [bridges, _jsx(Card, { options: { framed: false, padded: false },
290
+ // Trim the default header inset: the host row already pads the area.
291
+ className: "[&>div:first-child]:px-0 [&>div:first-child]:pt-1 [&>div:first-child]:pb-2", slots: {
292
+ title: `${section.title}${countSuffix}`,
293
+ ...(actions !== undefined && { headerActions: actions }),
294
+ }, testId: `related-list-${section.title}-${parentId}`, children: content })] }));
295
+ }
269
296
  if (hideTitle) {
270
297
  return (_jsxs(_Fragment, { children: [bridges, FillContainer !== undefined ? (_jsx(FillContainer, { ...(grow === true && { grow: true }), children: content })) : (content)] }));
271
298
  }
@@ -12,7 +12,7 @@ import { formatWhen } from "../format-when.js";
12
12
  import { useForm } from "../hooks/use-form.js";
13
13
  import { useTranslation } from "../i18n.js";
14
14
  import { useInsideDrawer } from "../inside-drawer.js";
15
- import { usePageHeaderSlotAvailable } from "../page-header-slot.js";
15
+ import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
16
16
  import { STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
17
17
  import { GridCellForField } from "./grid-cell-for-field.js";
18
18
  import { RelatedListSection } from "./related-list-section.js";
@@ -212,6 +212,7 @@ export function RenderEdit(props) {
212
212
  });
213
213
  const { Button, Banner, Card, Dialog, Form, Section, Grid, GridCell, Text, Progress, StepBar, WizardStepGroup, Tabs, PageHeader, ActionOverflowMenu, } = usePrimitives();
214
214
  const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
215
+ const pageHeaderCompact = usePageHeaderCompact();
215
216
  const insideDrawer = useInsideDrawer();
216
217
  // Both stepped layouts show one section at a time and keep the rest mounted
217
218
  // but inert. A tabs layout on a host without the Tabs primitive has no strip
@@ -1014,6 +1015,8 @@ export function RenderEdit(props) {
1014
1015
  : translate("kumiko.wizard.step", { current: currentStep + 1, total: lastStepIndex + 1 });
1015
1016
  })();
1016
1017
  return (_jsxs(ExtensionFormRegistryProvider, { value: extensionFormRegistry, children: [titleInShell && (_jsx(PageHeader, { title: formTitle, ...(recordTitle !== undefined && { recordTitle }), ...(headerMenuItems.length > 0 &&
1018
+ pageHeaderCompact && { overflowItems: headerMenuItems }), ...(headerMenuItems.length > 0 &&
1019
+ !pageHeaderCompact &&
1017
1020
  ActionOverflowMenu !== undefined && {
1018
1021
  actions: (_jsx(ActionOverflowMenu, { label: translate("kumiko.list.row-actions.more"), items: headerMenuItems, testId: "render-edit-header-menu" })),
1019
1022
  }) })), _jsx(Form, { onSubmit: () => void handleSubmit(), ...(hideSectionTitles !== true && !titleInShell && { title: formTitle }), ...(formSubtitle !== undefined && !stepRail && { subtitle: formSubtitle }), ...(isScreenForm && { screenForm: true }), ...(unsavedCount > 0 && { unsavedCount }), ...(sectionNavItems.length > 0 && { sectionNav: sectionNavItems }), ...(stepRail && {
@@ -15,6 +15,7 @@ import { useLocale, useTranslation } from "../i18n.js";
15
15
  import { usePrimitives } from "../primitives.js";
16
16
  import { EmbeddedListField } from "./embedded-list-field.js";
17
17
  import { MultiSelectCheckboxes } from "./multi-select-checkboxes.js";
18
+ import { QueryOptionsSelect } from "./query-options-select.js";
18
19
  import { ReferenceCreateDialog } from "./reference-create-dialog.js";
19
20
  const AllFieldsRequiredContext = createContext(false);
20
21
  export const AllFieldsRequiredProvider = AllFieldsRequiredContext.Provider;
@@ -41,7 +42,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
41
42
  // Reference-Field rendert eine eigene Component — sie nutzt
42
43
  // useQuery() für den Live-Lookup, also muss sie als React-
43
44
  // Komponente gemountet werden (nicht als pure render-Call).
44
- const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : readOnlyText && !isComplexFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale) })) : (renderInput({
45
+ const control = field.readOnly && field.renderer !== undefined ? (_jsx(FieldRendererOutput, { field: field, renderer: field.renderer, appLocale: appLocale, ...(row !== undefined && { row }) })) : field.type === "embedded" && field.embeddedListCells !== undefined ? (_jsx(EmbeddedListField, { field: field, id: id, onChange: onChange, allIssues: allIssues ?? {}, featureName: featureName ?? "" })) : field.type === "reference" ? (readOnlyText ? (_jsx(ReadOnlyReferenceValue, { field: field, featureName: featureName ?? "" })) : (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }))) : field.type === "select" && field.selectOptionsQuery !== undefined && !readOnlyText ? (_jsx(QueryOptionsSelect, { field: field, query: field.selectOptionsQuery.query, payload: field.selectOptionsQuery.payload, id: id, hasError: hasError, onChange: onChange })) : readOnlyText && !isComplexFieldType(field.type) ? (_jsx(Text, { testId: `field-value-${field.field}`, children: readOnlyDisplayText(field, appLocale) })) : (renderInput({
45
46
  field,
46
47
  id,
47
48
  hasError,
@@ -98,6 +98,11 @@ export type RenderListProps = {
98
98
  * fw#3234). Omitted: legacy `p-6` fallback for a standalone table with no
99
99
  * padded host. */
100
100
  readonly screenPadding?: boolean;
101
+ /** Forwarded to `DataTableProps.expandedRowIds` / `onToggleRowExpanded` /
102
+ * `renderExpandedRow` (entityList `expandableRow`). */
103
+ readonly expandedRowIds?: ReadonlySet<string>;
104
+ readonly onToggleRowExpanded?: (rowId: string) => void;
105
+ readonly renderExpandedRow?: (row: ListRowViewModel) => ReactNode;
101
106
  };
102
107
  export type ToolbarActionButton = {
103
108
  readonly id: string;
@@ -4,17 +4,23 @@ import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections.js";
5
5
  import { useReferenceLookup } from "../hooks/use-reference-lookup.js";
6
6
  import { useTranslation } from "../i18n.js";
7
- import { usePageHeaderSlotAvailable } from "../page-header-slot.js";
7
+ import { usePageHeaderCompact, usePageHeaderSlotAvailable } from "../page-header-slot.js";
8
8
  import { shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
9
9
  const SEARCH_DEBOUNCE_MS = 300;
10
+ // Fire-and-forget actions (schema navigate/drawer) can leave the toolbar for the
11
+ // page header without losing the confirm dialog or busy/error surfacing.
12
+ function isHeaderPromotableToolbarAction(action) {
13
+ return (action.style === "primary" && action.confirm === undefined && action.confirmRequired === false);
14
+ }
10
15
  export function RenderList(props) {
11
- const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, } = props;
16
+ const { screen, entity, rows, featureName, translate: translateProp, onRowClick, emptyState, onCreate, createLabel, searchable = false, searchPlaceholder, description, itemNounKey, searchValue, onSearchChange, sort, onSortChange, pager, onReachEnd, loadingMore, hasMore, rowActions, rowActionMode, toolbarActions, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, } = props;
12
17
  // Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
13
18
  // wären Column-Header raw i18n-Keys.
14
19
  const t = useTranslation();
15
20
  const translate = translateProp ?? t;
16
21
  const { DataTable, Button, Dialog, Input, Text, Banner, PageHeader } = usePrimitives();
17
22
  const pageHeaderSlotAvailable = usePageHeaderSlotAvailable();
23
+ const pageHeaderCompact = usePageHeaderCompact();
18
24
  const createInPageHeader = onCreate !== undefined && PageHeader !== undefined && pageHeaderSlotAvailable;
19
25
  // Local search buffer + debounce. External changes (browser back,
20
26
  // cross-component reset) are mirrored back; typing only fires
@@ -131,9 +137,23 @@ export function RenderList(props) {
131
137
  const hasToolbarActions = toolbarActions !== undefined && toolbarActions.length > 0;
132
138
  const hasHeaderSlot = screen.slots?.header !== undefined;
133
139
  const toolbarIconOnly = hasToolbarActions && shouldRenderActionsIconOnly(toolbarActions);
134
- const createButton = onCreate !== undefined ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-create", className: "px-3.5", children: effectiveCreateLabel })) : undefined;
135
- const toolbarEnd = hasHeaderSlot || hasToolbarActions || (onCreate !== undefined && !createInPageHeader) ? (_jsxs(_Fragment, { children: [hasHeaderSlot && _jsx(ListHeaderSlotMount, { screen: screen }), hasToolbarActions &&
136
- toolbarActions.map((a) => (_jsx(ToolbarActionView, { action: a, iconOnly: toolbarIconOnly, Button: Button, Dialog: Dialog, Banner: Banner }, a.id))), !createInPageHeader && createButton] })) : undefined;
140
+ const createButton = onCreate !== undefined && createInPageHeader && pageHeaderCompact ? (_jsx(Button, { variant: "primary", icon: "plus", size: "icon", ariaLabel: effectiveCreateLabel, title: effectiveCreateLabel, onClick: onCreate, testId: "render-list-create" })) : onCreate !== undefined ? (_jsx(Button, { variant: "primary", icon: "plus", onClick: onCreate, testId: "render-list-create", className: "px-3.5", children: effectiveCreateLabel })) : undefined;
141
+ // Without onCreate (e.g. a navigate action as the list's only CTA) the primary
142
+ // toolbar action takes the header slot on phones, so it is not a text button
143
+ // squeezed next to the search field. Desktop keeps it in the toolbar.
144
+ const promotedToolbarAction = onCreate === undefined &&
145
+ PageHeader !== undefined &&
146
+ pageHeaderSlotAvailable &&
147
+ pageHeaderCompact
148
+ ? toolbarActions?.find(isHeaderPromotableToolbarAction)
149
+ : undefined;
150
+ const promotedActionButton = promotedToolbarAction !== undefined ? (_jsx(Button, { variant: "primary", icon: promotedToolbarAction.icon ?? "plus", size: "icon", ariaLabel: promotedToolbarAction.label, title: promotedToolbarAction.label, onClick: () => void promotedToolbarAction.onTrigger(), testId: `render-list-toolbar-action-${promotedToolbarAction.id}` })) : undefined;
151
+ const toolbarActionsInToolbar = toolbarActions?.filter((a) => a !== promotedToolbarAction) ?? [];
152
+ const hasToolbarActionsInToolbar = toolbarActionsInToolbar.length > 0;
153
+ const toolbarEnd = hasHeaderSlot ||
154
+ hasToolbarActionsInToolbar ||
155
+ (onCreate !== undefined && !createInPageHeader) ? (_jsxs(_Fragment, { children: [hasHeaderSlot && _jsx(ListHeaderSlotMount, { screen: screen }), hasToolbarActionsInToolbar &&
156
+ toolbarActionsInToolbar.map((a) => (_jsx(ToolbarActionView, { action: a, iconOnly: toolbarIconOnly, Button: Button, Dialog: Dialog, Banner: Banner }, a.id))), !createInPageHeader && createButton] })) : undefined;
137
157
  // Empty-State: Default zeigt Heading + Description + optional CTA-
138
158
  // Button. Caller kann via emptyState-Prop komplett überschreiben.
139
159
  // Wenn weder onCreate noch ein Override gegeben ist, fällt die
@@ -148,7 +168,7 @@ export function RenderList(props) {
148
168
  const resolvedTitle = translate(titleKey);
149
169
  const toolbarTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
150
170
  // ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
151
- return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: handleLookupMap }, rc.field))), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...(onRowClick !== undefined && { onRowClick }), ...(composedEmptyState !== undefined && { emptyState: composedEmptyState }), ...(toolbarStart !== undefined && { toolbarStart }), ...(description !== undefined && !searchable && { toolbarDescription: description }), ...(toolbarEnd !== undefined && { toolbarEnd }), ...(sort !== undefined && { sort }), ...(onSortChange !== undefined && { onSortChange }), ...(pager !== undefined && { pager }), ...(onReachEnd !== undefined && { onReachEnd }), ...(loadingMore !== undefined && { loadingMore }), ...(hasMore !== undefined && { hasMore }), ...(itemNoun !== undefined && { itemNoun }), ...(rowActions !== undefined && { rowActions }), ...(rowActionMode !== undefined && { rowActionMode }), ...(filterFacets !== undefined && { filterFacets }), ...(filterValues !== undefined && { filterValues }), ...(onFilterChange !== undefined && { onFilterChange }), ...(onFilterReset !== undefined && { onFilterReset }), ...(dateRangeFacets !== undefined && { dateRangeFacets }), ...(onDateRangeChange !== undefined && { onDateRangeChange }), ...(chromeless !== undefined && { chromeless }), ...(scrollBody !== undefined && { scrollBody }), ...(screenPadding !== undefined && { screenPadding }), testId: "render-list-table" })] }));
171
+ return (_jsxs(_Fragment, { children: [createInPageHeader && PageHeader !== undefined && _jsx(PageHeader, { actions: createButton }), promotedActionButton !== undefined && PageHeader !== undefined && (_jsx(PageHeader, { actions: promotedActionButton })), referenceColumns.map((rc) => (_jsx(ReferenceLookupBridge, { field: rc.field, refEntity: rc.refEntity, labelField: rc.labelField, featureName: rc.refFeature, onMap: handleLookupMap }, rc.field))), _jsx(DataTable, { columns: enrichedVm.columns, rows: enrichedVm.rows, toolbarTitle: toolbarTitle, ...(onRowClick !== undefined && { onRowClick }), ...(composedEmptyState !== undefined && { emptyState: composedEmptyState }), ...(toolbarStart !== undefined && { toolbarStart }), ...(description !== undefined && !searchable && { toolbarDescription: description }), ...(toolbarEnd !== undefined && { toolbarEnd }), ...(sort !== undefined && { sort }), ...(onSortChange !== undefined && { onSortChange }), ...(pager !== undefined && { pager }), ...(onReachEnd !== undefined && { onReachEnd }), ...(loadingMore !== undefined && { loadingMore }), ...(hasMore !== undefined && { hasMore }), ...(itemNoun !== undefined && { itemNoun }), ...(rowActions !== undefined && { rowActions }), ...(rowActionMode !== undefined && { rowActionMode }), ...(filterFacets !== undefined && { filterFacets }), ...(filterValues !== undefined && { filterValues }), ...(onFilterChange !== undefined && { onFilterChange }), ...(onFilterReset !== undefined && { onFilterReset }), ...(dateRangeFacets !== undefined && { dateRangeFacets }), ...(onDateRangeChange !== undefined && { onDateRangeChange }), ...(chromeless !== undefined && { chromeless }), ...(scrollBody !== undefined && { scrollBody }), ...(screenPadding !== undefined && { screenPadding }), ...(expandedRowIds !== undefined && { expandedRowIds }), ...(onToggleRowExpanded !== undefined && { onToggleRowExpanded }), ...(renderExpandedRow !== undefined && { renderExpandedRow }), testId: "render-list-table" })] }));
152
172
  }
153
173
  // Header-Slot in der Listen-Toolbar (toolbarEnd): `screen.slots.header` ist
154
174
  // eine PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
@@ -68,6 +68,11 @@ export const kumikoDefaultTranslations = {
68
68
  "kumiko.list.count.one": "1 entry",
69
69
  "kumiko.list.count.other": "{count} entries",
70
70
  "kumiko.list.row-actions.more": "More actions",
71
+ "kumiko.page-header.actions": "Page actions",
72
+ "kumiko.theme.dark": "Dark theme",
73
+ "kumiko.theme.light": "Light theme",
74
+ "kumiko.list.row.collapse": "Collapse {title}",
75
+ "kumiko.list.row.expand": "Expand {title}",
71
76
  "kumiko.list.sort.by": "Sorted by {column}",
72
77
  "kumiko.form.changed": "changed",
73
78
  "kumiko.form.on-this-page": "On this page",
package/dist/index.d.ts CHANGED
@@ -66,7 +66,7 @@ export type { LocaleProviderProps, TranslationBundle, TranslationsByKey, Transla
66
66
  export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
67
67
  export { kumikoDefaultTranslations } from "./i18n-defaults.js";
68
68
  export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.js";
69
- export { PageHeaderSlotAvailableProvider, usePageHeaderSlotAvailable } from "./page-header-slot.js";
69
+ export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.js";
70
70
  export type { ActionMenuItemSpec, ActionOverflowMenuProps, AppPrimitives, BannerProps, ButtonProps, CardOptions, CardProps, CardSlots, CopyButtonProps, CorePrimitives, DataTableDateRangeFacet, DataTableFacet, DataTableProps, DataTableRowAction, DataTableRowActionMode, DataTableSort, DataTableSortDir, DialogProps, DrawerProps, EmbeddedListCellType, EmbeddedListColumn, EmbeddedListInputProps, EmbeddedListTotal, FieldCellWidth, FieldProps, FillContainerProps, FormProps, FormSectionNavItem, FormWidth, GridCellProps, GridProps, HeadingProps, InputProps, JsonViewProps, LightboxProps, LinkProps, MetricBandProps, MetricProps, ModalProps, PageHeaderProps, PrimitivesProviderProps, PrimitivesRegistry, ProgressProps, ProgressTone, PromoPanelAction, PromoPanelProps, RuntimeRenderer, SecretRevealProps, SecretRevealValue, SectionProps, ShareButtonProps, StatusBadgeProps, StatusTone, StepBarProps, StickyActionBarProps, StickyPrimaryActionMarker, TabsProps, TextProps, WizardStepGroupProps, } from "./primitives.js";
71
71
  export { PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, usePrimitives, } from "./primitives.js";
72
72
  export { buildWhatsAppShareUrl } from "./share-links.js";
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ export { useStreamHandler } from "./hooks/use-stream-handler.js";
47
47
  export { createStaticLocaleResolver, FormalityProvider, LocaleProvider, mergeTranslations, translationsByLocaleFromKeys, useFormality, useLocale, useOptionalLocale, useOptionalTranslation, useTranslation, } from "./i18n.js";
48
48
  export { kumikoDefaultTranslations } from "./i18n-defaults.js";
49
49
  export { InsideDrawerProvider, useInsideDrawer } from "./inside-drawer.js";
50
- export { PageHeaderSlotAvailableProvider, usePageHeaderSlotAvailable } from "./page-header-slot.js";
50
+ export { PageHeaderCompactProvider, PageHeaderSlotAvailableProvider, usePageHeaderCompact, usePageHeaderSlotAvailable, } from "./page-header-slot.js";
51
51
  export { PrimitivesProvider, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, usePrimitives, } from "./primitives.js";
52
52
  export { buildWhatsAppShareUrl } from "./share-links.js";
53
53
  export { sortByAccessor } from "./sort-by-accessor.js";
@@ -1,2 +1,4 @@
1
1
  export declare const PageHeaderSlotAvailableProvider: import("react").Provider<boolean>;
2
2
  export declare function usePageHeaderSlotAvailable(): boolean;
3
+ export declare const PageHeaderCompactProvider: import("react").Provider<boolean>;
4
+ export declare function usePageHeaderCompact(): boolean;
@@ -7,3 +7,10 @@ export const PageHeaderSlotAvailableProvider = PageHeaderSlotAvailableContext.Pr
7
7
  export function usePageHeaderSlotAvailable() {
8
8
  return useContext(PageHeaderSlotAvailableContext);
9
9
  }
10
+ // True while a shell with page-header slots renders at phone width: screens
11
+ // then move secondary header actions into the shell's overflow menu.
12
+ const PageHeaderCompactContext = createContext(false);
13
+ export const PageHeaderCompactProvider = PageHeaderCompactContext.Provider;
14
+ export function usePageHeaderCompact() {
15
+ return useContext(PageHeaderCompactContext);
16
+ }
@@ -43,6 +43,8 @@ export type ButtonProps = {
43
43
  readonly title?: string;
44
44
  /** Toggle state for toggle buttons — web sets `aria-pressed`; leave unset for plain buttons. */
45
45
  readonly pressed?: boolean;
46
+ /** Disclosure state for buttons that open a menu or panel — web sets `aria-expanded`. */
47
+ readonly expanded?: boolean;
46
48
  /** Breite — default="auto" (inhaltsbreit). "full" streckt CTA-Buttons auf
47
49
  * die Container-Breite (Karten/Panels). Andere Breiten sind Layout-Sache
48
50
  * des Containers, kein Button-Prop (Kit hält arbiträres Sizing draußen). */
@@ -77,6 +79,9 @@ export type LinkProps = {
77
79
  * `rel` is set explicitly. */
78
80
  readonly target?: "_blank" | "_self" | "_parent" | "_top";
79
81
  readonly rel?: string;
82
+ /** Plain left-click handler (SPA navigation); `href` stays set for
83
+ * modifier/middle clicks, which the browser handles itself. */
84
+ readonly onPress?: () => void;
80
85
  /** Layout-Zusätze (self-center, text-xs) — Web merged via cn(),
81
86
  * Native-Impls ignorieren es (Präzedenz: CardProps.className). */
82
87
  readonly className?: string;
@@ -642,6 +647,17 @@ export type DataTableProps = {
642
647
  * `scrollBody`'s flex-fill height budget.
643
648
  * Default false: unchanged symmetric inset for an embedded table. */
644
649
  readonly screenPadding?: boolean;
650
+ /** Ids of the rows whose expansion area is currently open (controlled by
651
+ * the host; several rows can be open at once). Only meaningful together
652
+ * with `renderExpandedRow` + `onToggleRowExpanded`. */
653
+ readonly expandedRowIds?: ReadonlySet<string>;
654
+ /** User toggled a row's expand control. With `renderExpandedRow` this makes
655
+ * every row expandable: the renderer adds a toggle (button with
656
+ * `aria-expanded`) that does not trigger `onRowClick`. */
657
+ readonly onToggleRowExpanded?: (rowId: string) => void;
658
+ /** Content of the area under an expanded row. Native renderers implement
659
+ * the same three props. */
660
+ readonly renderExpandedRow?: (row: ListRowViewModel) => ReactNode;
645
661
  };
646
662
  /** Cell type for one column of an embedded-list field. Mirrors
647
663
  * EmbeddedListCellViewModel["type"] from `@cosmicdrift/kumiko-headless`. */
@@ -892,6 +908,8 @@ export type GridCellProps = {
892
908
  * foreground). Custom-Impls mappen auf ihren TypeScale. */
893
909
  export type TextProps = {
894
910
  readonly variant?: "body" | "small" | "code" | "required-mark" | "muted";
911
+ /** Pure ornament (e.g. a separator glyph): hidden from assistive tech. */
912
+ readonly decorative?: boolean;
895
913
  readonly children: ReactNode;
896
914
  readonly testId?: string;
897
915
  };
@@ -1162,6 +1180,8 @@ export type PageHeaderProps = {
1162
1180
  readonly recordTitle?: string;
1163
1181
  readonly status?: ReactNode;
1164
1182
  readonly actions?: ReactNode;
1183
+ /** Land in the shell header's overflow menu at phone width. */
1184
+ readonly overflowItems?: readonly ActionMenuItemSpec[];
1165
1185
  };
1166
1186
  /** One item in an `ActionOverflowMenu` (A7: header/row actions beyond the
1167
1187
  * primary one collapse into this menu instead of a wall of buttons). */
@@ -1172,6 +1192,7 @@ export type ActionMenuItemSpec = {
1172
1192
  readonly icon?: IconKey;
1173
1193
  readonly disabled?: boolean;
1174
1194
  readonly variant?: "default" | "danger";
1195
+ readonly testId?: string;
1175
1196
  readonly onSelect: () => void;
1176
1197
  };
1177
1198
  export type ActionOverflowMenuProps = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.330.2",
3
+ "version": "0.332.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,9 +15,9 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.330.2",
19
- "@cosmicdrift/kumiko-headless": "0.330.2",
20
- "@cosmicdrift/kumiko-types": "0.330.2",
18
+ "@cosmicdrift/kumiko-framework": "0.332.0",
19
+ "@cosmicdrift/kumiko-headless": "0.332.0",
20
+ "@cosmicdrift/kumiko-types": "0.332.0",
21
21
  "react": "^19.2.6",
22
22
  "temporal-polyfill": "^0.3.2",
23
23
  "zod": "^4.4.3"
@@ -28,7 +28,7 @@
28
28
  "@types/react-dom": "^19.2.3",
29
29
  "jsdom": "^29.1.1",
30
30
  "react-dom": "^19.2.6",
31
- "@cosmicdrift/kumiko-locale-de": "0.330.2"
31
+ "@cosmicdrift/kumiko-locale-de": "0.332.0"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "0.332.0",
4
+ "type": "improvement",
5
+ "title": "projectionDetail header subtitle can show several parts, each optionally linking to the referenced record",
6
+ "detail": "`header.subtitle` accepts a list of parts (field name or `{ field, navigate }`). Empty parts drop out, the rest are joined by a \"·\" separator, and a part with `navigate` links to the referenced record (entity or screen target, only when reachable). The Link primitive gets an optional `onPress` for SPA navigation, Text an optional `decorative` flag. `subtitleHref` stays valid with the string form only; the boot validator rejects the combination with a list.",
7
+ "migration": "No code change needed."
8
+ },
9
+ {
10
+ "version": "0.331.0",
11
+ "type": "improvement",
12
+ "title": "entityList rows can expand into a related list with its own row actions (expandableRow)",
13
+ "detail": "`expandableRow` on an entityList declares a related list under each row, with the same fields as a projectionDetail `relatedList` section (query, `parentFilter` or `parentParam`, columns, row and toolbar actions, emptyState). The parent id is the row's `id`. An arrow button at the start of the row opens and closes the area, carries `aria-expanded`, and works by keyboard. Several rows can be open at once. A successful write from the area reloads both the related list and the parent list, so counters on the parent row update. The boot validator and the role projection check the area like a relatedList section. `DataTableProps` gains `expandedRowIds`, `onToggleRowExpanded` and `renderExpandedRow` for custom DataTable primitives."
14
+ },
15
+ {
16
+ "version": "0.331.0",
17
+ "type": "improvement",
18
+ "title": "relatedList columns format select, date and other field types like entityList columns (entity)",
19
+ "detail": "A relatedList section (projectionDetail tab or entityList `expandableRow`) takes an optional `entity`: an entity name or `feature:entity`. Columns that name a field of that entity render through the same cell formatter as an entityList column. A select shows as a status badge with its translated option label, dates are locale-formatted, and the header defaults to the field's label key. A column's own `sortable` still controls the header sort. The boot validator rejects an `entity` that does not resolve. Without `entity`, columns render as before."
20
+ },
2
21
  {
3
22
  "version": "0.330.1",
4
23
  "type": "breaking",