@cosmicdrift/kumiko-renderer 0.64.0 → 0.66.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.64.0",
3
+ "version": "0.66.0",
4
4
  "description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -15,8 +15,8 @@
15
15
  }
16
16
  },
17
17
  "dependencies": {
18
- "@cosmicdrift/kumiko-framework": "0.57.2",
19
- "@cosmicdrift/kumiko-headless": "0.57.2",
18
+ "@cosmicdrift/kumiko-framework": "0.66.0",
19
+ "@cosmicdrift/kumiko-headless": "0.66.0",
20
20
  "react": "^19.2.6"
21
21
  },
22
22
  "devDependencies": {
@@ -145,4 +145,57 @@ describe("useListUrlState", () => {
145
145
  const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
146
146
  expect(result.current.page).toBe(1);
147
147
  });
148
+
149
+ test("liest Faceted-Filter aus .f.<field>-Keys (comma-split), namespaced", () => {
150
+ const nav = makeNav({
151
+ "orders.f.status": "draft,published",
152
+ "orders.f.active": "true",
153
+ "incidents.f.status": "open", // andere Liste → ignoriert
154
+ });
155
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
156
+ expect(result.current.filters).toEqual({
157
+ status: ["draft", "published"],
158
+ active: ["true"],
159
+ });
160
+ });
161
+
162
+ test("setFilter schreibt comma-joined Werte + resettet page", () => {
163
+ const nav = makeNav({ "orders.page": "4" });
164
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
165
+ act(() => {
166
+ result.current.setFilter("status", ["draft", "review"]);
167
+ });
168
+ expect(nav.captures[0]).toEqual({
169
+ "orders.f.status": "draft,review",
170
+ "orders.page": null,
171
+ });
172
+ });
173
+
174
+ test("setFilter mit leerem Array löscht den Facet-Key", () => {
175
+ const nav = makeNav({ "orders.f.status": "draft" });
176
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
177
+ act(() => {
178
+ result.current.setFilter("status", []);
179
+ });
180
+ expect(nav.captures[0]).toEqual({
181
+ "orders.f.status": null,
182
+ "orders.page": null,
183
+ });
184
+ });
185
+
186
+ test("clearFilters löscht alle aktiven .f.<field>-Keys", () => {
187
+ const nav = makeNav({
188
+ "orders.f.status": "draft",
189
+ "orders.f.active": "true",
190
+ });
191
+ const { result } = renderHook(() => useListUrlState("orders"), { wrapper: wrapper(nav) });
192
+ act(() => {
193
+ result.current.clearFilters();
194
+ });
195
+ expect(nav.captures[0]).toEqual({
196
+ "orders.page": null,
197
+ "orders.f.status": null,
198
+ "orders.f.active": null,
199
+ });
200
+ });
148
201
  });
@@ -20,6 +20,7 @@ import type {
20
20
  SubmitResult,
21
21
  Translate,
22
22
  } from "@cosmicdrift/kumiko-headless";
23
+ import { fieldLabelKey } from "@cosmicdrift/kumiko-headless";
23
24
  import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
24
25
  import { RenderEdit } from "../components/render-edit";
25
26
  import { RenderList, type ToolbarActionButton } from "../components/render-list";
@@ -27,7 +28,7 @@ import { useDispatcher, useOptionalDispatcher } from "../context/dispatcher-cont
27
28
  import { useListUrlState } from "../hooks/use-list-url-state";
28
29
  import { useQuery } from "../hooks/use-query";
29
30
  import { useTranslation } from "../i18n";
30
- import { type DataTableRowAction, usePrimitives } from "../primitives";
31
+ import { type DataTableFacet, type DataTableRowAction, usePrimitives } from "../primitives";
31
32
  import { synthesizeActionFormEntity, synthesizeActionFormScreen } from "./action-form-shim";
32
33
  import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config-edit-shim";
33
34
  import { useCustomScreenComponent } from "./custom-screens";
@@ -327,6 +328,7 @@ function EntityEditCreateBody({
327
328
  writeCommand={writeCommand}
328
329
  onSubmit={handleSubmitted}
329
330
  onCancel={navigateToList}
331
+ {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
330
332
  {...(translate !== undefined && { translate })}
331
333
  />
332
334
  );
@@ -485,6 +487,7 @@ function EntityEditUpdateForm({
485
487
  {...(screen.allowDelete !== false && { onDelete: handleDelete })}
486
488
  onCancel={navigateToList}
487
489
  onReload={() => void onReload()}
490
+ {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
488
491
  {...(translate !== undefined && { translate })}
489
492
  />
490
493
  );
@@ -594,6 +597,23 @@ function EntityListBody({
594
597
  setHasMore(true);
595
598
  }, [useInfinite, sortQKey]);
596
599
 
600
+ // User-gewählte Faceted-Filter aus dem URL-State → payload.filters.
601
+ // Boolean-Felder: "true"/"false"-Strings zu echten Booleans coercen
602
+ // (DB-Spalte ist boolean). Alles als op:"in" (Multi-Select-Semantik).
603
+ const filterPayload = useMemo(() => {
604
+ const out: { field: string; op: "in"; value: unknown }[] = [];
605
+ for (const [field, values] of Object.entries(urlState.filters)) {
606
+ if (values.length === 0) continue;
607
+ // entity.fields ist am Renderer-Layer schwach getypt (vom Schema
608
+ // deserialisiert) — Boundary-Cast wie buildInitialValues.
609
+ const def = entity.fields[field] as { type?: string } | undefined;
610
+ if (def === undefined) continue;
611
+ const value = def.type === "boolean" ? values.map((v) => v === "true") : values;
612
+ out.push({ field, op: "in", value });
613
+ }
614
+ return out;
615
+ }, [urlState.filters, entity.fields]);
616
+
597
617
  // Payload für den Server-Query-Handler (LIST_PAYLOAD_SCHEMA):
598
618
  // search/sort/sortDirection/limit + offset/totalCount für Pager-Mode
599
619
  // ODER cursor für Infinite-Scroll.
@@ -611,6 +631,9 @@ function EntityListBody({
611
631
  if (screen.filter !== undefined) {
612
632
  payload["filter"] = screen.filter;
613
633
  }
634
+ if (filterPayload.length > 0) {
635
+ payload["filters"] = filterPayload;
636
+ }
614
637
  if (usePager) {
615
638
  // page=1 → offset=0, page=2 → offset=limit, etc. Server
616
639
  // clampt selbst wenn offset >= total.
@@ -629,6 +652,7 @@ function EntityListBody({
629
652
  urlState.q,
630
653
  effectiveSort,
631
654
  screen.filter,
655
+ filterPayload,
632
656
  usePager,
633
657
  urlState.page,
634
658
  useInfinite,
@@ -678,6 +702,48 @@ function EntityListBody({
678
702
  // sonst kollidieren die Hook-Slots zwischen Renders.
679
703
  const t = useTranslation();
680
704
  const effectiveTranslate = translate ?? t;
705
+
706
+ // Faceted-Filter: ein Dropdown pro filterable select/boolean-Feld.
707
+ // Labels + select-Option-Labels über dieselbe i18n-Konvention wie die
708
+ // Spalten-Header (fieldLabelKey / :option:<value>).
709
+ const filterFacets = useMemo<DataTableFacet[]>(() => {
710
+ const out: DataTableFacet[] = [];
711
+ for (const [field, rawDef] of Object.entries(entity.fields)) {
712
+ // entity.fields ist am Renderer-Layer schwach getypt (Record<string,
713
+ // unknown>, vom Schema deserialisiert) — Boundary-Cast wie buildInitialValues.
714
+ const def = rawDef as {
715
+ type?: string;
716
+ filterable?: boolean;
717
+ options?: readonly string[];
718
+ };
719
+ if (def.filterable !== true) continue;
720
+ const label = effectiveTranslate(fieldLabelKey(featureName, screen.entity, field));
721
+ if (def.type === "select" && Array.isArray(def.options)) {
722
+ out.push({
723
+ field,
724
+ label,
725
+ options: def.options.map((value) => ({
726
+ value,
727
+ label: effectiveTranslate(
728
+ `${featureName}:entity:${screen.entity}:field:${field}:option:${value}`,
729
+ ),
730
+ })),
731
+ });
732
+ } else if (def.type === "boolean") {
733
+ out.push({
734
+ field,
735
+ label,
736
+ // ponytail: Yes/No literal — boolean-Facet-Labels i18n'en wenn eine App es braucht
737
+ options: [
738
+ { value: "true", label: "Yes" },
739
+ { value: "false", label: "No" },
740
+ ],
741
+ });
742
+ }
743
+ }
744
+ return out;
745
+ }, [entity.fields, featureName, screen.entity, effectiveTranslate]);
746
+
681
747
  // Soft-Dispatcher: in Tests die ohne DispatcherProvider mounten,
682
748
  // bleibt rowActions undefined statt zu crashen. Echte Apps haben
683
749
  // den Provider via createKumikoApp — wenn nicht, ist es vermutlich
@@ -909,6 +975,12 @@ function EntityListBody({
909
975
  {...(onCreate !== undefined && { onCreate })}
910
976
  {...(translate !== undefined && { translate })}
911
977
  {...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
978
+ {...(filterFacets.length > 0 && {
979
+ filterFacets,
980
+ filterValues: urlState.filters,
981
+ onFilterChange: urlState.setFilter,
982
+ onFilterReset: urlState.clearFilters,
983
+ })}
912
984
  />
913
985
  );
914
986
  }
@@ -361,21 +361,38 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
361
361
  </>
362
362
  );
363
363
 
364
- // Title-Resolution analog zu RenderList: i18n-Key `screen:<id>.title`,
365
- // mit screenId als Fallback wenn das Bundle den Key nicht kennt.
366
- const titleKey = `screen:${screen.id}.title`;
367
- const resolvedTitle = translate(titleKey);
368
- const formTitle = resolvedTitle === titleKey ? screen.id : resolvedTitle;
364
+ // Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
365
+ // screen:<id>.<mode>.title / .<mode>.subtitle
366
+ // Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
367
+ // title fällt zuletzt auf screenId, subtitle auf undefined (kein Untertitel).
368
+ const isCreate = (() => {
369
+ const id = resolveExtensionEntityId(entityIdProp, vm.id);
370
+ return id == null || id === "";
371
+ })();
372
+ const formMode = isCreate ? "create" : "edit";
373
+ const resolveScreenText = (suffix: string): string | undefined => {
374
+ for (const key of [
375
+ `screen:${screen.id}.${formMode}.${suffix}`,
376
+ `screen:${screen.id}.${suffix}`,
377
+ ]) {
378
+ const value = translate(key);
379
+ if (value !== key) return value;
380
+ }
381
+ return undefined;
382
+ };
383
+ const formTitle = resolveScreenText("title") ?? screen.id;
384
+ const formSubtitle = resolveScreenText("subtitle");
369
385
 
370
386
  return (
371
387
  <ExtensionFormRegistryProvider value={extensionFormRegistry}>
372
388
  <Form
373
389
  onSubmit={() => void handleSubmit()}
374
390
  title={formTitle}
391
+ {...(formSubtitle !== undefined && { subtitle: formSubtitle })}
375
392
  actions={formActions}
376
393
  testId="render-edit-form"
377
394
  >
378
- {vm.sections.map((section: EditSectionViewModel) => {
395
+ {vm.sections.map((section: EditSectionViewModel, sectionIndex: number) => {
379
396
  if (section.kind === "extension") {
380
397
  return (
381
398
  <ExtensionSectionMount
@@ -391,11 +408,13 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
391
408
  // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
392
409
  // ActionForms, deren Section-Label = Screen-Titel ist).
393
410
  const sectionTitle = section.title === formTitle ? undefined : section.title;
411
+ // Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
412
+ const sectionKey = section.title ?? `section-${sectionIndex}`;
394
413
  return (
395
414
  <Section
396
- key={section.title}
415
+ key={sectionKey}
397
416
  {...(sectionTitle !== undefined && { title: sectionTitle })}
398
- testId={`section-${section.title}`}
417
+ testId={`section-${sectionKey}`}
399
418
  >
400
419
  <Grid columns={section.columns}>
401
420
  {section.fields.map((field: EditFieldViewModel) => (
@@ -73,6 +73,7 @@ export function RenderField({
73
73
  {...(issues !== undefined && { issues })}
74
74
  {...(labelAppendix !== undefined && { labelAppendix })}
75
75
  {...(fieldAppendix !== undefined && { fieldAppendix })}
76
+ {...(field.type === "boolean" && { layout: "inline" as const })}
76
77
  testId={`field-${field.field}`}
77
78
  >
78
79
  {control}
@@ -292,6 +293,23 @@ function renderInput({
292
293
  />
293
294
  );
294
295
  }
296
+ case "file":
297
+ case "image": {
298
+ const kind = field.type === "image" ? ("image" as const) : ("file" as const);
299
+ const fileId = typeof field.value === "string" && field.value !== "" ? field.value : null;
300
+ return (
301
+ <Input
302
+ kind={kind}
303
+ {...common}
304
+ value={fileId}
305
+ onChange={(v) => onChange(v)}
306
+ {...(field.accept !== undefined && { accept: field.accept })}
307
+ {...(field.maxSize !== undefined && { maxSize: field.maxSize })}
308
+ {...(field.entityType !== undefined && { entityType: field.entityType })}
309
+ {...(field.fieldName !== undefined && { fieldName: field.fieldName })}
310
+ />
311
+ );
312
+ }
295
313
  default: {
296
314
  // text + unknown → text input. Wenn TextFieldDef.multiline gesetzt
297
315
  // ist (das ViewModel hält's), wechselt der Renderer auf textarea.
@@ -16,7 +16,7 @@ import { extensionSectionName, useExtensionSectionComponent } from "../app/exten
16
16
  import type { ListSort } from "../hooks/use-list-url-state";
17
17
  import { type ReferenceLookupMap, useReferenceLookup } from "../hooks/use-reference-lookup";
18
18
  import { useTranslation } from "../i18n";
19
- import { type DataTableRowAction, usePrimitives } from "../primitives";
19
+ import { type DataTableFacet, type DataTableRowAction, usePrimitives } from "../primitives";
20
20
 
21
21
  // RenderList — präsentationaler View für entityList-Screens.
22
22
  //
@@ -90,6 +90,15 @@ export type RenderListProps = {
90
90
  * → useNav, handler-QN → dispatcher-Call). RenderList rendert die
91
91
  * Buttons rechts in der Toolbar, vor "+ Neu". */
92
92
  readonly toolbarActions?: readonly ToolbarActionButton[];
93
+ /** Faceted-Filter-Dropdowns — KumikoScreen baut sie aus den filterable
94
+ * select/boolean-Feldern; RenderList reicht sie an die DataTable durch. */
95
+ readonly filterFacets?: readonly DataTableFacet[];
96
+ /** Aktuell gewählte Facet-Werte je Feld (vom URL-State). */
97
+ readonly filterValues?: Readonly<Record<string, readonly string[]>>;
98
+ /** Toggle eines Facet-Werts (URL-State setzen + refetch). */
99
+ readonly onFilterChange?: (field: string, values: readonly string[]) => void;
100
+ /** Reset aller aktiven Facets. */
101
+ readonly onFilterReset?: () => void;
93
102
  };
94
103
 
95
104
  // Resolved-Form einer Toolbar-Action: KumikoScreen baut das aus dem
@@ -130,6 +139,10 @@ export function RenderList(props: RenderListProps): ReactNode {
130
139
  hasMore,
131
140
  rowActions,
132
141
  toolbarActions,
142
+ filterFacets,
143
+ filterValues,
144
+ onFilterChange,
145
+ onFilterReset,
133
146
  } = props;
134
147
  // Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
135
148
  // wären Column-Header raw i18n-Keys.
@@ -331,6 +344,10 @@ export function RenderList(props: RenderListProps): ReactNode {
331
344
  {...(loadingMore !== undefined && { loadingMore })}
332
345
  {...(hasMore !== undefined && { hasMore })}
333
346
  {...(rowActions !== undefined && { rowActions })}
347
+ {...(filterFacets !== undefined && { filterFacets })}
348
+ {...(filterValues !== undefined && { filterValues })}
349
+ {...(onFilterChange !== undefined && { onFilterChange })}
350
+ {...(onFilterReset !== undefined && { onFilterReset })}
334
351
  testId="render-list-table"
335
352
  />
336
353
  </>
@@ -4,10 +4,11 @@
4
4
  // nicht über dieselben Query-Keys streiten.
5
5
  //
6
6
  // Param-Schema pro Liste:
7
- // <screenId>.sort — field name (string)
8
- // <screenId>.dir — "asc" | "desc"
9
- // <screenId>.q — search term (URL-encoded)
10
- // <screenId>.page — 1-based page number (nur bei pagination="pages")
7
+ // <screenId>.sort — field name (string)
8
+ // <screenId>.dir — "asc" | "desc"
9
+ // <screenId>.q — search term (URL-encoded)
10
+ // <screenId>.page — 1-based page number (nur bei pagination="pages")
11
+ // <screenId>.f.<field> — Faceted-Filter: comma-joined selected values
11
12
  //
12
13
  // Schreibt mit setSearchParams (replaceState — kein push), damit
13
14
  // Sort/Filter-Toggles nicht die Browser-History fluten.
@@ -29,6 +30,9 @@ export type ListUrlState = {
29
30
  /** 1-basierte Page-Nummer. Bei pagination="infinite" oder false ist
30
31
  * der Wert ignoriert; Caller liest ihn nur wenn relevant. */
31
32
  readonly page: number;
33
+ /** Aktive Faceted-Filter: field → ausgewählte Werte (Strings; Caller
34
+ * coerced pro Field-Type). Leer wenn kein Filter gesetzt. */
35
+ readonly filters: Readonly<Record<string, readonly string[]>>;
32
36
  };
33
37
 
34
38
  export type ListUrlStateApi = ListUrlState & {
@@ -39,6 +43,11 @@ export type ListUrlStateApi = ListUrlState & {
39
43
  readonly setQ: (next: string) => void;
40
44
  /** Setzt die Page. 1 oder kleiner löscht den Key (Default-Page). */
41
45
  readonly setPage: (next: number) => void;
46
+ /** Setzt die ausgewählten Werte eines Facet-Felds. Leeres Array löscht
47
+ * den Key. Resettet die Page (wie sort/search). */
48
+ readonly setFilter: (field: string, values: readonly string[]) => void;
49
+ /** Löscht alle aktiven Faceted-Filter (Reset-Button). */
50
+ readonly clearFilters: () => void;
42
51
  };
43
52
 
44
53
  // `.` als Trenner: lesbar (`?orders.sort=name`), kollidiert nicht mit
@@ -72,6 +81,20 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
72
81
  const q = params[key(screenId, "q")] ?? "";
73
82
  const page = parsePage(params[key(screenId, "page")]);
74
83
 
84
+ const filterPrefix = `${screenId}.f.`;
85
+ const filters = useMemo<Readonly<Record<string, readonly string[]>>>(() => {
86
+ const out: Record<string, readonly string[]> = {};
87
+ for (const [k, v] of Object.entries(params)) {
88
+ if (!k.startsWith(filterPrefix) || v === "") continue;
89
+ const field = k.slice(filterPrefix.length);
90
+ if (field === "") continue;
91
+ // ponytail: comma-join — Facet-Werte sind select-Options/booleans
92
+ // ohne Komma; upgrade auf repeated-keys falls je Komma-Werte nötig.
93
+ out[field] = v.split(",").filter((s) => s !== "");
94
+ }
95
+ return out;
96
+ }, [params, filterPrefix]);
97
+
75
98
  const setSort = useCallback(
76
99
  (next: ListSort | null) => {
77
100
  // Atomares Update: bei jedem Sort-Wechsel resetten wir auch die
@@ -109,5 +132,21 @@ export function useListUrlState(screenId: string): ListUrlStateApi {
109
132
  [nav, screenId],
110
133
  );
111
134
 
112
- return { sort, q, page, setSort, setQ, setPage };
135
+ const setFilter = useCallback(
136
+ (field: string, values: readonly string[]) => {
137
+ nav.setSearchParams({
138
+ [key(screenId, `f.${field}`)]: values.length === 0 ? null : values.join(","),
139
+ [key(screenId, "page")]: null,
140
+ });
141
+ },
142
+ [nav, screenId],
143
+ );
144
+
145
+ const clearFilters = useCallback(() => {
146
+ const updates: Record<string, string | null> = { [key(screenId, "page")]: null };
147
+ for (const field of Object.keys(filters)) updates[key(screenId, `f.${field}`)] = null;
148
+ nav.setSearchParams(updates);
149
+ }, [nav, screenId, filters]);
150
+
151
+ return { sort, q, page, filters, setSort, setQ, setPage, setFilter, clearFilters };
113
152
  }
@@ -23,6 +23,13 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
23
23
  // Version — Update-Awareness-Banner (UpdateChecker).
24
24
  "kumiko.version.update-available": "Eine neue Version ist verfügbar.",
25
25
 
26
+ // Toast — Self-service Docs-Link-Default (useToast docsLinkLabel).
27
+ "kumiko.toast.learn-more": "Mehr erfahren",
28
+
29
+ // Field — aria-Labels der Date/Timestamp-Primitives.
30
+ "kumiko.field.open-calendar": "Kalender öffnen",
31
+ "kumiko.field.time": "Uhrzeit",
32
+
26
33
  // List — DataTable Toolbar, Empty-State, Search.
27
34
  "kumiko.list.search-placeholder": "Suchen…",
28
35
  "kumiko.list.empty.title": "Noch keine Einträge.",
@@ -104,6 +111,11 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
104
111
 
105
112
  "kumiko.version.update-available": "A new version is available.",
106
113
 
114
+ "kumiko.toast.learn-more": "Learn more",
115
+
116
+ "kumiko.field.open-calendar": "Open calendar",
117
+ "kumiko.field.time": "Time",
118
+
107
119
  "kumiko.list.search-placeholder": "Search…",
108
120
  "kumiko.list.empty.title": "No entries yet.",
109
121
  "kumiko.list.empty.hint": "Create the first one to get started.",
package/src/index.ts CHANGED
@@ -92,6 +92,7 @@ export type {
92
92
  BannerProps,
93
93
  ButtonProps,
94
94
  CorePrimitives,
95
+ DataTableFacet,
95
96
  DataTableProps,
96
97
  DataTableRowAction,
97
98
  DataTableRowActionMode,
@@ -99,6 +99,10 @@ export type FieldProps = {
99
99
  readonly labelAppendix?: ReactNode;
100
100
  readonly fieldAppendix?: ReactNode;
101
101
  readonly children: ReactNode;
102
+ /** Label-Layout. `stacked` (Default): Label über dem Control. `inline`:
103
+ * Control + Label nebeneinander (Box links, Label rechts) — für
104
+ * Checkbox/Switch-Felder (boolean), wie das shadcn-Muster. */
105
+ readonly layout?: "stacked" | "inline";
102
106
  readonly testId?: string;
103
107
  };
104
108
 
@@ -170,6 +174,24 @@ export type InputProps =
170
174
  readonly required?: boolean;
171
175
  readonly hasError?: boolean;
172
176
  }
177
+ | {
178
+ readonly kind: "file" | "image";
179
+ readonly id: string;
180
+ readonly name: string;
181
+ /** FileRef-UUID (gespeicherte Datei) oder null (kein Upload). */
182
+ readonly value: string | null;
183
+ readonly onChange: (fileId: string | null) => void;
184
+ readonly disabled?: boolean;
185
+ readonly required?: boolean;
186
+ readonly hasError?: boolean;
187
+ /** Erlaubte Extensions/MIME (z.B. ["jpg","png"]) → `accept`-Attribut. */
188
+ readonly accept?: readonly string[];
189
+ readonly maxSize?: string;
190
+ /** Für den Upload-POST `/api/files` — der Endpoint prüft maxSize/accept
191
+ * gegen die Field-Def von (entityType, fieldName). */
192
+ readonly entityType?: string;
193
+ readonly fieldName?: string;
194
+ }
173
195
  | {
174
196
  readonly kind: "date";
175
197
  readonly id: string;
@@ -344,6 +366,19 @@ export type DataTableRowAction = {
344
366
  readonly isVisible?: (row: ListRowViewModel) => boolean;
345
367
  };
346
368
 
369
+ // Ein Faceted-Filter-Slot in der Toolbar: ein Outline-Dropdown-Button
370
+ // (wie shadcns "Columns"-Toggle) mit Multi-Select-Checkboxen. KumikoScreen
371
+ // baut das aus den filterable select/boolean-Feldern des Entity.
372
+ export type DataTableFacet = {
373
+ /** Entity-Feldname (= URL-State-Key + payload.filters[].field). */
374
+ readonly field: string;
375
+ /** Translated Button-Label ("Status", "Active"). */
376
+ readonly label: string;
377
+ /** Auswählbare Werte mit Anzeige-Label. value = roher Filterwert
378
+ * (select-option / "true"|"false"), als String transportiert. */
379
+ readonly options: readonly { readonly value: string; readonly label: string }[];
380
+ };
381
+
347
382
  export type DataTableProps = {
348
383
  readonly columns: readonly ListColumnViewModel[];
349
384
  readonly rows: readonly ListRowViewModel[];
@@ -384,6 +419,16 @@ export type DataTableProps = {
384
419
  /** Toolbar-Slot rechts (typisch + Neu Button, Filter, View-Switch).
385
420
  * Web zieht den Cluster mit ml-auto an die rechte Kante. */
386
421
  readonly toolbarEnd?: ReactNode;
422
+ /** Faceted-Filter-Dropdowns (select/boolean-Felder) — der Renderer
423
+ * setzt sie neben das Search-Input. Leer/undefined → keine Filter. */
424
+ readonly filterFacets?: readonly DataTableFacet[];
425
+ /** Aktuell ausgewählte Werte je Facet-Feld (vom URL-State). */
426
+ readonly filterValues?: Readonly<Record<string, readonly string[]>>;
427
+ /** Toggle eines Facet-Werts → Caller schreibt URL-State + refetch. */
428
+ readonly onFilterChange?: (field: string, values: readonly string[]) => void;
429
+ /** Reset-Button: löscht alle aktiven Facets. Renderer zeigt ihn nur
430
+ * wenn mindestens ein Facet aktiv ist. */
431
+ readonly onFilterReset?: () => void;
387
432
  /** Pagination-State + Callback. Wenn gesetzt, rendert der Renderer
388
433
  * einen Pager unter der Tabelle (Web: Footer-Bar mit ← 1 ... N →).
389
434
  * total/limit/page sind 1-basiert für die UI; Server-Translation
@@ -425,6 +470,9 @@ export type FormProps = {
425
470
  readonly onSubmit: (e?: FormEvent) => void;
426
471
  readonly children: ReactNode;
427
472
  readonly title?: ReactNode;
473
+ /** Optionaler muted Untertitel unter dem Titel (z.B. "Add a new item to
474
+ * your catalog") — gibt dem Form-Header Kontext statt nur ein Label. */
475
+ readonly subtitle?: ReactNode;
428
476
  readonly actions?: ReactNode;
429
477
  readonly testId?: string;
430
478
  };