@cosmicdrift/kumiko-renderer 0.232.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.
@@ -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";
@@ -61,11 +62,12 @@ export type RenderFieldProps = {
61
62
  * `field.readOnly` field as plain text instead (projectionDetail's read
62
63
  * view, fw#2245); editable fields are untouched by this prop either way. */
63
64
  readonly valueDisplay?: "form" | "text";
64
- /** Current form values, keyed by field name only consulted when
65
+ /** Current form values, keyed by field name. Consulted when
65
66
  * `field.renderer` resolves to a `{ react: { __component } }` registry
66
- * component, passed through as `ColumnRendererProps.row` (same contract
67
- * as list-column renderers, fw#2245). Omitted falls back to a
68
- * single-key `{ [field.field]: field.value }` row. */
67
+ * component (passed through as `ColumnRendererProps.row`, same contract
68
+ * as list-column renderers, fw#2245), and to resolve a `type: "number"`
69
+ * field's sibling-field `unit`. Omitted falls back to a single-key
70
+ * `{ [field.field]: field.value }` row. */
69
71
  readonly row?: Readonly<Record<string, unknown>>;
70
72
  };
71
73
 
@@ -134,7 +136,7 @@ export function RenderField({
134
136
  ) : readOnlyText && !isComplexFieldType(field.type) ? (
135
137
  <Text testId={`field-value-${field.field}`}>{readOnlyDisplayText(field, appLocale)}</Text>
136
138
  ) : (
137
- renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, t })
139
+ renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, t, row })
138
140
  );
139
141
 
140
142
  return (
@@ -145,7 +147,6 @@ export function RenderField({
145
147
  {...(issues !== undefined && { issues })}
146
148
  {...(labelAppendix !== undefined && { labelAppendix })}
147
149
  {...(fieldAppendix !== undefined && { fieldAppendix })}
148
- {...(field.type === "boolean" && { layout: "inline" as const })}
149
150
  testId={`field-${field.field}`}
150
151
  >
151
152
  {control}
@@ -434,6 +435,44 @@ function isComplexFieldType(type: string): boolean {
434
435
  return type === "embedded" || type === "jsonb" || type === "files" || type === "images";
435
436
  }
436
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
+
437
476
  // Dispatches field.type → Input-kind. Select threads options through
438
477
  // from the EditFieldViewModel (computeEditViewModel pulls them from
439
478
  // SelectFieldDef.options). Structural types without a widget (embedded,
@@ -449,6 +488,7 @@ function renderInput({
449
488
  Banner,
450
489
  Text,
451
490
  t,
491
+ row,
452
492
  }: {
453
493
  readonly field: EditFieldViewModel;
454
494
  readonly id: string;
@@ -459,6 +499,7 @@ function renderInput({
459
499
  readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
460
500
  readonly Text: ReturnType<typeof usePrimitives>["Text"];
461
501
  readonly t: ReturnType<typeof useTranslation>;
502
+ readonly row?: Readonly<Record<string, unknown>>;
462
503
  }): ReactNode {
463
504
  const common = {
464
505
  id,
@@ -474,20 +515,25 @@ function renderInput({
474
515
  // already emits `number | undefined`, so no extra coercion is needed
475
516
  // beyond what "number" already does (#1925).
476
517
  case "number":
477
- case "bigInt":
518
+ case "bigInt": {
519
+ const icon = resolveFieldIcon(field);
520
+ const unit = resolveNumberUnit(field.unit, row);
478
521
  return (
479
522
  <Input
480
523
  kind="number"
481
524
  {...common}
482
525
  value={numberValue(field.value)}
483
526
  onChange={(v) => onChange(v)}
484
- {...(field.icon !== undefined && { icon: field.icon })}
527
+ {...(icon !== undefined && { icon })}
528
+ {...(unit !== undefined && { unit })}
485
529
  />
486
530
  );
487
- case "decimal":
531
+ }
532
+ case "decimal": {
488
533
  // step="any" disables the native stepMismatch constraint — without it
489
534
  // <input type="number"> defaults to step=1 and blocks form submit on
490
535
  // any fractional value via silent browser-native validation.
536
+ const icon = resolveFieldIcon(field);
491
537
  return (
492
538
  <Input
493
539
  kind="number"
@@ -495,9 +541,10 @@ function renderInput({
495
541
  value={numberValue(field.value)}
496
542
  onChange={(v) => onChange(v)}
497
543
  step="any"
498
- {...(field.icon !== undefined && { icon: field.icon })}
544
+ {...(icon !== undefined && { icon })}
499
545
  />
500
546
  );
547
+ }
501
548
  case "tz":
502
549
  return (
503
550
  <Input
@@ -677,13 +724,14 @@ function renderInput({
677
724
  />
678
725
  );
679
726
  }
727
+ const icon = resolveFieldIcon(field);
680
728
  return (
681
729
  <Input
682
730
  kind="text"
683
731
  {...common}
684
732
  value={stringValue(field.value)}
685
733
  onChange={(v) => onChange(v)}
686
- {...(field.icon !== undefined && { icon: field.icon })}
734
+ {...(icon !== undefined && { icon })}
687
735
  />
688
736
  );
689
737
  }
@@ -721,6 +769,18 @@ function resolveMoneyCurrency(value: unknown, fieldCurrency: string | undefined)
721
769
  return fieldCurrency ?? "EUR";
722
770
  }
723
771
 
772
+ // Static unit → used as-is. Sibling-field reference → read the live row
773
+ // value; missing/empty/non-string sibling means no suffix, never a guess.
774
+ function resolveNumberUnit(
775
+ unit: string | { readonly field: string } | undefined,
776
+ row: Readonly<Record<string, unknown>> | undefined,
777
+ ): string | undefined {
778
+ if (unit === undefined) return undefined;
779
+ if (typeof unit === "string") return unit;
780
+ const sibling = row?.[unit.field];
781
+ return typeof sibling === "string" && sibling.length > 0 ? sibling : undefined;
782
+ }
783
+
724
784
  function moneyMinorValue(v: unknown, currency: string): number | "" {
725
785
  if (v === undefined || v === null || v === "") return "";
726
786
  if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
@@ -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}
@@ -62,6 +62,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
62
62
  "kumiko.list.empty.hint": "Create the first one to get started.",
63
63
  "kumiko.list.no-entries": "No entries.",
64
64
  "kumiko.list.end-of-list": "— End of list —",
65
+ "kumiko.list.sort.label": "Sort",
66
+ "kumiko.list.sort.unsorted": "Unsorted",
65
67
 
66
68
  "kumiko.pager.status": "{from}–{to} of {total}",
67
69
  "kumiko.pager.previousPage": "Previous page",
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 } 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,
@@ -67,11 +72,12 @@ export type ButtonProps = {
67
72
  * setzen wenn die Action blockiert bis das Loading abgeschlossen
68
73
  * ist (verhindert Double-Submit). */
69
74
  readonly loading?: boolean;
70
- /** Semantische Klasse — default="primary". Custom-Impls entscheiden
71
- * was daraus visuell wird; die Renderer verwenden "primary" für
72
- * Save, "danger" für Delete, "secondary" für Confirm-State,
73
- * "link" für Inline-Aktionen im Fließtext (kein BG, underline). */
74
- readonly variant?: "primary" | "secondary" | "danger" | "link";
75
+ /** Semantic class — default="primary". Custom impls decide what this
76
+ * becomes visually; the renderers use "primary" for Save, "danger" for
77
+ * Delete, "secondary" for a Confirm state, "link" for inline actions in
78
+ * running text (no background, underline), "danger-ghost" for a
79
+ * destructive action as red text instead of a red fill. */
80
+ readonly variant?: "primary" | "secondary" | "danger" | "link" | "danger-ghost";
75
81
  /** Größe — default="md". "sm" für kompakte Inline-Aktionen (Toolbar,
76
82
  * Listen-Zeilen), "icon" für quadratische Icon-only-Buttons. */
77
83
  readonly size?: "sm" | "md" | "icon";
@@ -91,6 +97,10 @@ export type ButtonProps = {
91
97
  * a click (e.g. binding a drop-target handler). Web forwards it, native
92
98
  * impls ignore it (no native equivalent). */
93
99
  readonly ref?: Ref<HTMLButtonElement>;
100
+ /** Icon before the label. */
101
+ readonly icon?: NavIconKey;
102
+ /** Icon after the label (e.g. "Continue →"). */
103
+ readonly iconEnd?: NavIconKey;
94
104
  };
95
105
 
96
106
  /** Navigations-Link. `variant="button"` rendert die Button-Optik auf einem
@@ -227,6 +237,9 @@ export type InputProps =
227
237
  /** `<input step>`. "any" disables the native stepMismatch constraint
228
238
  * (needed for decimal fields — integer fields leave this unset). */
229
239
  readonly step?: number | "any";
240
+ /** Resolved display suffix (static or from a sibling field) — never
241
+ * part of the numeric value. */
242
+ readonly unit?: string;
230
243
  }
231
244
  | {
232
245
  readonly kind: "range";
@@ -484,8 +497,22 @@ export type DataTableRowAction = {
484
497
  readonly onTrigger: (row: ListRowViewModel) => Promise<void> | void;
485
498
  /** Conditional Visibility pro Row (z.B. "Start" nur wenn status==="scheduled"). */
486
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;
487
504
  };
488
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
+
489
516
  // Ein Faceted-Filter-Slot in der Toolbar: ein Outline-Dropdown-Button
490
517
  // (wie shadcns "Columns"-Toggle) mit Multi-Select-Checkboxen. KumikoScreen
491
518
  // baut das aus den filterable select/boolean-Feldern des Entity.
@@ -696,6 +723,9 @@ export type FormProps = {
696
723
  * your catalog") — gibt dem Form-Header Kontext statt nur ein Label. */
697
724
  readonly subtitle?: ReactNode;
698
725
  readonly actions?: ReactNode;
726
+ /** Secondary, record-related actions — rendered on the left on desktop,
727
+ * on their own row below the primary action on a narrow viewport. */
728
+ readonly secondaryActions?: ReactNode;
699
729
  readonly testId?: string;
700
730
  /** Max width of the form container. Default "full" — see FormWidth
701
731
  * (`packages/types/src/screen.ts`, EditLayout.width). Native impls may
@@ -736,6 +766,10 @@ export type SectionProps = {
736
766
  * Default "default" (normal card border). */
737
767
  readonly variant?: "default" | "destructive";
738
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;
739
773
  };
740
774
 
741
775
  /** Columns-basiertes Layout. Web: CSS grid, Native: Flex-Wrap mit
@@ -977,6 +1011,45 @@ export type StatusBadgeProps = {
977
1011
  readonly testId?: string;
978
1012
  };
979
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
+
980
1053
  /** Compact label/value tile (record-detail metrics band). `testId` is the
981
1054
  * tile's own id — the impl derives `${testId}-label`/`${testId}-value` for
982
1055
  * the two rendered nodes, so a caller only ever needs to know the base id. */