@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.
- package/package.json +5 -4
- package/src/__tests__/error-i18n-defaults.test.ts +13 -0
- package/src/__tests__/format-when.test.ts +12 -0
- package/src/__tests__/i18n.test.tsx +59 -0
- package/src/__tests__/qn.test.ts +44 -1
- package/src/__tests__/sort-by-accessor.test.ts +48 -0
- package/src/app/__tests__/config-edit-shim.test.ts +40 -0
- package/src/app/__tests__/screen-access-allows.test.ts +24 -0
- package/src/app/dashboard-body.tsx +32 -0
- package/src/app/extension-sections.tsx +11 -4
- package/src/app/kumiko-screen.tsx +408 -20
- package/src/app/projection-detail-shim.ts +72 -0
- package/src/app/projection-list-shim.ts +62 -0
- package/src/app/qn.ts +13 -0
- package/src/components/__tests__/render-field-app-locale.test.tsx +2 -0
- package/src/components/render-edit-logic.ts +8 -5
- package/src/components/render-edit.tsx +25 -2
- package/src/components/render-field.tsx +2 -1
- package/src/components/render-list.tsx +24 -2
- package/src/context/user-roles-context.tsx +27 -0
- package/src/format-when.ts +11 -0
- package/src/hooks/__tests__/use-ai-text.test.tsx +177 -0
- package/src/hooks/__tests__/use-disclosure.test.tsx +25 -0
- package/src/hooks/__tests__/use-mutation.test.tsx +77 -0
- package/src/hooks/__tests__/use-stream-handler.test.tsx +107 -0
- package/src/hooks/use-ai-text.ts +172 -0
- package/src/hooks/use-disclosure.ts +20 -0
- package/src/hooks/use-mutation.ts +61 -0
- package/src/hooks/use-query.ts +5 -2
- package/src/hooks/use-reference-lookup.ts +2 -1
- package/src/hooks/use-stream-handler.ts +134 -0
- package/src/i18n-defaults.ts +56 -0
- package/src/i18n.tsx +72 -31
- package/src/index.ts +31 -0
- package/src/primitives.tsx +70 -4
- 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 {
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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 {
|
package/src/primitives.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|