@cosmicdrift/kumiko-renderer 0.233.0 → 0.235.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";
@@ -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/i18n.tsx CHANGED
@@ -120,7 +120,7 @@ export function useLocale(): LocaleResolver {
120
120
  useSyncExternalStore(
121
121
  ctx.resolver.subscribe,
122
122
  () => ctx.resolver.locale(),
123
- () => "en",
123
+ () => ctx.resolver.locale(),
124
124
  );
125
125
  return ctx.resolver;
126
126
  }
@@ -147,7 +147,7 @@ export function useTranslation(): (
147
147
  const locale = useSyncExternalStore(
148
148
  ctx.resolver.subscribe,
149
149
  () => ctx.resolver.locale(),
150
- () => "en",
150
+ () => ctx.resolver.locale(),
151
151
  );
152
152
 
153
153
  // `t` MUSS referenz-stabil sein solange sich Resolver/Bundles/Locale
@@ -200,7 +200,7 @@ export function useOptionalLocale(): string | undefined {
200
200
  useSyncExternalStore(
201
201
  (onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
202
202
  () => (ctx ? ctx.resolver.locale() : "en"),
203
- () => "en",
203
+ () => (ctx ? ctx.resolver.locale() : "en"),
204
204
  );
205
205
  return ctx === undefined ? undefined : ctx.resolver.locale();
206
206
  }
@@ -213,7 +213,7 @@ export function useOptionalTranslation():
213
213
  const locale = useSyncExternalStore(
214
214
  (onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
215
215
  () => (ctx ? ctx.resolver.locale() : "en"),
216
- () => "en",
216
+ () => (ctx ? ctx.resolver.locale() : "en"),
217
217
  );
218
218
  const t = useCallback(
219
219
  (key: string, params?: Readonly<Record<string, unknown>>): string => {
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. */