@cosmicdrift/kumiko-renderer 0.331.0 → 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.
@@ -3,7 +3,7 @@ import { evalFieldCondition, isWriteFormEditSection, metricField, parseRefTarget
3
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";
@@ -1621,6 +1621,46 @@ function runMetricNavigate(nav, navigate, record, host) {
1621
1621
  function isAbsoluteHttpUrl(value) {
1622
1622
  return /^https?:\/\//i.test(value) && isSafeHref(value);
1623
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
+ }
1624
1664
  function headerStatusTone(header, record) {
1625
1665
  if (header.status === undefined)
1626
1666
  return undefined;
@@ -1900,7 +1940,10 @@ function ProjectionDetailBody({ schema, screen, translate, entityId, }) {
1900
1940
  // Plain Text, not Heading — the Card title slot already renders as its own
1901
1941
  // heading element (h3); nesting another heading (h1) inside it is invalid HTML.
1902
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;
1903
- 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;
1904
1947
  // slots.header shares the header card with the actions (one card, actions
1905
1948
  // top right) instead of rendering as an unframed block above it.
1906
1949
  const metricItems = screen.metrics?.map((metric) => {
@@ -7,6 +7,11 @@ import { useTranslation } from "../i18n.js";
7
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
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
@@ -133,8 +138,22 @@ export function RenderList(props) {
133
138
  const hasHeaderSlot = screen.slots?.header !== undefined;
134
139
  const toolbarIconOnly = hasToolbarActions && shouldRenderActionsIconOnly(toolbarActions);
135
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;
136
- const toolbarEnd = hasHeaderSlot || hasToolbarActions || (onCreate !== undefined && !createInPageHeader) ? (_jsxs(_Fragment, { children: [hasHeaderSlot && _jsx(ListHeaderSlotMount, { screen: screen }), hasToolbarActions &&
137
- toolbarActions.map((a) => (_jsx(ToolbarActionView, { action: a, iconOnly: toolbarIconOnly, Button: Button, Dialog: Dialog, Banner: Banner }, a.id))), !createInPageHeader && createButton] })) : 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;
138
157
  // Empty-State: Default zeigt Heading + Description + optional CTA-
139
158
  // Button. Caller kann via emptyState-Prop komplett überschreiben.
140
159
  // Wenn weder onCreate noch ein Override gegeben ist, fällt die
@@ -149,7 +168,7 @@ export function RenderList(props) {
149
168
  const resolvedTitle = translate(titleKey);
150
169
  const toolbarTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
151
170
  // ListSort = DataTableSort (use-list-url-state aliased) — kein Cast nötig.
152
- 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 }), ...(expandedRowIds !== undefined && { expandedRowIds }), ...(onToggleRowExpanded !== undefined && { onToggleRowExpanded }), ...(renderExpandedRow !== undefined && { renderExpandedRow }), 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" })] }));
153
172
  }
154
173
  // Header-Slot in der Listen-Toolbar (toolbarEnd): `screen.slots.header` ist
155
174
  // eine PlatformComponent (`{ react: { __component: "X" } }`), aufgelöst über
@@ -68,6 +68,9 @@ 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",
71
74
  "kumiko.list.row.collapse": "Collapse {title}",
72
75
  "kumiko.list.row.expand": "Expand {title}",
73
76
  "kumiko.list.sort.by": "Sorted by {column}",
@@ -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;
@@ -903,6 +908,8 @@ export type GridCellProps = {
903
908
  * foreground). Custom-Impls mappen auf ihren TypeScale. */
904
909
  export type TextProps = {
905
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;
906
913
  readonly children: ReactNode;
907
914
  readonly testId?: string;
908
915
  };
@@ -1185,6 +1192,7 @@ export type ActionMenuItemSpec = {
1185
1192
  readonly icon?: IconKey;
1186
1193
  readonly disabled?: boolean;
1187
1194
  readonly variant?: "default" | "danger";
1195
+ readonly testId?: string;
1188
1196
  readonly onSelect: () => void;
1189
1197
  };
1190
1198
  export type ActionOverflowMenuProps = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.331.0",
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.331.0",
19
- "@cosmicdrift/kumiko-headless": "0.331.0",
20
- "@cosmicdrift/kumiko-types": "0.331.0",
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.331.0"
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,11 @@
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
+ },
2
9
  {
3
10
  "version": "0.331.0",
4
11
  "type": "improvement",