@cosmicdrift/kumiko-renderer 1.0.0 → 2.0.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.
Files changed (36) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/error-i18n-defaults.test.ts +13 -0
  3. package/src/__tests__/format-when.test.ts +12 -0
  4. package/src/__tests__/i18n.test.tsx +59 -0
  5. package/src/__tests__/qn.test.ts +44 -1
  6. package/src/__tests__/sort-by-accessor.test.ts +48 -0
  7. package/src/app/__tests__/config-edit-shim.test.ts +40 -0
  8. package/src/app/__tests__/screen-access-allows.test.ts +24 -0
  9. package/src/app/dashboard-body.tsx +32 -0
  10. package/src/app/extension-sections.tsx +11 -4
  11. package/src/app/kumiko-screen.tsx +408 -20
  12. package/src/app/projection-detail-shim.ts +72 -0
  13. package/src/app/projection-list-shim.ts +62 -0
  14. package/src/app/qn.ts +13 -0
  15. package/src/components/__tests__/render-field-app-locale.test.tsx +2 -0
  16. package/src/components/render-edit-logic.ts +8 -5
  17. package/src/components/render-edit.tsx +25 -2
  18. package/src/components/render-field.tsx +2 -1
  19. package/src/components/render-list.tsx +24 -2
  20. package/src/context/user-roles-context.tsx +27 -0
  21. package/src/format-when.ts +11 -0
  22. package/src/hooks/__tests__/use-ai-text.test.tsx +177 -0
  23. package/src/hooks/__tests__/use-disclosure.test.tsx +25 -0
  24. package/src/hooks/__tests__/use-mutation.test.tsx +77 -0
  25. package/src/hooks/__tests__/use-stream-handler.test.tsx +107 -0
  26. package/src/hooks/use-ai-text.ts +172 -0
  27. package/src/hooks/use-disclosure.ts +20 -0
  28. package/src/hooks/use-mutation.ts +61 -0
  29. package/src/hooks/use-query.ts +5 -2
  30. package/src/hooks/use-reference-lookup.ts +2 -1
  31. package/src/hooks/use-stream-handler.ts +134 -0
  32. package/src/i18n-defaults.ts +56 -0
  33. package/src/i18n.tsx +72 -31
  34. package/src/index.ts +31 -0
  35. package/src/primitives.tsx +70 -4
  36. package/src/sort-by-accessor.ts +20 -0
package/src/i18n.tsx CHANGED
@@ -16,7 +16,14 @@
16
16
  // Session die Sprache umschalten ohne Reload.
17
17
 
18
18
  import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
19
- import { createContext, type ReactNode, useContext, useSyncExternalStore } from "react";
19
+ import {
20
+ createContext,
21
+ type ReactNode,
22
+ useCallback,
23
+ useContext,
24
+ useMemo,
25
+ useSyncExternalStore,
26
+ } from "react";
20
27
 
21
28
  /** Map von i18n-Key → Template-String. Templates dürfen `{name}`-
22
29
  * Platzhalter enthalten — identische Semantik zu i18next-t. */
@@ -25,6 +32,21 @@ export type TranslationBundle = Readonly<Record<string, string>>;
25
32
  /** Map von Locale-Code (BCP-47, z.B. `"de"`, `"en-US"`) → Bundle. */
26
33
  export type TranslationsByLocale = Readonly<Record<string, TranslationBundle>>;
27
34
 
35
+ /** Key-first shape for `r.translations({ keys })` — each key maps locale → string. */
36
+ export type TranslationsByKey = Readonly<Record<string, Readonly<Record<string, string>>>>;
37
+
38
+ /** Pivot key-first server translations to locale-first client bundles. */
39
+ export function translationsByLocaleFromKeys(source: TranslationsByKey): TranslationsByLocale {
40
+ const out: Record<string, Record<string, string>> = {};
41
+ for (const [key, byLocale] of Object.entries(source)) {
42
+ for (const [locale, value] of Object.entries(byLocale)) {
43
+ out[locale] ??= {};
44
+ out[locale][key] = value;
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
28
50
  /** Merged zwei TranslationsByLocale-Maps — der override gewinnt pro Key,
29
51
  * die Locales werden zusammengeführt. Standard-Baustein für Client-
30
52
  * Plugins, die App-Overrides über ihre Default-Bundles legen. */
@@ -48,6 +70,12 @@ type LocaleContextValue = {
48
70
 
49
71
  const LocaleContext = createContext<LocaleContextValue | undefined>(undefined);
50
72
 
73
+ // Stabile Referenz statt `fallbackBundles = []` als Default-Parameter:
74
+ // ein Literal-Default wird bei JEDEM Aufruf neu allokiert und würde die
75
+ // useMemo-Referenzprüfung im Provider unten aushebeln, sobald der
76
+ // Aufrufer fallbackBundles weglässt.
77
+ const EMPTY_FALLBACK_BUNDLES: readonly TranslationsByLocale[] = [];
78
+
51
79
  export type LocaleProviderProps = {
52
80
  readonly resolver: LocaleResolver;
53
81
  /** Von Feature-Plugins gelieferte Default-Bundles. Lookup-Reihenfolge
@@ -64,15 +92,21 @@ export type LocaleProviderProps = {
64
92
 
65
93
  export function LocaleProvider({
66
94
  resolver,
67
- fallbackBundles = [],
95
+ fallbackBundles = EMPTY_FALLBACK_BUNDLES,
68
96
  fallbackLocale = "en",
69
97
  children,
70
98
  }: LocaleProviderProps): ReactNode {
71
- return (
72
- <LocaleContext.Provider value={{ resolver, fallbackBundles, fallbackLocale }}>
73
- {children}
74
- </LocaleContext.Provider>
99
+ // Ohne Memoization baut jeder Re-Render des Providers (z.B. weil ein
100
+ // Ahnen-Component neu rendert) ein neues Context-Value-Objekt — jeder
101
+ // Consumer von useTranslation()/useLocale() sieht dann eine neue `ctx`-
102
+ // Referenz und damit selbst mit useCallback-Memoization einen neuen `t`.
103
+ // Konsequenz: `t` in einem useEffect-Dependency-Array triggert einen
104
+ // Endlos-Loop (siehe admin-shell Overview-Screens, Prod-Incident).
105
+ const value = useMemo(
106
+ () => ({ resolver, fallbackBundles, fallbackLocale }),
107
+ [resolver, fallbackBundles, fallbackLocale],
75
108
  );
109
+ return <LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>;
76
110
  }
77
111
 
78
112
  /** Liefert den aktuellen LocaleResolver und abonniert automatisch
@@ -112,38 +146,45 @@ export function useTranslation(): (
112
146
  // Re-Render bei Sprach-Wechsel. `ctx.resolver.subscribe` ist bereits
113
147
  // eine stable-reference aus dem Resolver, daher hier keine eigene
114
148
  // Memoization der Subscribe-Callback nötig.
115
- useSyncExternalStore(
149
+ const locale = useSyncExternalStore(
116
150
  ctx.resolver.subscribe,
117
151
  () => ctx.resolver.locale(),
118
152
  () => "en",
119
153
  );
120
154
 
121
- return (key, params) => {
122
- // 1. App-provided resolver zuerst. Convention: wenn der App-Resolver
123
- // den Key nicht kennt, gibt er den Key zurück das ist die
124
- // Fallback-Einladung an Plugin-Bundles. i18next verhält sich
125
- // exakt so per default.
126
- const resolved = ctx.resolver.translate(key, params);
127
- if (resolved !== key) return resolved;
128
-
129
- // 2. + 3. Plugin-Bundles durchlaufen für current + fallback-locale.
130
- const currentLocale = ctx.resolver.locale();
131
- const primaryLookup = currentLocale;
132
- // `primaryLookup` könnte z.B. "de-AT" sein — in den Bundles stehen
133
- // oft nur die Language-Roots ("de"). Wir versuchen beide.
134
- const languageRoot = primaryLookup.split("-")[0] ?? primaryLookup;
135
- const localesToTry = [primaryLookup, languageRoot, ctx.fallbackLocale];
136
-
137
- for (const bundle of ctx.fallbackBundles) {
138
- for (const locale of localesToTry) {
139
- const value = bundle[locale]?.[key];
140
- if (value !== undefined) return interpolate(value, params);
155
+ // `t` MUSS referenz-stabil sein solange sich Resolver/Bundles/Locale
156
+ // nicht ändern Consumer nutzen `t` regelmäßig in useEffect-Deps
157
+ // (z.B. um Queries neu zu laden wenn sich die Sprache ändert). Ein neu
158
+ // erzeugtes `t` pro Render führt sonst zu einem Render/Effect-Endlos-
159
+ // Loop (siehe admin-shell Overview-Screens, Prod-Incident 2026-07-07).
160
+ return useCallback(
161
+ (key: string, params?: Readonly<Record<string, unknown>>): string => {
162
+ // 1. App-provided resolver zuerst. Convention: wenn der App-Resolver
163
+ // den Key nicht kennt, gibt er den Key zurück — das ist die
164
+ // Fallback-Einladung an Plugin-Bundles. i18next verhält sich
165
+ // exakt so per default.
166
+ const resolved = ctx.resolver.translate(key, params);
167
+ if (resolved !== key) return resolved;
168
+
169
+ // 2. + 3. Plugin-Bundles durchlaufen für current + fallback-locale.
170
+ const primaryLookup = locale;
171
+ // `primaryLookup` könnte z.B. "de-AT" sein — in den Bundles stehen
172
+ // oft nur die Language-Roots ("de"). Wir versuchen beide.
173
+ const languageRoot = primaryLookup.split("-")[0] ?? primaryLookup;
174
+ const localesToTry = [primaryLookup, languageRoot, ctx.fallbackLocale];
175
+
176
+ for (const bundle of ctx.fallbackBundles) {
177
+ for (const localeToTry of localesToTry) {
178
+ const value = bundle[localeToTry]?.[key];
179
+ if (value !== undefined) return interpolate(value, params);
180
+ }
141
181
  }
142
- }
143
182
 
144
- // 4. Nichts gefunden — key zurück, wie der Default-Resolver auch.
145
- return key;
146
- };
183
+ // 4. Nichts gefunden — key zurück, wie der Default-Resolver auch.
184
+ return key;
185
+ },
186
+ [ctx, locale],
187
+ );
147
188
  }
148
189
 
149
190
  function interpolate(template: string, params?: Readonly<Record<string, unknown>>): string {
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@ export type {
18
18
  export { ColumnRenderersProvider, useColumnRenderer } from "./app/column-renderers";
19
19
  export type { CustomScreensMap, CustomScreensProviderProps } from "./app/custom-screens";
20
20
  export { CustomScreensProvider, useCustomScreenComponent } from "./app/custom-screens";
21
+ export type { DashboardBodyProps, DashboardBodyProviderProps } from "./app/dashboard-body";
22
+ export { DashboardBodyProvider, useDashboardBody } from "./app/dashboard-body";
21
23
  export type {
22
24
  ExtensionFormRegistry,
23
25
  ExtensionFormSubmitHandler,
@@ -57,11 +59,27 @@ export {
57
59
  useDispatcherStatus,
58
60
  useOptionalDispatcher,
59
61
  } from "./context/dispatcher-context";
62
+ export type { UserRolesProviderProps } from "./context/user-roles-context";
63
+ export { UserRolesProvider, useUserRoles } from "./context/user-roles-context";
64
+ export { formatWhen } from "./format-when";
60
65
  export {
61
66
  REFERENCE_COMBOBOX_LIMIT,
62
67
  REFERENCE_LIST_LOOKUP_LIMIT,
63
68
  REFERENCE_SEARCH_DEBOUNCE_MS,
64
69
  } from "./hooks/reference-limits";
70
+ export type {
71
+ AiTextActionState,
72
+ AiTextMode,
73
+ AiTextRewriteStyle,
74
+ AiTextRunPayload,
75
+ AiTextRunResult,
76
+ AiTextUsage,
77
+ UseAiTextActionResult,
78
+ UseCompletionResult,
79
+ } from "./hooks/use-ai-text";
80
+ export { AI_TEXT_RUN_QN, useAiTextAction, useCompletion } from "./hooks/use-ai-text";
81
+ export type { UseDisclosureResult } from "./hooks/use-disclosure";
82
+ export { useDisclosure } from "./hooks/use-disclosure";
65
83
  export type { UseFormOptions, UseFormResult } from "./hooks/use-form";
66
84
  export { useForm } from "./hooks/use-form";
67
85
  export type {
@@ -71,18 +89,28 @@ export type {
71
89
  ListUrlStateApi,
72
90
  } from "./hooks/use-list-url-state";
73
91
  export { useListUrlState } from "./hooks/use-list-url-state";
92
+ export type { UseMutationResult } from "./hooks/use-mutation";
93
+ export { useMutation } from "./hooks/use-mutation";
74
94
  export type { UseQueryOptions, UseQueryResult } from "./hooks/use-query";
75
95
  export { useQuery } from "./hooks/use-query";
76
96
  export { useStore, useStoreSelector } from "./hooks/use-store";
97
+ export type {
98
+ StreamStatus,
99
+ UseStreamHandlerOptions,
100
+ UseStreamHandlerResult,
101
+ } from "./hooks/use-stream-handler";
102
+ export { useStreamHandler } from "./hooks/use-stream-handler";
77
103
  export type {
78
104
  LocaleProviderProps,
79
105
  TranslationBundle,
106
+ TranslationsByKey,
80
107
  TranslationsByLocale,
81
108
  } from "./i18n";
82
109
  export {
83
110
  createStaticLocaleResolver,
84
111
  LocaleProvider,
85
112
  mergeTranslations,
113
+ translationsByLocaleFromKeys,
86
114
  useLocale,
87
115
  useTranslation,
88
116
  } from "./i18n";
@@ -108,6 +136,8 @@ export type {
108
136
  GridProps,
109
137
  HeadingProps,
110
138
  InputProps,
139
+ LightboxProps,
140
+ LinkProps,
111
141
  PrimitivesProviderProps,
112
142
  PrimitivesRegistry,
113
143
  RuntimeRenderer,
@@ -115,6 +145,7 @@ export type {
115
145
  TextProps,
116
146
  } from "./primitives";
117
147
  export { PrimitivesProvider, usePrimitives } from "./primitives";
148
+ export { sortByAccessor } from "./sort-by-accessor";
118
149
  export type { LiveEvent, LiveEventSubscriber, LiveEventsProviderProps } from "./sse/live-events";
119
150
  export { LiveEventsProvider, useLiveEvents } from "./sse/live-events";
120
151
  export type {
@@ -66,8 +66,35 @@ export type ButtonProps = {
66
66
  readonly loading?: boolean;
67
67
  /** Semantische Klasse — default="primary". Custom-Impls entscheiden
68
68
  * was daraus visuell wird; die Renderer verwenden "primary" für
69
- * Save, "danger" für Delete, "secondary" für Confirm-State. */
70
- readonly variant?: "primary" | "secondary" | "danger";
69
+ * Save, "danger" für Delete, "secondary" für Confirm-State,
70
+ * "link" für Inline-Aktionen im Fließtext (kein BG, underline). */
71
+ readonly variant?: "primary" | "secondary" | "danger" | "link";
72
+ /** Größe — default="md". "sm" für kompakte Inline-Aktionen (Toolbar,
73
+ * Listen-Zeilen), "icon" für quadratische Icon-only-Buttons. */
74
+ readonly size?: "sm" | "md" | "icon";
75
+ /** Barrierefreies Label — Pflicht bei icon-only-Buttons (children ist nur
76
+ * ein Icon/Zeichen), sonst hat der Button keinen zugänglichen Namen. */
77
+ readonly ariaLabel?: string;
78
+ /** Breite — default="auto" (inhaltsbreit). "full" streckt CTA-Buttons auf
79
+ * die Container-Breite (Karten/Panels). Andere Breiten sind Layout-Sache
80
+ * des Containers, kein Button-Prop (Kit hält arbiträres Sizing draußen). */
81
+ readonly width?: "full" | "auto";
82
+ readonly children: ReactNode;
83
+ readonly testId?: string;
84
+ };
85
+
86
+ /** Navigations-Link. `variant="button"` rendert die Button-Optik auf einem
87
+ * semantischen Anchor (z.B. „Zum Login" nach Reset-Success), `muted` den
88
+ * dezenten Sekundär-Link. Interne SPA-Navigation läuft über den Browser-
89
+ * Default (History-Integration liegt beim Nav-Layer, nicht am Primitive). */
90
+ export type LinkProps = {
91
+ readonly href: string;
92
+ readonly variant?: "default" | "button" | "muted";
93
+ /** `_blank` setzt in der Web-Impl automatisch rel="noreferrer". */
94
+ readonly target?: "_blank";
95
+ /** Layout-Zusätze (self-center, text-xs) — Web merged via cn(),
96
+ * Native-Impls ignorieren es (Präzedenz: CardProps.className). */
97
+ readonly className?: string;
71
98
  readonly children: ReactNode;
72
99
  readonly testId?: string;
73
100
  };
@@ -125,6 +152,12 @@ export type InputProps =
125
152
  /** Browser-Autofill / Native-Keyboard-Hint. Web setzt das auf
126
153
  * `<input autocomplete=...>`, Native auf `textContentType`. */
127
154
  readonly autoComplete?: string;
155
+ /** `data-testid` aufs echte `<input>` — App-Tests adressieren das
156
+ * Element direkt (getByTestId + readOnly/disabled-Assertions). */
157
+ readonly testId?: string;
158
+ /** Read-only Input (z.B. gewürfelter Free-Tier-Slug). Nicht `disabled`
159
+ * — bleibt fokussier-/kopierbar. */
160
+ readonly readOnly?: boolean;
128
161
  }
129
162
  | {
130
163
  readonly kind: "email";
@@ -139,6 +172,7 @@ export type InputProps =
139
172
  /** Default "email". Apps die "username" wollen (Login-Form mit
140
173
  * Username-or-Email) reichen das durch. */
141
174
  readonly autoComplete?: string;
175
+ readonly testId?: string;
142
176
  }
143
177
  | {
144
178
  readonly kind: "password";
@@ -153,6 +187,7 @@ export type InputProps =
153
187
  * Browser-Password-Manager nutzen das für die Speicherentscheidung.
154
188
  * Native: textContentType="password" / "newPassword". */
155
189
  readonly autoComplete?: "current-password" | "new-password";
190
+ readonly testId?: string;
156
191
  }
157
192
  | {
158
193
  readonly kind: "number";
@@ -163,6 +198,20 @@ export type InputProps =
163
198
  readonly disabled?: boolean;
164
199
  readonly required?: boolean;
165
200
  readonly hasError?: boolean;
201
+ readonly testId?: string;
202
+ }
203
+ | {
204
+ readonly kind: "range";
205
+ readonly id: string;
206
+ readonly name: string;
207
+ readonly value: number;
208
+ readonly onChange: (v: number) => void;
209
+ readonly min: number;
210
+ readonly max: number;
211
+ readonly step?: number;
212
+ readonly disabled?: boolean;
213
+ readonly required?: boolean;
214
+ readonly hasError?: boolean;
166
215
  }
167
216
  | {
168
217
  readonly kind: "boolean";
@@ -511,6 +560,10 @@ export type SectionProps = {
511
560
  * übernehmen"). Web rendert standalone eine abgehobene Footer-Row
512
561
  * (border-t), innerhalb eines Forms eine rechtsbündige Button-Reihe. */
513
562
  readonly actions?: ReactNode;
563
+ /** "destructive" marks the Section as a warning/danger area (e.g. account
564
+ * deletion, restrict processing) — border color only, no content change.
565
+ * Default "default" (normal card border). */
566
+ readonly variant?: "default" | "destructive";
514
567
  readonly testId?: string;
515
568
  };
516
569
 
@@ -533,9 +586,10 @@ export type GridCellProps = {
533
586
  /** Semantischer Text. Variants bilden Standard-Typografie-Rollen ab —
534
587
  * `body` ist Default, `small` für sekundäre Labels, `code` für inline
535
588
  * monospace (entityId, screen-id), `required-mark` für das Sternchen
536
- * hinter Labels. Custom-Impls mappen auf ihren TypeScale. */
589
+ * hinter Labels, `muted` für gedimmten Fließtext (text-sm muted-
590
+ * foreground). Custom-Impls mappen auf ihren TypeScale. */
537
591
  export type TextProps = {
538
- readonly variant?: "body" | "small" | "code" | "required-mark";
592
+ readonly variant?: "body" | "small" | "code" | "required-mark" | "muted";
539
593
  readonly children: ReactNode;
540
594
  readonly testId?: string;
541
595
  };
@@ -581,6 +635,16 @@ export type DialogProps = {
581
635
  readonly testId?: string;
582
636
  };
583
637
 
638
+ /** Image lightbox — full-size preview on click. Web renders Radix overlay;
639
+ * trigger (thumbnail) and open state live in the app. */
640
+ export type LightboxProps = {
641
+ readonly open: boolean;
642
+ readonly onOpenChange: (open: boolean) => void;
643
+ readonly src: string;
644
+ readonly alt: string;
645
+ readonly testId?: string;
646
+ };
647
+
584
648
  /** Source-badge for one cascade step (User / Tenant / System / …).
585
649
  * Used inline next to a config value to indicate where it came from.
586
650
  * Requires a LocaleProvider above it (labels run through useTranslation)
@@ -661,8 +725,10 @@ export type CorePrimitives = {
661
725
  readonly Text: ComponentType<TextProps>;
662
726
  readonly Heading: ComponentType<HeadingProps>;
663
727
  readonly Dialog: ComponentType<DialogProps>;
728
+ readonly Lightbox: ComponentType<LightboxProps>;
664
729
  readonly ConfigSourceBadge: ComponentType<ConfigSourceBadgeProps>;
665
730
  readonly ConfigCascadeView: ComponentType<ConfigCascadeViewProps>;
731
+ readonly Link: ComponentType<LinkProps>;
666
732
  };
667
733
 
668
734
  /** Offene Extension-Zone für App-eigene Primitives. Devs erweitern
@@ -0,0 +1,20 @@
1
+ import type { DataTableSort } from "./primitives";
2
+
3
+ /** Sorts `rows` by a `DataTableSort` against a field->accessor map. Unknown
4
+ * field or `sort === null` returns `rows` unchanged (no-op, not an error —
5
+ * callers pass whatever the DataTable reports). */
6
+ export function sortByAccessor<TRow>(
7
+ rows: readonly TRow[],
8
+ sort: DataTableSort | null,
9
+ accessors: Readonly<Record<string, (row: TRow) => string | number>>,
10
+ ): readonly TRow[] {
11
+ if (sort === null) return rows;
12
+ const accessor = accessors[sort.field];
13
+ if (accessor === undefined) return rows;
14
+ const factor = sort.dir === "asc" ? 1 : -1;
15
+ return [...rows].sort((a, b) => {
16
+ const av = accessor(a);
17
+ const bv = accessor(b);
18
+ return av < bv ? -factor : av > bv ? factor : 0;
19
+ });
20
+ }