@cosmicdrift/kumiko-renderer 0.233.0 → 0.234.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ // ToolbarActionView collapses a group of >2 icon-carrying toolbar actions to
2
+ // icon-only buttons (`shouldRenderActionsIconOnly`). The button then has no
3
+ // accessible name from its children, so the label has to move to `ariaLabel`
4
+ // and must NOT stay in the children — otherwise the text renders inside a
5
+ // button sized for an icon.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type {
9
+ EntityDefinition,
10
+ EntityListScreenDefinition,
11
+ } from "@cosmicdrift/kumiko-framework/ui-types";
12
+ import { render, screen as rtlScreen } from "@testing-library/react";
13
+ import type { ComponentType, ReactNode } from "react";
14
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
15
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
16
+ import {
17
+ type ButtonProps,
18
+ type CorePrimitives,
19
+ type DataTableProps,
20
+ PrimitivesProvider,
21
+ } from "../../primitives";
22
+ import { RenderList, type ToolbarActionButton } from "../render-list";
23
+
24
+ const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId, ariaLabel, size }) => (
25
+ <button
26
+ type="button"
27
+ data-testid={testId}
28
+ data-size={size ?? "md"}
29
+ aria-label={ariaLabel}
30
+ onClick={() => {
31
+ void onClick?.();
32
+ }}
33
+ >
34
+ {children}
35
+ </button>
36
+ );
37
+
38
+ const toolbarOnlyDataTable: ComponentType<DataTableProps> = ({ toolbarEnd }) => (
39
+ <div>{toolbarEnd}</div>
40
+ );
41
+
42
+ const noop = (): ReactNode => null;
43
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
44
+
45
+ const testPrimitives: CorePrimitives = {
46
+ Button: TestButton,
47
+ Banner: passChildren,
48
+ Field: passChildren,
49
+ Input: noop,
50
+ DataTable: toolbarOnlyDataTable,
51
+ Form: passChildren,
52
+ Section: passChildren,
53
+ Card: passChildren,
54
+ Grid: passChildren,
55
+ GridCell: passChildren,
56
+ Text: passChildren,
57
+ Heading: noop,
58
+ Dialog: noop,
59
+ Modal: noop,
60
+ Lightbox: noop,
61
+ ConfigSourceBadge: noop,
62
+ ConfigCascadeView: noop,
63
+ Link: noop,
64
+ };
65
+
66
+ const entity: EntityDefinition = {
67
+ fields: {
68
+ name: { type: "text", maxLength: 50, required: false, searchable: false, sortable: false },
69
+ },
70
+ };
71
+
72
+ const listScreen: EntityListScreenDefinition = {
73
+ id: "widget-list",
74
+ type: "entityList",
75
+ entity: "widget",
76
+ columns: ["name"],
77
+ };
78
+
79
+ function toolbarAction(id: string, label: string, withIcon: boolean): ToolbarActionButton {
80
+ return {
81
+ id,
82
+ label,
83
+ onTrigger: () => {},
84
+ ...(withIcon && { icon: "archive" as const }),
85
+ };
86
+ }
87
+
88
+ function renderToolbar(actions: readonly ToolbarActionButton[]): void {
89
+ render(
90
+ <LocaleProvider
91
+ resolver={createStaticLocaleResolver({ locale: "en" })}
92
+ fallbackBundles={[kumikoDefaultTranslations]}
93
+ >
94
+ <PrimitivesProvider value={testPrimitives}>
95
+ <RenderList
96
+ screen={listScreen}
97
+ entity={entity}
98
+ rows={[]}
99
+ featureName="toolbar-fixture"
100
+ toolbarActions={actions}
101
+ />
102
+ </PrimitivesProvider>
103
+ </LocaleProvider>,
104
+ );
105
+ }
106
+
107
+ describe("RenderList toolbar actions collapse to icon-only", () => {
108
+ test("three icon actions render buttons with no visible text but an aria-label", () => {
109
+ renderToolbar([
110
+ toolbarAction("sync", "Sync", true),
111
+ toolbarAction("export", "Export", true),
112
+ toolbarAction("archive", "Archive", true),
113
+ ]);
114
+
115
+ for (const [id, label] of [
116
+ ["sync", "Sync"],
117
+ ["export", "Export"],
118
+ ["archive", "Archive"],
119
+ ] as const) {
120
+ const button = rtlScreen.getByTestId(`render-list-toolbar-action-${id}`);
121
+ expect(button.getAttribute("data-size")).toBe("icon");
122
+ expect(button.getAttribute("aria-label")).toBe(label);
123
+ expect(button.textContent).toBe("");
124
+ }
125
+ });
126
+
127
+ test("two actions stay on text buttons carrying the label as children", () => {
128
+ renderToolbar([toolbarAction("sync", "Sync", true), toolbarAction("export", "Export", true)]);
129
+
130
+ const button = rtlScreen.getByTestId("render-list-toolbar-action-sync");
131
+ expect(button.getAttribute("data-size")).toBe("md");
132
+ expect(button.textContent).toBe("Sync");
133
+ });
134
+
135
+ test("an icon-less member keeps the whole group on text buttons", () => {
136
+ renderToolbar([
137
+ toolbarAction("sync", "Sync", true),
138
+ toolbarAction("export", "Export", true),
139
+ toolbarAction("archive", "Archive", false),
140
+ ]);
141
+
142
+ const button = rtlScreen.getByTestId("render-list-toolbar-action-sync");
143
+ expect(button.getAttribute("data-size")).toBe("md");
144
+ expect(button.textContent).toBe("Sync");
145
+ });
146
+ });
@@ -93,26 +93,36 @@ export function RelatedListSection({
93
93
  }
94
94
  : undefined;
95
95
 
96
+ const content =
97
+ rowsQuery.loading && rowsQuery.data === null ? (
98
+ <Banner padded variant="loading" testId="related-list-loading">
99
+ Loading…
100
+ </Banner>
101
+ ) : rowsQuery.error ? (
102
+ <Banner padded variant="error" testId="related-list-error">
103
+ {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
104
+ </Banner>
105
+ ) : (
106
+ <RenderList
107
+ screen={listScreen}
108
+ entity={entity}
109
+ rows={rowsQuery.data?.rows ?? []}
110
+ featureName={featureName}
111
+ translate={effectiveTranslate}
112
+ {...(onRowClick !== undefined && { onRowClick })}
113
+ />
114
+ );
115
+
116
+ // hideTitle (tabs mode) → this section carries no header content of its
117
+ // own, and RenderList's DataTable already draws its own card frame — a
118
+ // Section wrapper here would only add a second, nested card (fw record-
119
+ // screen-type polish). Stacked (non-tabs) sections keep their Section
120
+ // card since they render a visible title.
121
+ if (hideTitle) return content;
122
+
96
123
  return (
97
- <Section title={hideTitle ? undefined : section.title} testId={`related-list-${section.title}`}>
98
- {rowsQuery.loading && rowsQuery.data === null ? (
99
- <Banner padded variant="loading" testId="related-list-loading">
100
- Loading…
101
- </Banner>
102
- ) : rowsQuery.error ? (
103
- <Banner padded variant="error" testId="related-list-error">
104
- {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
105
- </Banner>
106
- ) : (
107
- <RenderList
108
- screen={listScreen}
109
- entity={entity}
110
- rows={rowsQuery.data?.rows ?? []}
111
- featureName={featureName}
112
- translate={effectiveTranslate}
113
- {...(onRowClick !== undefined && { onRowClick })}
114
- />
115
- )}
124
+ <Section title={section.title} testId={`related-list-${section.title}`}>
125
+ {content}
116
126
  </Section>
117
127
  );
118
128
  }
@@ -9,11 +9,15 @@ import type { RenderEditAction } from "./render-edit-types";
9
9
  // to hook into like the built-in onDelete/onSubmit paths have).
10
10
  export function RenderEditActionButton({
11
11
  action,
12
+ iconOnly = false,
12
13
  Button,
13
14
  Dialog,
14
15
  onError,
15
16
  }: {
16
17
  readonly action: RenderEditAction;
18
+ /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
19
+ * effect when this action actually resolved an icon. */
20
+ readonly iconOnly?: boolean;
17
21
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
18
22
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
19
23
  readonly onError: (text: string | null) => void;
@@ -37,6 +41,7 @@ export function RenderEditActionButton({
37
41
  // Same rule as RowActionWriteHandler: "danger" forces a confirm even
38
42
  // without an explicit confirm key.
39
43
  const needsConfirm = action.confirm !== undefined || action.style === "danger";
44
+ const showIconOnly = iconOnly && action.icon !== undefined;
40
45
 
41
46
  return (
42
47
  <>
@@ -44,6 +49,8 @@ export function RenderEditActionButton({
44
49
  type="button"
45
50
  variant={variant}
46
51
  loading={busy}
52
+ {...(action.icon !== undefined && { icon: action.icon })}
53
+ {...(showIconOnly && { size: "icon" as const, ariaLabel: action.label })}
47
54
  onClick={() => {
48
55
  if (needsConfirm) {
49
56
  setConfirmOpen(true);
@@ -53,7 +60,7 @@ export function RenderEditActionButton({
53
60
  }}
54
61
  testId={`render-edit-action-${action.id}`}
55
62
  >
56
- {action.label}
63
+ {showIconOnly ? null : action.label}
57
64
  </Button>
58
65
  <Dialog
59
66
  open={confirmOpen}
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  EntityDefinition,
3
3
  EntityEditScreenDefinition,
4
+ IconKey,
4
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
5
6
  import type {
6
7
  FormSnapshot,
@@ -156,6 +157,10 @@ export type RenderEditAction = {
156
157
  readonly style?: "primary" | "secondary" | "danger";
157
158
  readonly confirm?: string;
158
159
  readonly confirmLabel?: string;
160
+ /** Resolved icon (author `RowAction.icon` or the id-derived default) —
161
+ * drives both the icon-left-of-text render and the icon-only collapse
162
+ * rule (see `shouldRenderActionsIconOnly`). */
163
+ readonly icon?: IconKey;
159
164
  };
160
165
 
161
166
  export type RenderEditChangeState<TValues extends FormValues> = {
@@ -36,7 +36,7 @@ import { useDraftStorage } from "../context/draft-storage-context";
36
36
  import { formatWhen } from "../format-when";
37
37
  import { useForm } from "../hooks/use-form";
38
38
  import { useTranslation } from "../i18n";
39
- import { usePrimitives } from "../primitives";
39
+ import { shouldRenderActionsIconOnly, usePrimitives } from "../primitives";
40
40
  import { RelatedListSection } from "./related-list-section";
41
41
  import {
42
42
  filterEditSections,
@@ -922,6 +922,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
922
922
  onCopyLink !== undefined ||
923
923
  (actions !== undefined && actions.length > 0) ||
924
924
  onCancel !== undefined;
925
+ // Teil-C collapse rule: driven by the custom `actions` group only — a
926
+ // resolved true also folds the Copy-Link button (same visual group in the
927
+ // footer), but never Delete/Cancel, which keep their text regardless.
928
+ const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
925
929
  const secondaryFormActions = (
926
930
  <>
927
931
  {onDelete !== undefined && (
@@ -939,21 +943,30 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
939
943
  {onCopyLink !== undefined && (
940
944
  <Button
941
945
  type="button"
942
- variant="link"
946
+ variant={iconOnlyMidActions ? "secondary" : "link"}
943
947
  icon={linkCopied ? "check" : "link"}
944
948
  testId="render-edit-copy-link"
949
+ {...(iconOnlyMidActions && {
950
+ size: "icon" as const,
951
+ ariaLabel: translate(
952
+ linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink",
953
+ ),
954
+ })}
945
955
  onClick={async () => {
946
956
  await onCopyLink();
947
957
  setLinkCopied(true);
948
958
  }}
949
959
  >
950
- {translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
960
+ {iconOnlyMidActions
961
+ ? null
962
+ : translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
951
963
  </Button>
952
964
  )}
953
965
  {actions?.map((action) => (
954
966
  <RenderEditActionButton
955
967
  key={action.id}
956
968
  action={action}
969
+ iconOnly={iconOnlyMidActions}
957
970
  Button={Button}
958
971
  Dialog={Dialog}
959
972
  onError={setActionError}
@@ -972,6 +985,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
972
985
  )}
973
986
  </>
974
987
  );
988
+ // Mirrors every branch inside formActions below — without this guard
989
+ // DefaultForm renders an empty footer strip (border + padding, no content)
990
+ // on read-only detail screens, since `actions` would otherwise always be
991
+ // a defined (if empty) fragment.
992
+ const hasFormActions =
993
+ (isWizard && currentStep > 0) ||
994
+ (isWizard && !isLastWizardStep) ||
995
+ ((isFormEditable || hasExtensionRegistrations) && (!isWizard || isLastWizardStep));
975
996
  const formActions = (
976
997
  <>
977
998
  {isWizard && currentStep > 0 && (
@@ -1039,7 +1060,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1039
1060
  {...(hideSectionTitles !== true && { title: formTitle })}
1040
1061
  {...(hideSectionTitles !== true &&
1041
1062
  formSubtitle !== undefined && { subtitle: formSubtitle })}
1042
- {...(hideActions !== true && { actions: formActions })}
1063
+ {...(hideActions !== true && hasFormActions && { actions: formActions })}
1043
1064
  {...(hideActions !== true &&
1044
1065
  hasSecondaryFormActions && { secondaryActions: secondaryFormActions })}
1045
1066
  testId="render-edit-form"
@@ -1206,6 +1227,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1206
1227
  key={sectionKey}
1207
1228
  {...(sectionTitle !== undefined && { title: sectionTitle })}
1208
1229
  {...(section.description !== undefined && { subtitle: section.description })}
1230
+ {...(section.icon !== undefined && { icon: section.icon })}
1209
1231
  testId={`section-${sectionKey}`}
1210
1232
  >
1211
1233
  <Grid columns={section.columns}>
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  type EntityEditScreenDefinition,
3
+ type FieldIconKey,
3
4
  type FieldRenderer,
4
5
  isFormatSpec,
5
6
  } from "@cosmicdrift/kumiko-framework/ui-types";
@@ -146,7 +147,6 @@ export function RenderField({
146
147
  {...(issues !== undefined && { issues })}
147
148
  {...(labelAppendix !== undefined && { labelAppendix })}
148
149
  {...(fieldAppendix !== undefined && { fieldAppendix })}
149
- {...(field.type === "boolean" && { layout: "inline" as const })}
150
150
  testId={`field-${field.field}`}
151
151
  >
152
152
  {control}
@@ -435,6 +435,44 @@ function isComplexFieldType(type: string): boolean {
435
435
  return type === "embedded" || type === "jsonb" || type === "files" || type === "images";
436
436
  }
437
437
 
438
+ // Mirrors ACTION_ICON_BY_ID (kumiko-screen.tsx) for fields: only names with
439
+ // an unambiguous matching FieldIconKey are listed here, everything else
440
+ // stays iconless rather than guessing.
441
+ const FIELD_ICON_BY_NAME: Readonly<Partial<Record<string, FieldIconKey>>> = {
442
+ email: "mail",
443
+ phone: "phone",
444
+ tel: "phone",
445
+ mobile: "phone",
446
+ url: "link",
447
+ website: "link",
448
+ link: "link",
449
+ password: "lock",
450
+ search: "search",
451
+ city: "map-pin",
452
+ address: "map-pin",
453
+ street: "map-pin",
454
+ user: "user",
455
+ owner: "user",
456
+ assignee: "user",
457
+ };
458
+
459
+ function kebabLastSegment(value: string): string {
460
+ const kebab = toKebab(value);
461
+ const lastDash = kebab.lastIndexOf("-");
462
+ return lastDash === -1 ? kebab : kebab.slice(lastDash + 1);
463
+ }
464
+
465
+ // Derives a prefix icon when the field declares none: field.icon wins, then
466
+ // the field name. Deliberately no type-based fallback — a hash in front of a
467
+ // number field restates what the field already shows and reads as noise.
468
+ function resolveFieldIcon(field: EditFieldViewModel): FieldIconKey | undefined {
469
+ if (field.icon !== undefined) return field.icon;
470
+ const byName =
471
+ FIELD_ICON_BY_NAME[field.field.toLowerCase()] ??
472
+ FIELD_ICON_BY_NAME[kebabLastSegment(field.field)];
473
+ return byName;
474
+ }
475
+
438
476
  // Dispatches field.type → Input-kind. Select threads options through
439
477
  // from the EditFieldViewModel (computeEditViewModel pulls them from
440
478
  // SelectFieldDef.options). Structural types without a widget (embedded,
@@ -478,6 +516,7 @@ function renderInput({
478
516
  // beyond what "number" already does (#1925).
479
517
  case "number":
480
518
  case "bigInt": {
519
+ const icon = resolveFieldIcon(field);
481
520
  const unit = resolveNumberUnit(field.unit, row);
482
521
  return (
483
522
  <Input
@@ -485,15 +524,16 @@ function renderInput({
485
524
  {...common}
486
525
  value={numberValue(field.value)}
487
526
  onChange={(v) => onChange(v)}
488
- {...(field.icon !== undefined && { icon: field.icon })}
527
+ {...(icon !== undefined && { icon })}
489
528
  {...(unit !== undefined && { unit })}
490
529
  />
491
530
  );
492
531
  }
493
- case "decimal":
532
+ case "decimal": {
494
533
  // step="any" disables the native stepMismatch constraint — without it
495
534
  // <input type="number"> defaults to step=1 and blocks form submit on
496
535
  // any fractional value via silent browser-native validation.
536
+ const icon = resolveFieldIcon(field);
497
537
  return (
498
538
  <Input
499
539
  kind="number"
@@ -501,9 +541,10 @@ function renderInput({
501
541
  value={numberValue(field.value)}
502
542
  onChange={(v) => onChange(v)}
503
543
  step="any"
504
- {...(field.icon !== undefined && { icon: field.icon })}
544
+ {...(icon !== undefined && { icon })}
505
545
  />
506
546
  );
547
+ }
507
548
  case "tz":
508
549
  return (
509
550
  <Input
@@ -683,13 +724,14 @@ function renderInput({
683
724
  />
684
725
  );
685
726
  }
727
+ const icon = resolveFieldIcon(field);
686
728
  return (
687
729
  <Input
688
730
  kind="text"
689
731
  {...common}
690
732
  value={stringValue(field.value)}
691
733
  onChange={(v) => onChange(v)}
692
- {...(field.icon !== undefined && { icon: field.icon })}
734
+ {...(icon !== undefined && { icon })}
693
735
  />
694
736
  );
695
737
  }
@@ -2,6 +2,7 @@ import type { EagerloadedRow } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type {
3
3
  EntityDefinition,
4
4
  EntityListScreenDefinition,
5
+ IconKey,
5
6
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
7
  import type {
7
8
  ListColumnViewModel,
@@ -16,7 +17,13 @@ import { extensionSectionName, useExtensionSectionComponent } from "../app/exten
16
17
  import type { ListSort } from "../hooks/use-list-url-state";
17
18
  import { type ReferenceLookupMap, useReferenceLookup } from "../hooks/use-reference-lookup";
18
19
  import { useTranslation } from "../i18n";
19
- import { type DataTableFacet, type DataTableRowAction, usePrimitives } from "../primitives";
20
+ import {
21
+ type DataTableFacet,
22
+ type DataTableRowAction,
23
+ type DataTableRowActionMode,
24
+ shouldRenderActionsIconOnly,
25
+ usePrimitives,
26
+ } from "../primitives";
20
27
 
21
28
  // RenderList — präsentationaler View für entityList-Screens.
22
29
  //
@@ -85,6 +92,10 @@ export type RenderListProps = {
85
92
  * EntityListScreenDefinition.rowActions: handler-QN → dispatcher-Call,
86
93
  * i18n-Keys → translated Strings). */
87
94
  readonly rowActions?: readonly DataTableRowAction[];
95
+ /** How the row-action column renders (see `DataTableProps.rowActionMode`).
96
+ * KumikoScreen derives it from the actions themselves; without it the
97
+ * DataTable falls back to its adaptive default. */
98
+ readonly rowActionMode?: DataTableRowActionMode;
88
99
  /** Toolbar-Aktionen im List-Header — Resolved-Form (KumikoScreen baut
89
100
  * das aus EntityListScreenDefinition.toolbarActions: navigate-target
90
101
  * → useNav, handler-QN → dispatcher-Call). RenderList rendert die
@@ -112,6 +123,9 @@ export type ToolbarActionButton = {
112
123
  readonly confirm?: string;
113
124
  readonly confirmLabel?: string;
114
125
  readonly onTrigger: () => Promise<void> | void;
126
+ /** Id-derived default icon (ACTION_ICON_BY_ID in kumiko-screen.tsx) —
127
+ * ToolbarAction has no author-declared icon field, unlike RowAction. */
128
+ readonly icon?: IconKey;
115
129
  };
116
130
 
117
131
  const SEARCH_DEBOUNCE_MS = 300;
@@ -138,6 +152,7 @@ export function RenderList(props: RenderListProps): ReactNode {
138
152
  loadingMore,
139
153
  hasMore,
140
154
  rowActions,
155
+ rowActionMode,
141
156
  toolbarActions,
142
157
  filterFacets,
143
158
  filterValues,
@@ -274,6 +289,7 @@ export function RenderList(props: RenderListProps): ReactNode {
274
289
  // "+ Neu" zuletzt weil das die häufigste/auffälligste CTA ist.
275
290
  const hasToolbarActions = toolbarActions !== undefined && toolbarActions.length > 0;
276
291
  const hasHeaderSlot = screen.slots?.header !== undefined;
292
+ const toolbarIconOnly = hasToolbarActions && shouldRenderActionsIconOnly(toolbarActions);
277
293
  const toolbarEnd =
278
294
  hasHeaderSlot || hasToolbarActions || onCreate !== undefined ? (
279
295
  <>
@@ -283,6 +299,7 @@ export function RenderList(props: RenderListProps): ReactNode {
283
299
  <ToolbarActionView
284
300
  key={a.id}
285
301
  action={a}
302
+ iconOnly={toolbarIconOnly}
286
303
  Button={Button}
287
304
  Dialog={Dialog}
288
305
  Banner={Banner}
@@ -350,6 +367,7 @@ export function RenderList(props: RenderListProps): ReactNode {
350
367
  {...(loadingMore !== undefined && { loadingMore })}
351
368
  {...(hasMore !== undefined && { hasMore })}
352
369
  {...(rowActions !== undefined && { rowActions })}
370
+ {...(rowActionMode !== undefined && { rowActionMode })}
353
371
  {...(filterFacets !== undefined && { filterFacets })}
354
372
  {...(filterValues !== undefined && { filterValues })}
355
373
  {...(onFilterChange !== undefined && { onFilterChange })}
@@ -427,11 +445,15 @@ function ReferenceLookupBridge({
427
445
  // öffnet sich vor dem Trigger wenn confirm/danger gesetzt.
428
446
  function ToolbarActionView({
429
447
  action,
448
+ iconOnly = false,
430
449
  Button,
431
450
  Dialog,
432
451
  Banner,
433
452
  }: {
434
453
  readonly action: ToolbarActionButton;
454
+ /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
455
+ * effect when this action actually resolved an icon. */
456
+ readonly iconOnly?: boolean;
435
457
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
436
458
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
437
459
  readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
@@ -459,12 +481,15 @@ function ToolbarActionView({
459
481
 
460
482
  const variant: "primary" | "secondary" | "danger" = action.style ?? "secondary";
461
483
  const needsConfirm = action.confirm !== undefined || action.style === "danger";
484
+ const showIconOnly = iconOnly && action.icon !== undefined;
462
485
 
463
486
  return (
464
487
  <>
465
488
  <Button
466
489
  variant={variant}
467
490
  loading={busy}
491
+ {...(action.icon !== undefined && { icon: action.icon })}
492
+ {...(showIconOnly && { size: "icon" as const, ariaLabel: action.label })}
468
493
  onClick={() => {
469
494
  if (needsConfirm) {
470
495
  setConfirmOpen(true);
@@ -474,7 +499,7 @@ function ToolbarActionView({
474
499
  }}
475
500
  testId={`render-list-toolbar-action-${action.id}`}
476
501
  >
477
- {action.label}
502
+ {showIconOnly ? null : action.label}
478
503
  </Button>
479
504
  <Dialog
480
505
  open={confirmOpen}
package/src/index.ts CHANGED
@@ -206,7 +206,12 @@ export type {
206
206
  TextProps,
207
207
  WizardStepGroupProps,
208
208
  } from "./primitives";
209
- export { PrimitivesProvider, usePrimitives } from "./primitives";
209
+ export {
210
+ PrimitivesProvider,
211
+ shouldRenderActionsIconOnly,
212
+ statusToneForValue,
213
+ usePrimitives,
214
+ } from "./primitives";
210
215
  export { sortByAccessor } from "./sort-by-accessor";
211
216
  export type { LiveEvent, LiveEventSubscriber, LiveEventsProviderProps } from "./sse/live-events";
212
217
  export { LiveEventsProvider, useLiveEvents } from "./sse/live-events";
@@ -35,7 +35,12 @@ import type {
35
35
  ConfigScope,
36
36
  ConfigValueSource,
37
37
  } from "@cosmicdrift/kumiko-framework/engine";
38
- import type { FieldIconKey, FormWidth, NavIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
38
+ import type {
39
+ FieldIconKey,
40
+ FormWidth,
41
+ IconKey,
42
+ NavIconKey,
43
+ } from "@cosmicdrift/kumiko-framework/ui-types";
39
44
  import type {
40
45
  FieldIssue,
41
46
  ListColumnViewModel,
@@ -492,8 +497,22 @@ export type DataTableRowAction = {
492
497
  readonly onTrigger: (row: ListRowViewModel) => Promise<void> | void;
493
498
  /** Conditional Visibility pro Row (z.B. "Start" nur wenn status==="scheduled"). */
494
499
  readonly isVisible?: (row: ListRowViewModel) => boolean;
500
+ /** Resolved icon (author `RowAction.icon` or the id-derived default) —
501
+ * drives both the icon-left-of-text render and the icon-only collapse
502
+ * rule (see `shouldRenderActionsIconOnly`). */
503
+ readonly icon?: IconKey;
495
504
  };
496
505
 
506
+ /** Teil-C action-icon collapse rule: a group of more than two actions where
507
+ * every member carries an icon renders icon-only instead of wall-to-wall
508
+ * text buttons; any icon-less member keeps the whole group on text so it
509
+ * doesn't fall apart visually mid-group. */
510
+ export function shouldRenderActionsIconOnly(
511
+ actions: readonly { readonly icon?: IconKey }[],
512
+ ): boolean {
513
+ return actions.length > 2 && actions.every((a) => a.icon !== undefined);
514
+ }
515
+
497
516
  // Ein Faceted-Filter-Slot in der Toolbar: ein Outline-Dropdown-Button
498
517
  // (wie shadcns "Columns"-Toggle) mit Multi-Select-Checkboxen. KumikoScreen
499
518
  // baut das aus den filterable select/boolean-Feldern des Entity.
@@ -747,6 +766,10 @@ export type SectionProps = {
747
766
  * Default "default" (normal card border). */
748
767
  readonly variant?: "default" | "destructive";
749
768
  readonly testId?: string;
769
+ /** Rendered left of the title, `text-muted-foreground`, same optical
770
+ * size as the title. No effect without a `title` — an icon alone would
771
+ * have nothing to sit next to. */
772
+ readonly icon?: IconKey;
750
773
  };
751
774
 
752
775
  /** Columns-basiertes Layout. Web: CSS grid, Native: Flex-Wrap mit
@@ -988,6 +1011,45 @@ export type StatusBadgeProps = {
988
1011
  readonly testId?: string;
989
1012
  };
990
1013
 
1014
+ // Status value -> StatusTone heuristic, shared by every surface that shows a
1015
+ // raw status value without a tone of its own (projectionDetail header badge,
1016
+ // list cells). Covers the common status vocabularies; unknown values stay
1017
+ // undefined so the caller keeps its own neutral default.
1018
+ const STATUS_TONE_BY_VALUE: Readonly<Record<string, StatusTone>> = {
1019
+ ok: "ok",
1020
+ active: "ok",
1021
+ done: "ok",
1022
+ complete: "ok",
1023
+ completed: "ok",
1024
+ paid: "ok",
1025
+ approved: "ok",
1026
+ published: "ok",
1027
+ success: "ok",
1028
+ pending: "warn",
1029
+ processing: "warn",
1030
+ review: "warn",
1031
+ "in-review": "warn",
1032
+ waiting: "warn",
1033
+ open: "warn",
1034
+ draft: "warn",
1035
+ failed: "bad",
1036
+ error: "bad",
1037
+ overdue: "bad",
1038
+ rejected: "bad",
1039
+ blocked: "bad",
1040
+ critical: "bad",
1041
+ };
1042
+
1043
+ export function statusToneForValue(value: string): StatusTone | undefined {
1044
+ const slug = value
1045
+ .trim()
1046
+ .toLowerCase()
1047
+ .replace(/[\s_]+/g, "-");
1048
+ // Own-key check: the value comes from row data, and a plain object lookup
1049
+ // would hand back `Object.prototype.constructor` & friends as a "tone".
1050
+ return Object.hasOwn(STATUS_TONE_BY_VALUE, slug) ? STATUS_TONE_BY_VALUE[slug] : undefined;
1051
+ }
1052
+
991
1053
  /** Compact label/value tile (record-detail metrics band). `testId` is the
992
1054
  * tile's own id — the impl derives `${testId}-label`/`${testId}-value` for
993
1055
  * the two rendered nodes, so a caller only ever needs to know the base id. */