@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.
- package/package.json +6 -4
- package/src/__tests__/i18n.test.tsx +39 -0
- package/src/app/__tests__/entity-list-row-action-icon.test.tsx +169 -0
- package/src/app/kumiko-screen.tsx +255 -50
- package/src/app/secrets-edit-body.tsx +190 -0
- package/src/components/__tests__/render-field-icon-derivation.test.tsx +119 -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 +26 -4
- package/src/components/render-field.tsx +47 -5
- package/src/components/render-list.tsx +27 -2
- package/src/i18n.tsx +4 -4
- package/src/index.ts +6 -1
- package/src/primitives.tsx +63 -1
|
@@ -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
|
-
{...(
|
|
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
|
-
{...(
|
|
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
|
-
{...(
|
|
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 {
|
|
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
|
-
() =>
|
|
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
|
-
() =>
|
|
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 {
|
|
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,
|
|
@@ -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. */
|