@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.
- package/dist/app/kumiko-screen.js +45 -2
- package/dist/components/render-list.js +22 -3
- package/dist/i18n-defaults.js +3 -0
- package/dist/primitives.d.ts +8 -0
- package/package.json +5 -5
- package/src/changes.json +7 -0
|
@@ -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
|
|
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
|
-
|
|
137
|
-
|
|
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
|
package/dist/i18n-defaults.js
CHANGED
|
@@ -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}",
|
package/dist/primitives.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
20
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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",
|