@cosmicdrift/kumiko-renderer 0.390.1 → 0.391.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.
Files changed (37) hide show
  1. package/dist/app/projection-detail-header.js +7 -3
  2. package/dist/app/reference-link.d.ts +4 -0
  3. package/dist/app/reference-link.js +16 -0
  4. package/dist/app/use-projection-detail-data.js +19 -4
  5. package/dist/components/grid-cell-for-field.d.ts +3 -1
  6. package/dist/components/grid-cell-for-field.js +2 -2
  7. package/dist/components/query-options-select.d.ts +2 -1
  8. package/dist/components/query-options-select.js +15 -3
  9. package/dist/components/render-edit-field-grid.js +3 -1
  10. package/dist/components/render-edit-layout.js +1 -1
  11. package/dist/components/render-edit-titles.d.ts +1 -1
  12. package/dist/components/render-edit-titles.js +3 -6
  13. package/dist/components/render-field-control.d.ts +1 -0
  14. package/dist/components/render-field-control.js +2 -2
  15. package/dist/components/render-field.d.ts +4 -1
  16. package/dist/components/render-field.js +2 -1
  17. package/dist/components/use-render-list-columns.d.ts +2 -0
  18. package/dist/components/use-render-list-columns.js +38 -3
  19. package/dist/headless/form/form-controller.js +9 -0
  20. package/dist/headless/form/types.d.ts +1 -0
  21. package/dist/headless/format/index.d.ts +1 -1
  22. package/dist/headless/format/index.js +23 -0
  23. package/dist/metric-types.d.ts +29 -0
  24. package/dist/metric-types.js +1 -0
  25. package/dist/primitives.d.ts +2 -20
  26. package/dist/web/app/dashboard-panels.js +2 -2
  27. package/dist/web/layout/shell-header.js +2 -2
  28. package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
  29. package/dist/web/layout/uniform-bar-controls.js +3 -0
  30. package/dist/web/primitives/data-table-cell.js +25 -2
  31. package/dist/web/primitives/data-table.js +1 -1
  32. package/dist/web/primitives/metric.d.ts +2 -2
  33. package/dist/web/primitives/metric.js +13 -4
  34. package/dist/web/widgets/stat.d.ts +1 -0
  35. package/dist/web/widgets/stat.js +3 -0
  36. package/package.json +5 -5
  37. package/src/changes.json +18 -0
@@ -50,20 +50,24 @@ export function buildMetricItems(model) {
50
50
  return screen.metrics?.map((metric) => {
51
51
  const field = metric.field;
52
52
  const label = effectiveTranslate(metric.label);
53
- const value = String(record[field] ?? "");
53
+ const rawValue = record[field];
54
+ const value = String(rawValue ?? "");
55
+ const tiles = screen.metricsStyle === "tiles";
56
+ // Same rule as the dashboard stat panels: a fixed tone must not paint an all-clear 0.
57
+ const tone = rawValue === 0 ? undefined : metric.tone;
54
58
  const testId = `kumiko-screen-projection-detail-metric-${field}`;
55
59
  const navigate = metric.navigate;
56
60
  const onPress = navigate !== undefined && navigateTargetAllows(navigate, appFeatures, userRoles)
57
61
  ? () => runMetricNavigate(nav, navigate, record, host)
58
62
  : undefined;
59
- return Metric !== undefined ? (_jsx(Metric, { label: label, value: value, testId: testId, ...(onPress !== undefined && { onPress }) }, field)) : (_jsxs(GridCell, { children: [_jsx(Text, { variant: "small", testId: `${testId}-label`, children: label }), _jsx(Text, { testId: `${testId}-value`, children: value })] }, field));
63
+ return Metric !== undefined ? (_jsx(Metric, { label: label, value: value, testId: testId, ...(onPress !== undefined && { onPress }), ...(tiles && { tile: true }), ...(tone !== undefined && { tone }), ...(metric.icon !== undefined && { icon: metric.icon }) }, field)) : (_jsxs(GridCell, { children: [_jsx(Text, { variant: "small", testId: `${testId}-label`, children: label }), _jsx(Text, { testId: `${testId}-value`, children: value })] }, field));
60
64
  });
61
65
  }
62
66
  // Metrics are a dl band when the primitive exists; the old Grid stays as the fallback.
63
67
  export function renderMetricsBlock(model, metricItems, subtitle, lead) {
64
68
  const { screen, primitives } = model;
65
69
  const { MetricBand, Grid } = primitives;
66
- return 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 }));
70
+ return MetricBand !== undefined ? (_jsx(MetricBand, { testId: "kumiko-screen-projection-detail-metrics", subtitle: subtitle, ...(lead !== undefined && { lead }), ...(screen.metricsStyle === "tiles" && { tiles: true }), children: metricItems })) : (_jsx(Grid, { columns: screen.metrics?.length ?? 1, testId: "kumiko-screen-projection-detail-metrics", children: metricItems }));
67
71
  }
68
72
  // The phone shell header clips a long status mid-word, so on phones the badge
69
73
  // sits in the body instead of the shell header slot.
@@ -3,3 +3,7 @@ import type { CellLink } from "../headless/index.ts";
3
3
  * navigation, the user may not open that screen, or no detail screen exists. */
4
4
  export type ReferenceLinkResolver = (refEntity: string, id: string) => CellLink | undefined;
5
5
  export declare function useReferenceLinkResolver(): ReferenceLinkResolver;
6
+ /** Link to a screen named by its QN, or undefined when there is no navigation
7
+ * or the user may not open that screen. */
8
+ export type ScreenLinkResolver = (screenQn: string) => CellLink | undefined;
9
+ export declare function useScreenLinkResolver(): ScreenLinkResolver;
@@ -2,6 +2,7 @@ import { useCallback } from "react";
2
2
  import { useUserRoles } from "../context/user-roles-context.js";
3
3
  import { useAppFeatures } from "./app-features-context.js";
4
4
  import { useOptionalNav } from "./nav.js";
5
+ import { lastSegment } from "./qn.js";
5
6
  import { navigateWithReturnTo, useReturnHost } from "./return-to.js";
6
7
  import { navigateTargetAllows } from "./screen-access.js";
7
8
  export function useReferenceLinkResolver() {
@@ -30,3 +31,18 @@ export function useReferenceLinkResolver() {
30
31
  return { href, onPress: () => navigateWithReturnTo(nav, target, host) };
31
32
  }, [nav, appFeatures, userRoles, host]);
32
33
  }
34
+ export function useScreenLinkResolver() {
35
+ const nav = useOptionalNav();
36
+ const appFeatures = useAppFeatures();
37
+ const userRoles = useUserRoles();
38
+ const host = useReturnHost();
39
+ return useCallback((screenQn) => {
40
+ const screenId = lastSegment(screenQn);
41
+ if (nav === undefined || screenId === "")
42
+ return undefined;
43
+ if (!navigateTargetAllows({ screen: screenId }, appFeatures, userRoles))
44
+ return undefined;
45
+ const target = { screenId };
46
+ return { href: nav.hrefFor(target), onPress: () => navigateWithReturnTo(nav, target, host) };
47
+ }, [nav, appFeatures, userRoles, host]);
48
+ }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { rendererWarn } from "../dev-console.js";
3
3
  import { useQuery } from "../hooks/use-query.js";
4
4
  import { synthesizeProjectionDetailEntity, synthesizeProjectionDetailScreen, } from "./projection-detail-shim.js";
@@ -29,7 +29,8 @@ export function useProjectionDetailData(input) {
29
29
  : screen;
30
30
  return synthesizeProjectionDetailScreen(source);
31
31
  }, [screen, activeSection]);
32
- const detailQuery = useQuery(screen.query, effectiveEntityId !== undefined ? { [idParam]: effectiveEntityId } : {});
32
+ const live = screen.live === true;
33
+ const detailQuery = useQuery(screen.query, effectiveEntityId !== undefined ? { [idParam]: effectiveEntityId } : {}, { live });
33
34
  // A writeForm section's handler creates a new record (see EditWriteFormSection's
34
35
  // doc) — both the outer record (header/metrics/other sections) and a sibling
35
36
  // relatedList section's own independently-fetched rows must reflect it. A
@@ -39,9 +40,23 @@ export function useProjectionDetailData(input) {
39
40
  // remount (a `key`) so unsaved input in the other writeForm sections and
40
41
  // the related lists' local state survive.
41
42
  const [reloadNonce, setReloadNonce] = useState(0);
43
+ // Live mode: every refetched record bumps the nonce below, a write included.
42
44
  const reloadDetail = useCallback(async () => {
43
45
  await refetchAfterWrite(detailQuery.refetch);
44
- setReloadNonce((n) => n + 1);
45
- }, [detailQuery.refetch]);
46
+ if (!live)
47
+ setReloadNonce((n) => n + 1);
48
+ }, [detailQuery.refetch, live]);
49
+ // A live refetch only reaches the record; the related lists hold their own rows.
50
+ const liveRecord = live ? (detailQuery.data ?? undefined) : undefined;
51
+ const shownLiveRecord = useRef(liveRecord);
52
+ useEffect(() => {
53
+ const previous = shownLiveRecord.current;
54
+ // skip: the shown record is unchanged, the related lists need no reload.
55
+ if (previous === liveRecord)
56
+ return;
57
+ shownLiveRecord.current = liveRecord;
58
+ if (previous !== undefined)
59
+ setReloadNonce((n) => n + 1);
60
+ }, [liveRecord]);
46
61
  return { activeSection, hasTabs, entity, detailScreen, detailQuery, reloadNonce, reloadDetail };
47
62
  }
@@ -6,6 +6,8 @@ export type GridCellForFieldProps = {
6
6
  readonly columns: number;
7
7
  readonly issues: readonly FieldIssue[] | undefined;
8
8
  readonly onChange: (value: unknown) => void;
9
+ /** Passed through to RenderField — see RenderFieldProps.onSuggest. */
10
+ readonly onSuggest?: (value: string) => void;
9
11
  readonly GridCell: ReturnType<typeof usePrimitives>["GridCell"];
10
12
  /** Passed through so Reference fields can build the correct
11
13
  * lookup query QN (`<feature>:query:<refEntity>:list`). */
@@ -33,4 +35,4 @@ export type GridCellForFieldProps = {
33
35
  readonly sectionTitle?: string;
34
36
  };
35
37
  export declare function fieldCellWidth(field: EditFieldViewModel): FieldCellWidth;
36
- export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }: GridCellForFieldProps): ReactNode;
38
+ export declare function GridCellForField({ field, columns, issues, onChange, onSuggest, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }: GridCellForFieldProps): ReactNode;
@@ -29,7 +29,7 @@ export function fieldCellWidth(field) {
29
29
  return "full";
30
30
  return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
31
31
  }
32
- export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }) {
32
+ export function GridCellForField({ field, columns, issues, onChange, onSuggest, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, fillDisabled, sectionTitle, }) {
33
33
  // RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
34
34
  if (!field.visible)
35
35
  return null;
@@ -38,5 +38,5 @@ export function GridCellForField({ field, columns, issues, onChange, GridCell, f
38
38
  field.embeddedListCells !== undefined &&
39
39
  field.label === sectionTitle;
40
40
  const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
41
- return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(fillDisabled === true && { fillDisabled }), ...(hideLabel && { hideLabel }) }) }));
41
+ return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, ...(onSuggest !== undefined && { onSuggest }), featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }), ...(fillDisabled === true && { fillDisabled }), ...(hideLabel && { hideLabel }) }) }));
42
42
  }
@@ -11,8 +11,9 @@ type QueryOptionsSelectProps = {
11
11
  readonly id: string;
12
12
  readonly hasError: boolean;
13
13
  readonly onChange: (value: unknown) => void;
14
+ readonly onSuggest?: (value: string) => void;
14
15
  };
15
- export declare function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, }: QueryOptionsSelectProps): ReactNode;
16
+ export declare function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, onSuggest, }: QueryOptionsSelectProps): ReactNode;
16
17
  export declare function QueryOptionLabel({ query, payload, value, }: {
17
18
  readonly query: string;
18
19
  readonly payload: ResolvedOptionsQueryPayload;
@@ -22,7 +22,7 @@ export function resolveOptionsQueryPayload(payload, row) {
22
22
  // Select whose options come from a query (SelectFieldDef.optionsQuery), so it
23
23
  // has to be a mounted component for useQuery. useQuery re-fetches on the
24
24
  // serialized payload, hence no payload memoization here.
25
- export function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, }) {
25
+ export function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, onSuggest, }) {
26
26
  const { Input } = usePrimitives();
27
27
  const resolvedPayload = resolveOptionsQueryPayload(payload, row);
28
28
  const payloadKey = JSON.stringify(resolvedPayload);
@@ -41,8 +41,20 @@ export function QueryOptionsSelect({ field, query, payload, row, id, hasError, o
41
41
  const previousKey = settledPayloadKey.current;
42
42
  settledPayloadKey.current = payloadKey;
43
43
  const payloadChanged = previousKey !== null && previousKey !== payloadKey;
44
- if (payloadChanged && current !== "" && !data.rows.some((row) => row.value === current)) {
45
- onChange("");
44
+ const discarded = payloadChanged && current !== "" && !data.rows.some((row) => row.value === current);
45
+ const suggestion = onSuggest === undefined ? undefined : data.rows.find((row) => row.default === true);
46
+ if (payloadChanged) {
47
+ // The user's sibling edit caused this load, so the form is dirty anyway: a
48
+ // discarded value is replaced by the suggestion in one change.
49
+ if (current !== "" && !discarded)
50
+ return;
51
+ if (suggestion)
52
+ onChange(suggestion.value);
53
+ else if (discarded)
54
+ onChange("");
55
+ }
56
+ else if (current === "" && suggestion) {
57
+ onSuggest?.(suggestion.value);
46
58
  }
47
59
  }, [data]);
48
60
  // A stored value missing from the result (still loading, or dropped from the
@@ -28,7 +28,9 @@ function createFieldCellRenderer(model, section, columns) {
28
28
  }), issues: snapshot.errors[field.field], onChange: (v) => {
29
29
  model.setFieldUntyped(field.field, v);
30
30
  clearErrorsAndValidate(field.field);
31
- }, GridCell: GridCell, featureName: props.featureName, ...appendixCellProps(props, field.field), allIssues: snapshot.errors, valueDisplay: props.valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...(props.hideSectionTitles !== true &&
31
+ }, ...(props.actionFormScreenQn !== undefined && {
32
+ onSuggest: (value) => form.controller.seedField(field.field, value),
33
+ }), GridCell: GridCell, featureName: props.featureName, ...appendixCellProps(props, field.field), allIssues: snapshot.errors, valueDisplay: props.valueDisplay, row: snapshot.values, ...(disabled && { fillDisabled: true }), ...(props.hideSectionTitles !== true &&
32
34
  section.title !== undefined && { sectionTitle: section.title }), ...(showsChangedMarker && field.field in snapshot.changes && { changed: true }) }, field.field));
33
35
  }
34
36
  export function renderFieldGrid(model, section, fields, columns, gridKey, columnsDeclared) {
@@ -41,7 +41,7 @@ function buildFormHeadingProps(model) {
41
41
  ...(props.hideSectionTitles !== true &&
42
42
  !layout.titleInShell &&
43
43
  !titles.titleInSettingsHeader &&
44
- !titles.recordTitleInHeaderRegion && { title: titles.formTitle }),
44
+ !titles.titleInHeaderRegion && { title: titles.formTitle }),
45
45
  ...(titles.formSubtitle !== undefined &&
46
46
  !titles.stepRail &&
47
47
  !titles.titleInSettingsHeader && { subtitle: titles.formSubtitle }),
@@ -8,7 +8,7 @@ export type TitleModel = {
8
8
  readonly formSubtitle: string | undefined;
9
9
  readonly titleInSettingsHeader: boolean;
10
10
  readonly usesFieldFlow: boolean;
11
- readonly recordTitleInHeaderRegion: boolean;
11
+ readonly titleInHeaderRegion: boolean;
12
12
  readonly stepRail: boolean;
13
13
  readonly upNextSection: EditSectionViewModel | undefined;
14
14
  readonly sectionNavItems: readonly FormSectionNavItem[];
@@ -83,12 +83,9 @@ export function deriveTitles(engine, layout, footer) {
83
83
  // floating as a heading above the band.
84
84
  titleInSettingsHeader: footer.isSettingsList && !layout.titleInShell,
85
85
  usesFieldFlow: layout.isScreenForm || state.insideDrawer,
86
- // The caller's headerRegion already shows the record title in the shell page
87
- // header, so the type title ("Release") above the form would repeat it.
88
- recordTitleInHeaderRegion: headerRegion !== undefined &&
89
- recordTitle !== undefined &&
90
- PageHeader !== undefined &&
91
- state.pageHeaderSlotAvailable,
86
+ // The caller's headerRegion fills the shell page header with the record title
87
+ // or, without one, the screen title, so a form title above the sections repeats it.
88
+ titleInHeaderRegion: headerRegion !== undefined && PageHeader !== undefined && state.pageHeaderSlotAvailable,
92
89
  stepRail: state.isWizard && layout.isScreenForm && StepBar !== undefined,
93
90
  upNextSection: screen.layout.wizard?.aside?.upNext === true ? filteredSections[currentStep + 1] : undefined,
94
91
  sectionNavItems: layout.isScreenForm && !state.isStepped
@@ -7,6 +7,7 @@ export type ControlContext = RenderInputArgs & {
7
7
  readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>> | undefined;
8
8
  readonly changed: boolean | undefined;
9
9
  readonly fillDisabled: boolean | undefined;
10
+ readonly onSuggest?: (value: string) => void;
10
11
  };
11
12
  export declare function listPrintsOwnIssues(field: EditFieldViewModel): boolean;
12
13
  export declare function renderControl(ctx: ControlContext): ReactNode;
@@ -42,11 +42,11 @@ const referenceRule = ({ field, id, hasError, onChange, Input, featureName, read
42
42
  return (_jsx(ReferenceInput, { field: field, id: id, hasError: hasError, onChange: onChange, Input: Input, featureName: featureName ?? "" }));
43
43
  };
44
44
  const selectQueryRule = (ctx) => {
45
- const { field, id, hasError, onChange, readOnlyText } = ctx;
45
+ const { field, id, hasError, onChange, onSuggest, readOnlyText } = ctx;
46
46
  const source = field.selectOptionsQuery;
47
47
  if (field.type !== "select" || source === undefined || readOnlyText)
48
48
  return undefined;
49
- return (_jsx(QueryOptionsSelect, { field: field, query: source.query, payload: source.payload, row: rowOf(ctx), id: id, hasError: hasError, onChange: onChange }));
49
+ return (_jsx(QueryOptionsSelect, { field: field, query: source.query, payload: source.payload, row: rowOf(ctx), id: id, hasError: hasError, onChange: onChange, ...(onSuggest !== undefined && { onSuggest }) }));
50
50
  };
51
51
  const selectAvailabilityRule = (ctx) => {
52
52
  const { field, id, hasError, onChange, readOnlyText } = ctx;
@@ -5,6 +5,9 @@ export type RenderFieldProps = {
5
5
  readonly field: EditFieldViewModel;
6
6
  readonly issues?: readonly FieldIssue[];
7
7
  readonly onChange: (val: unknown) => void;
8
+ /** Seeds an empty optionsQuery select with the query's default row as a baseline
9
+ * value (not a change). Only set where that is wanted (actionForm). */
10
+ readonly onSuggest?: (value: string) => void;
8
11
  /** Relevant only for type:"reference": the feature name for the lookup
9
12
  * query QN (`<feature>:query:<refEntity>:list`). Other field types
10
13
  * ignore this prop. */
@@ -46,4 +49,4 @@ export type RenderFieldProps = {
46
49
  readonly hideLabel?: boolean;
47
50
  };
48
51
  export declare const AllFieldsRequiredProvider: import("react").Provider<boolean>;
49
- export declare function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, fillDisabled, hideLabel, }: RenderFieldProps): ReactNode;
52
+ export declare function RenderField({ field, issues, onChange, onSuggest, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, layout, accent, fillDisabled, hideLabel, }: RenderFieldProps): ReactNode;
@@ -20,7 +20,7 @@ function fieldOptionalProps({ issues, printsIssues, labelAppendix, fieldAppendix
20
20
  ...(hideLabel === true && { hideLabel }),
21
21
  };
22
22
  }
23
- export function RenderField({ field, issues, onChange, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, fillDisabled, hideLabel, }) {
23
+ export function RenderField({ field, issues, onChange, onSuggest, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay = "form", row, changed, layout, accent, fillDisabled, hideLabel, }) {
24
24
  const { Field, Input, Banner, Text, JsonView } = usePrimitives();
25
25
  // The app locale is required for money/date inputs; otherwise they fall back
26
26
  // to navigator.language instead of the selected app language. RenderField is
@@ -43,6 +43,7 @@ export function RenderField({ field, issues, onChange, featureName, labelAppendi
43
43
  id,
44
44
  hasError: issues !== undefined && issues.length > 0,
45
45
  onChange,
46
+ ...(onSuggest !== undefined && { onSuggest }),
46
47
  Input,
47
48
  appLocale,
48
49
  Banner,
@@ -1,6 +1,8 @@
1
1
  import type { EntityDefinition, EntityListScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { type ScreenLinkResolver } from "../app/reference-link.ts";
2
3
  import type { ListColumnViewModel, ListViewModel, Translate } from "../headless/index.ts";
3
4
  import { type PublishedReferenceLookup } from "./render-list-reference-columns.ts";
5
+ export declare function withScreenLinks(columns: readonly ListColumnViewModel[], screen: EntityListScreenDefinition, resolveScreenLink: ScreenLinkResolver): readonly ListColumnViewModel[];
4
6
  type RenderListColumnsInput = {
5
7
  readonly screen: EntityListScreenDefinition;
6
8
  readonly entity: Pick<EntityDefinition, "fields" | "derivedFields">;
@@ -1,7 +1,32 @@
1
+ import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
1
2
  import { useCallback, useMemo, useState } from "react";
2
- import { useReferenceLinkResolver } from "../app/reference-link.js";
3
+ import { useReferenceLinkResolver, useScreenLinkResolver, } from "../app/reference-link.js";
3
4
  import { computeListViewModel } from "../headless/index.js";
4
5
  import { enrichReferenceColumns, selectReferenceColumns, } from "./render-list-reference-columns.js";
6
+ // A column whose rows name their own target screen links each cell there.
7
+ export function withScreenLinks(columns, screen, resolveScreenLink) {
8
+ const linkFields = new Map();
9
+ for (const spec of screen.columns) {
10
+ const normalized = normalizeListColumn(spec);
11
+ if (normalized.linkScreenField !== undefined) {
12
+ linkFields.set(normalized.field, normalized.linkScreenField);
13
+ }
14
+ }
15
+ if (linkFields.size === 0)
16
+ return columns;
17
+ return columns.map((col) => {
18
+ const linkScreenField = linkFields.get(col.field);
19
+ if (linkScreenField === undefined)
20
+ return col;
21
+ return {
22
+ ...col,
23
+ linkFor: (_value, row) => {
24
+ const screenQn = row?.[linkScreenField];
25
+ return typeof screenQn === "string" ? resolveScreenLink(screenQn) : undefined;
26
+ },
27
+ };
28
+ });
29
+ }
5
30
  export function useRenderListColumns(input) {
6
31
  const { screen, entity, rows, featureName, translate } = input;
7
32
  const vm = useMemo(() => computeListViewModel({ screen, entity, rows, translate, featureName }), [screen, entity, rows, translate, featureName]);
@@ -25,14 +50,24 @@ export function useRenderListColumns(input) {
25
50
  });
26
51
  }, []);
27
52
  const resolveReferenceLink = useReferenceLinkResolver();
28
- const enrichedColumns = useMemo(() => enrichReferenceColumns({
53
+ const resolveScreenLink = useScreenLinkResolver();
54
+ const enrichedColumns = useMemo(() => withScreenLinks(enrichReferenceColumns({
29
55
  columns: vm.columns,
30
56
  referenceColumns,
31
57
  referenceLookups,
32
58
  resolveReferenceLink,
33
59
  featureName,
34
60
  translate,
35
- }), [vm.columns, referenceColumns, referenceLookups, resolveReferenceLink, featureName, translate]);
61
+ }), screen, resolveScreenLink), [
62
+ vm.columns,
63
+ referenceColumns,
64
+ referenceLookups,
65
+ resolveReferenceLink,
66
+ resolveScreenLink,
67
+ screen,
68
+ featureName,
69
+ translate,
70
+ ]);
36
71
  const enrichedVm = useMemo(() => ({
37
72
  ...vm,
38
73
  columns: enrichedColumns,
@@ -224,6 +224,14 @@ export function createFormController(options) {
224
224
  };
225
225
  invalidate();
226
226
  }
227
+ function runSeedField(field, value) {
228
+ // skip: touched field keeps the user's input, a seed must not overwrite it
229
+ if (!Object.is(values[field], initial[field]))
230
+ return;
231
+ initial = { ...initial, [field]: value };
232
+ values = { ...values, [field]: value };
233
+ invalidate();
234
+ }
227
235
  // Stale-submit-safe rebase. Difference to runRebase: the baseline is
228
236
  // taken from the values that were actually SENT to the server, not
229
237
  // from `values` at the moment the server replied. If the user typed
@@ -304,6 +312,7 @@ export function createFormController(options) {
304
312
  },
305
313
  rebase: runRebase,
306
314
  rebaseFields: runRebaseFields,
315
+ seedField: runSeedField,
307
316
  setCtx(nextCtx) {
308
317
  // Cast back to TCtx: setCtx's public signature takes unknown so
309
318
  // callers don't need to plumb generics. If conditions depend on ctx
@@ -34,6 +34,7 @@ export type FormController<TValues extends FormValues> = {
34
34
  reset(): void;
35
35
  rebase(): void;
36
36
  rebaseFields(fields: readonly string[]): void;
37
+ seedField(field: string, value: unknown): void;
37
38
  setCtx(ctx: unknown): void;
38
39
  submit<TData = unknown>(): Promise<SubmitResult<TData>>;
39
40
  };
@@ -9,4 +9,4 @@ export { type HtmlValue, html, RawHtml, raw } from "./html-template.ts";
9
9
  export { currencyDecimals, formatMoney, resolveSafeMoneyLocale } from "./money.ts";
10
10
  export declare function applyFormatSpec(spec: {
11
11
  format: string;
12
- } & Record<string, unknown>, value: unknown, translate?: (key: string) => string): string;
12
+ } & Record<string, unknown>, value: unknown, translate?: (key: string, params?: Readonly<Record<string, string | number>>) => string): string;
@@ -246,6 +246,28 @@ function formatEnumOptionCell(spec, value, translate) {
246
246
  // multiSelect-style columns hold an array of option values
247
247
  return Array.isArray(value) ? value.map(labelFor).join(", ") : labelFor(value);
248
248
  }
249
+ function isRecordValue(value) {
250
+ return typeof value === "object" && value !== null && !Array.isArray(value);
251
+ }
252
+ // Only string and number params: money/date params need the dashboard formats.
253
+ function textParams(params) {
254
+ if (!isRecordValue(params))
255
+ return undefined;
256
+ const out = {};
257
+ for (const [name, param] of Object.entries(params)) {
258
+ if (typeof param === "string" || typeof param === "number")
259
+ out[name] = param;
260
+ }
261
+ return out;
262
+ }
263
+ function formatI18nTextCell(_spec, value, translate) {
264
+ if (typeof value === "string")
265
+ return translate === undefined ? value : translate(value);
266
+ if (!isRecordValue(value) || typeof value["i18nKey"] !== "string")
267
+ return String(value);
268
+ const key = value["i18nKey"];
269
+ return translate === undefined ? key : translate(key, textParams(value["i18nParams"]));
270
+ }
249
271
  // Thumbnails and file links are drawn by the web list cell; this is the
250
272
  // text fallback for every other surface.
251
273
  function formatFileCell(_spec, value) {
@@ -265,6 +287,7 @@ const CELL_FORMATTERS = {
265
287
  unit: formatUnitSpecCell,
266
288
  priority: formatPriorityCell,
267
289
  enumOption: formatEnumOptionCell,
290
+ i18nText: formatI18nTextCell,
268
291
  image: formatFileCell,
269
292
  file: formatFileCell,
270
293
  json: formatJsonCell,
@@ -0,0 +1,29 @@
1
+ import type { NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { DashboardStatTone } from "@cosmicdrift/kumiko-types/screen";
3
+ import type { ReactNode } from "react";
4
+ /** Compact label/value tile (record-detail metrics band). `testId` is the
5
+ * tile's own id — the impl derives `${testId}-label`/`${testId}-value` for
6
+ * the two rendered nodes, so a caller only ever needs to know the base id. */
7
+ export type MetricProps = {
8
+ readonly label: string;
9
+ readonly value: string;
10
+ readonly testId?: string;
11
+ /** Click handler — set when the metric declares a `navigate` target
12
+ * (see `MetricSpec`). Omitted for a plain, non-interactive metric. */
13
+ readonly onPress?: () => void;
14
+ /** Draw as a KPI tile (the dashboard StatCard) instead of a band entry. */
15
+ readonly tile?: boolean;
16
+ readonly tone?: DashboardStatTone;
17
+ readonly icon?: NavIconKey;
18
+ };
19
+ /** Band above the tabs of a record detail: optional subtitle line plus the
20
+ * metrics as a definition list. `children` are `Metric` tiles. */
21
+ export type MetricBandProps = {
22
+ /** Free-form block rendered unstyled as the first full-width row of the band. */
23
+ readonly lead?: ReactNode;
24
+ readonly subtitle?: ReactNode;
25
+ readonly children: ReactNode;
26
+ /** The children are KPI tiles, laid out as a tile grid instead of a definition list. */
27
+ readonly tiles?: boolean;
28
+ readonly testId?: string;
29
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -5,6 +5,7 @@ import type { DataTableBulk } from "./data-table-bulk-types.ts";
5
5
  import type { DataTableDateRangeFacet, DataTableFacet } from "./data-table-facet-types.ts";
6
6
  import type { DataTableTableBlock } from "./data-table-view-types.ts";
7
7
  import type { FieldIssue, ListColumnViewModel, ListRowViewModel, RuntimeRenderer } from "./headless/index.ts";
8
+ import type { MetricBandProps, MetricProps } from "./metric-types.ts";
8
9
  export type { RuntimeRenderer };
9
10
  /** Escape hatch for `data-*` test hooks (e.g. an E2E selector) that don't
10
11
  * warrant a dedicated typed prop. Web merges these onto the rendered
@@ -652,6 +653,7 @@ export declare function shouldRenderActionsIconOnly(actions: readonly {
652
653
  }[]): boolean;
653
654
  export type { BulkRowOutcome, DataTableBulk, DataTableBulkAction, } from "./data-table-bulk-types.ts";
654
655
  export type { DataTableDateRangeFacet, DataTableFacet, DataTableFacetChip, } from "./data-table-facet-types.ts";
656
+ export type { MetricBandProps, MetricProps } from "./metric-types.ts";
655
657
  export type DataTableProps = {
656
658
  readonly columns: readonly ListColumnViewModel[];
657
659
  readonly rows: readonly ListRowViewModel[];
@@ -1340,17 +1342,6 @@ export type StatusBadgeProps = {
1340
1342
  };
1341
1343
  export declare function statusToneForOptionTone(tone: SelectOptionTone): StatusTone | undefined;
1342
1344
  export declare function statusToneForValue(value: string): StatusTone | undefined;
1343
- /** Compact label/value tile (record-detail metrics band). `testId` is the
1344
- * tile's own id — the impl derives `${testId}-label`/`${testId}-value` for
1345
- * the two rendered nodes, so a caller only ever needs to know the base id. */
1346
- export type MetricProps = {
1347
- readonly label: string;
1348
- readonly value: string;
1349
- readonly testId?: string;
1350
- /** Click handler — set when the metric declares a `navigate` target
1351
- * (see `MetricSpec`). Omitted for a plain, non-interactive metric. */
1352
- readonly onPress?: () => void;
1353
- };
1354
1345
  /** Structured JSON display (audit payload/metadata, job logs, unsupported
1355
1346
  * jsonb/embedded field fallback). Takes the raw value, not a pre-stringified
1356
1347
  * one — the implementation owns `JSON.stringify` (and must not throw on
@@ -1369,15 +1360,6 @@ export type FileValueListProps = {
1369
1360
  readonly previewOpenByDefault?: boolean;
1370
1361
  readonly testId?: string;
1371
1362
  };
1372
- /** Band above the tabs of a record detail: optional subtitle line plus the
1373
- * metrics as a definition list. `children` are `Metric` tiles. */
1374
- export type MetricBandProps = {
1375
- /** Free-form block rendered unstyled as the first full-width row of the band. */
1376
- readonly lead?: ReactNode;
1377
- readonly subtitle?: ReactNode;
1378
- readonly children: ReactNode;
1379
- readonly testId?: string;
1380
- };
1381
1363
  /** Page title area rendered by the app shell's header instead of inside the
1382
1364
  * screen: `title` replaces the last breadcrumb, `status` sits right after it,
1383
1365
  * `actions` at the right edge. Renders nothing itself; without a shell slot
@@ -13,7 +13,7 @@ import { FeedList } from "../widgets/feed-list.js";
13
13
  import { ModeSwitch } from "../widgets/mode-switch.js";
14
14
  import { ProgressList } from "../widgets/progress-list.js";
15
15
  import { SectionCard } from "../widgets/section-card.js";
16
- import { StatCard, StatStripCell } from "../widgets/stat.js";
16
+ import { STAT_CARD_GRID_CLASSES, StatCard, StatStripCell, } from "../widgets/stat.js";
17
17
  import { EmptyState, LoadingState } from "../widgets/states.js";
18
18
  import { DAY_MS, HOUR_MS, PanelError, PanelShell, panelPayload, QueryPanel, useDashboardFormats, useDashboardQuery, useReportPanelLoaded, } from "./dashboard-shared.js";
19
19
  import { isRecord, resolveDashboardText } from "./dashboard-text.js";
@@ -107,7 +107,7 @@ export function StatGroupPanelBody({ panel, label, screenId, screenParams, trans
107
107
  if (label === undefined) {
108
108
  return (_jsx("div", { "data-testid": testId, className: "grid grid-cols-2 gap-y-2 rounded-lg border border-border bg-card py-1 lg:auto-cols-fr lg:grid-flow-col lg:divide-x lg:divide-border-row", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "strip" }, stat.id))) }));
109
109
  }
110
- return (_jsx(SectionCard, { title: label, ...(panel.subtitle !== undefined && { subtitle: translate(panel.subtitle) }), testId: testId, children: _jsx("section", { className: "grid grid-cols-[repeat(auto-fit,minmax(min(12rem,100%),1fr))] gap-3", children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
110
+ return (_jsx(SectionCard, { title: label, ...(panel.subtitle !== undefined && { subtitle: translate(panel.subtitle) }), testId: testId, children: _jsx("section", { className: STAT_CARD_GRID_CLASSES, children: panel.stats.map((stat) => (_jsx(StatPanelBody, { panel: stat, label: translate(stat.label), screenId: screenId, screenParams: screenParams, translate: translate, variant: "card" }, stat.id))) }) }));
111
111
  }
112
112
  function markerKindColor(kind) {
113
113
  if (kind === undefined)
@@ -19,7 +19,7 @@ import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header
19
19
  import { buildNavRegistrySliceForApp, lastSegment } from "./nav-tree.js";
20
20
  import { usePageHeaderSlot } from "./page-header-slot.js";
21
21
  import { resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
22
- import { UNIFORM_BAR_CONTROLS_CLASSES } from "./uniform-bar-controls.js";
22
+ import { SHELL_HEADER_CONTROLS_CLASSES } from "./uniform-bar-controls.js";
23
23
  export function ShellHeader({ schema, user, headerActions, }) {
24
24
  const nav = useNav();
25
25
  const t = useTranslation();
@@ -69,7 +69,7 @@ export function ShellHeader({ schema, user, headerActions, }) {
69
69
  e.preventDefault();
70
70
  nav.navigate({ screenId });
71
71
  }, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", title: titleOverride ?? crumb.label, className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
72
- }) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(UNIFORM_BAR_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
72
+ }) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(SHELL_HEADER_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
73
73
  }
74
74
  // A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque
75
75
  // nodes with their own popovers, and the panel stays mounted while closed so their
@@ -1 +1,2 @@
1
1
  export declare const UNIFORM_BAR_CONTROLS_CLASSES = "[&_[data-slot=button]:not([data-size^=icon])]:h-9 [&_[data-slot=input]]:h-9";
2
+ export declare const SHELL_HEADER_CONTROLS_CLASSES = "[&_[data-slot=button]:not([data-size^=icon])]:h-8 [&_[data-slot=input]]:h-8";
@@ -2,3 +2,6 @@
2
2
  // primitive asked for: the descendant selector outranks the button's own
3
3
  // size utilities. Icon-only buttons keep their square size.
4
4
  export const UNIFORM_BAR_CONTROLS_CLASSES = "[&_[data-slot=button]:not([data-size^=icon])]:h-9 [&_[data-slot=input]]:h-9";
5
+ // The shell header's own controls (workspace and tenant switcher, language, theme) are h-8,
6
+ // so screen actions portalled next to them get h-8 too instead of the toolbar's h-9.
7
+ export const SHELL_HEADER_CONTROLS_CLASSES = "[&_[data-slot=button]:not([data-size^=icon])]:h-8 [&_[data-slot=input]]:h-8";
@@ -1,4 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
2
3
  import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-types/kms-adapter-types";
3
4
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
4
5
  import { Check } from "lucide-react";
@@ -217,6 +218,22 @@ function selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBa
217
218
  // dashboard-01 pattern: outline badge + muted instead of a filled secondary.
218
219
  return (_jsx(Badge, { variant: "outline", className: "px-1.5 text-muted-foreground", children: label }));
219
220
  }
221
+ function isSelectOptionTone(value) {
222
+ return SELECT_OPTION_TONES.some((tone) => tone === value);
223
+ }
224
+ // enumOption's `tones` arrive with the untyped spec, so each entry is checked here.
225
+ function enumOptionTones(spec) {
226
+ const tones = spec["tones"];
227
+ if (spec["format"] !== "enumOption" || typeof tones !== "object" || tones === null) {
228
+ return undefined;
229
+ }
230
+ const out = {};
231
+ for (const [option, tone] of Object.entries(tones)) {
232
+ if (isSelectOptionTone(tone))
233
+ out[option] = tone;
234
+ }
235
+ return out;
236
+ }
220
237
  // A multi reference may list the same id twice; the occurrence count keeps React keys unique.
221
238
  function uniqueLinkItemKeys(items) {
222
239
  const seen = new Map();
@@ -303,7 +320,12 @@ export function DataTableCell({ value, row, field, type, renderer, optionLabels,
303
320
  }
304
321
  if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
305
322
  const spec = renderer;
306
- return applyFormatSpec({ ...spec, locale: spec.locale ?? locale }, value, translate);
323
+ const text = applyFormatSpec({ ...spec, locale: spec.locale ?? locale }, value, translate);
324
+ const tones = enumOptionTones(spec);
325
+ if (tones !== undefined && typeof value === "string") {
326
+ return selectPill(value, { [value]: text }, tones, locale, RegisteredStatusBadge);
327
+ }
328
+ return link === undefined ? text : _jsx(CellTextLink, { link: link, children: text });
307
329
  }
308
330
  if (typeof renderer === "function") {
309
331
  if (linkItems !== undefined) {
@@ -342,5 +364,6 @@ export function DataTableCell({ value, row, field, type, renderer, optionLabels,
342
364
  if (type === "multiSelect" && Array.isArray(value) && value.length > 0) {
343
365
  return (_jsx("span", { className: "flex flex-wrap gap-1", children: value.map((entry) => (_jsx(Fragment, { children: selectPill(entry, optionLabels, optionTones, locale, RegisteredStatusBadge) }, String(entry)))) }));
344
366
  }
345
- return defaultCellRender(value, type, optionLabels, locale, field, grouping);
367
+ const text = defaultCellRender(value, type, optionLabels, locale, field, grouping);
368
+ return link === undefined ? text : _jsx(CellTextLink, { link: link, children: text });
346
369
  }
@@ -33,5 +33,5 @@ export function DefaultDataTable(props) {
33
33
  // (that lives in the shell's breadcrumb). The toolbar is NOT sticky: lists
34
34
  // typically scroll with the page container, not internally. Sticky would
35
35
  // compete with the topbar.
36
- return (_jsxs("div", { className: wrapperClassName(model), children: [hasToolbar && renderToolbar(model, facetCluster), renderToolbarDescription(model, hasToolbar), renderTableContent(model)] }));
36
+ return (_jsxs("div", { className: wrapperClassName(model), children: [!model.fillsHeight && renderToolbarDescription(model, hasToolbar), hasToolbar && renderToolbar(model, facetCluster), model.fillsHeight && renderToolbarDescription(model, hasToolbar), renderTableContent(model)] }));
37
37
  }
@@ -1,5 +1,5 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { MetricBandProps, MetricProps } from "../../index.ts";
3
3
  export declare const InsideScreenFormColumnContext: import("react").Context<boolean>;
4
- export declare function DefaultMetricBand({ lead, subtitle, children, testId, }: MetricBandProps): ReactNode;
5
- export declare function DefaultMetric({ label, value, testId, onPress }: MetricProps): ReactNode;
4
+ export declare function DefaultMetricBand({ lead, subtitle, children, testId, tiles, }: MetricBandProps): ReactNode;
5
+ export declare function DefaultMetric(props: MetricProps): ReactNode;
@@ -1,18 +1,27 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createContext, useContext } from "react";
3
+ import { Icon } from "../icons.js";
3
4
  import { cn } from "../lib/cn.js";
5
+ import { STAT_CARD_GRID_CLASSES, StatCard } from "../widgets/stat.js";
4
6
  const InsideMetricBandContext = createContext(false);
5
7
  // The screen-form column already insets and spaces its children; a band in its
6
8
  // header region must not add its own padding on top. DefaultCard resets this
7
9
  // to false because a card body is a different inset surface.
8
10
  export const InsideScreenFormColumnContext = createContext(false);
9
- // Column counts follow the board: 2 below md, 3 below lg, 5 from lg.
10
- export function DefaultMetricBand({ lead, subtitle, children, testId, }) {
11
+ // Band column counts follow the board: 2 below md, 3 below lg, 5 from lg.
12
+ export function DefaultMetricBand({ lead, subtitle, children, testId, tiles, }) {
11
13
  const insideScreenFormColumn = useContext(InsideScreenFormColumnContext);
12
- return (_jsxs("div", { className: cn("flex shrink-0 flex-col gap-3", !insideScreenFormColumn && "px-6 pb-4 pt-3.5"), "data-testid": testId, children: [lead !== undefined && _jsx("div", { "data-kumiko-layout": "metric-band-lead", children: lead }), subtitle !== undefined && (_jsx("div", { className: "flex flex-wrap items-center gap-x-2 text-[13px] text-foreground-secondary [&_a]:text-primary", children: subtitle })), _jsx(InsideMetricBandContext.Provider, { value: true, children: _jsx("dl", { className: "m-0 grid grid-cols-2 gap-6 md:grid-cols-3 lg:grid-cols-5", children: children }) })] }));
14
+ return (_jsxs("div", { className: cn("flex shrink-0 flex-col gap-3", !insideScreenFormColumn && "px-6 pb-4 pt-3.5"), "data-testid": testId, children: [lead !== undefined && _jsx("div", { "data-kumiko-layout": "metric-band-lead", children: lead }), subtitle !== undefined && (_jsx("div", { className: "flex flex-wrap items-center gap-x-2 text-[13px] text-foreground-secondary [&_a]:text-primary", children: subtitle })), tiles === true ? (_jsx("div", { className: STAT_CARD_GRID_CLASSES, children: children })) : (_jsx(InsideMetricBandContext.Provider, { value: true, children: _jsx("dl", { className: "m-0 grid grid-cols-2 gap-6 md:grid-cols-3 lg:grid-cols-5", children: children }) }))] }));
13
15
  }
14
- export function DefaultMetric({ label, value, testId, onPress }) {
16
+ function MetricTile({ label, value, testId, onPress, tone, icon }) {
17
+ const card = (_jsx(StatCard, { label: label, value: value, ...(icon !== undefined && { icon: _jsx(Icon, { name: icon, className: "size-4" }) }), ...(tone !== undefined && { tone }), ...(testId !== undefined && { testId }) }));
18
+ return onPress !== undefined ? (_jsx("button", { type: "button", className: "text-left", onClick: onPress, children: card })) : (card);
19
+ }
20
+ export function DefaultMetric(props) {
15
21
  const insideBand = useContext(InsideMetricBandContext);
22
+ if (props.tile === true)
23
+ return _jsx(MetricTile, { ...props });
24
+ const { label, value, testId, onPress } = props;
16
25
  const item = (_jsxs("div", { "data-testid": testId, className: cn("flex min-w-0 flex-col gap-0.5", onPress !== undefined && "cursor-pointer"), ...(onPress !== undefined && { onClick: onPress }), children: [_jsx("dt", { className: "text-xs text-muted-foreground", "data-testid": testId !== undefined ? `${testId}-label` : undefined, children: label }), _jsx("dd", { className: "m-0 text-base font-medium tabular-nums text-foreground", "data-testid": testId !== undefined ? `${testId}-value` : undefined, children: onPress !== undefined ? (_jsx("button", { type: "button", className: "text-left hover:underline", children: value })) : (value) })] }));
17
26
  return insideBand ? item : _jsx("dl", { className: "m-0", children: item });
18
27
  }
@@ -1,6 +1,7 @@
1
1
  import type { DashboardStatTone } from "@cosmicdrift/kumiko-types/screen";
2
2
  import type { ReactNode } from "react";
3
3
  export type StatTone = DashboardStatTone;
4
+ export declare const STAT_CARD_GRID_CLASSES = "grid grid-cols-[repeat(auto-fit,minmax(min(12rem,100%),1fr))] gap-3";
4
5
  export type StatDelta = {
5
6
  readonly value: string;
6
7
  readonly direction: "up" | "down";
@@ -1,6 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { usePrimitives } from "../../index.js";
3
3
  import { cn } from "../lib/cn.js";
4
+ // One grid for every row of StatCards (dashboard stat groups, detail metric tiles), so the
5
+ // cards fill the row at any count instead of leaving fixed empty columns.
6
+ export const STAT_CARD_GRID_CLASSES = "grid grid-cols-[repeat(auto-fit,minmax(min(12rem,100%),1fr))] gap-3";
4
7
  const TONE_CHIP = {
5
8
  default: "bg-muted text-foreground",
6
9
  positive: "bg-primary/10 text-primary",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.390.1",
3
+ "version": "0.391.0",
4
4
  "description": "React renderer for Kumiko screens. The root export holds the platform-agnostic logic (primitives contract, hooks, KumikoScreen, navigation and SSE abstractions); ./headless the DOM-free UI logic, ./live the HTTP dispatcher, ./web the browser bindings (HTML primitives, history navigation, EventSource live events, createKumikoApp).",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,8 +48,8 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-framework": "0.390.1",
52
- "@cosmicdrift/kumiko-types": "0.390.1",
51
+ "@cosmicdrift/kumiko-framework": "0.391.0",
52
+ "@cosmicdrift/kumiko-types": "0.391.0",
53
53
  "@radix-ui/react-dialog": "^1.1.15",
54
54
  "@radix-ui/react-dropdown-menu": "^2.1.16",
55
55
  "@radix-ui/react-label": "^2.1.8",
@@ -92,8 +92,8 @@
92
92
  }
93
93
  },
94
94
  "devDependencies": {
95
- "@cosmicdrift/kumiko-locale-de": "0.390.1",
96
- "@cosmicdrift/kumiko-testing": "0.390.1",
95
+ "@cosmicdrift/kumiko-locale-de": "0.391.0",
96
+ "@cosmicdrift/kumiko-testing": "0.391.0",
97
97
  "@tailwindcss/cli": "^4.3.0",
98
98
  "@testing-library/react": "^16.3.2",
99
99
  "@testing-library/user-event": "^14.6.1",
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.391.0",
4
+ "type": "improvement",
5
+ "title": "Translated text cells, toned status pills, screen links in rows and live detail screens",
6
+ "detail": "An i18nText cell renders the text in the user's language. An enumOption column with tones draws the translated label as a status pill. A column with linkScreenField links the cell to the screen the row names, but only when the user may open that screen. A projectionDetail with live: true refetches the record and its relatedList sections on entity events. With metricsStyle \"tiles\" its metrics render as the dashboard KPI tiles (StatCard), the icon chip colored by the metric's tone; like on dashboards a value of 0 keeps the neutral tone."
7
+ },
8
+ {
9
+ "version": "0.391.0",
10
+ "type": "fix",
11
+ "title": "No repeated screen title on detail screens, header actions as high as the header controls, embedded table description first",
12
+ "detail": "A projectionDetail without a record title no longer repeats the screen title above its sections when the shell header already shows it. Screen actions in the shell header are now as high as the header's own controls (h-8) instead of the toolbar height. An embedded table (one that does not fill the screen height) shows its description above its toolbar, right under the section title."
13
+ },
14
+ {
15
+ "version": "0.391.0",
16
+ "type": "improvement",
17
+ "title": "actionForm optionsQuery selects seed the row marked default",
18
+ "detail": "A row of an optionsQuery result can carry default: true. In an actionForm the first such row fills an empty field as a baseline value, like a prefill, so the form stays unchanged and leaving it asks nothing; set submitPrefilled to submit the suggestion unchanged. When a sibling field changes the payload and the old value drops out of the new rows, the new default replaces it as a normal change. configEdit never pre-selects, and a set value is never replaced."
19
+ },
2
20
  {
3
21
  "version": "0.390.0",
4
22
  "type": "fix",