@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.
- package/package.json +4 -4
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +5 -2
- package/src/app/__tests__/entity-list-row-action-icon.test.tsx +169 -0
- package/src/app/__tests__/projection-detail-actions.test.tsx +10 -6
- package/src/app/kumiko-screen.tsx +247 -44
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +3 -1
- package/src/components/__tests__/render-field-icon-derivation.test.tsx +119 -0
- package/src/components/__tests__/render-field-number-unit.test.tsx +191 -0
- package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +146 -0
- package/src/components/related-list-section.tsx +29 -19
- package/src/components/render-edit-action-button.tsx +8 -1
- package/src/components/render-edit-types.ts +5 -0
- package/src/components/render-edit.tsx +73 -31
- package/src/components/render-field.tsx +71 -11
- package/src/components/render-list.tsx +27 -2
- package/src/i18n-defaults.ts +2 -0
- package/src/index.ts +6 -1
- package/src/primitives.tsx +79 -6
|
@@ -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
|
|
65
|
+
/** Current form values, keyed by field name. Consulted when
|
|
65
66
|
* `field.renderer` resolves to a `{ react: { __component } }` registry
|
|
66
|
-
* component
|
|
67
|
-
* as list-column renderers, fw#2245)
|
|
68
|
-
*
|
|
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
|
-
{...(
|
|
527
|
+
{...(icon !== undefined && { icon })}
|
|
528
|
+
{...(unit !== undefined && { unit })}
|
|
485
529
|
/>
|
|
486
530
|
);
|
|
487
|
-
|
|
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
|
-
{...(
|
|
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
|
-
{...(
|
|
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 {
|
|
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-defaults.ts
CHANGED
|
@@ -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 {
|
|
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";
|
package/src/primitives.tsx
CHANGED
|
@@ -35,7 +35,12 @@ import type {
|
|
|
35
35
|
ConfigScope,
|
|
36
36
|
ConfigValueSource,
|
|
37
37
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
38
|
-
import type {
|
|
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
|
-
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
|
|
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. */
|