@cosmicdrift/kumiko-renderer 0.1.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 +42 -0
- package/src/__tests__/i18n.test.tsx +127 -0
- package/src/__tests__/qn.test.ts +40 -0
- package/src/__tests__/use-list-url-state.test.tsx +161 -0
- package/src/app/action-form-shim.ts +50 -0
- package/src/app/column-renderers.tsx +64 -0
- package/src/app/config-edit-shim.ts +48 -0
- package/src/app/custom-screens.tsx +29 -0
- package/src/app/feature-schema.ts +59 -0
- package/src/app/kumiko-screen.tsx +1050 -0
- package/src/app/nav.tsx +124 -0
- package/src/app/qn.ts +23 -0
- package/src/components/render-edit.tsx +346 -0
- package/src/components/render-field.tsx +299 -0
- package/src/components/render-list.tsx +402 -0
- package/src/context/dispatcher-context.tsx +59 -0
- package/src/hooks/reference-limits.ts +18 -0
- package/src/hooks/use-form.ts +88 -0
- package/src/hooks/use-list-url-state.ts +113 -0
- package/src/hooks/use-query.ts +129 -0
- package/src/hooks/use-reference-lookup.ts +54 -0
- package/src/hooks/use-store.ts +47 -0
- package/src/i18n-defaults.ts +94 -0
- package/src/i18n.tsx +158 -0
- package/src/index.ts +104 -0
- package/src/primitives.tsx +528 -0
- package/src/sse/live-events.tsx +56 -0
- package/src/tokens.tsx +142 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
// Primitives-Contract. Plattform-neutral — die Types beschreiben die
|
|
2
|
+
// semantische Oberfläche, die konkrete Implementation (HTML, React
|
|
3
|
+
// Native) kommt aus `@cosmicdrift/kumiko-renderer-web` oder `@cosmicdrift/kumiko-renderer-native`.
|
|
4
|
+
//
|
|
5
|
+
// Die Renderer (RenderEdit, RenderList, RenderField, KumikoScreen)
|
|
6
|
+
// konsumieren nur diesen Context. Es gibt KEIN Default-Registry hier
|
|
7
|
+
// — jede App muss eine vollständige Registry stellen; die Plattform-
|
|
8
|
+
// Packages liefern defaultPrimitives mit, die via createKumikoApp
|
|
9
|
+
// durchgereicht werden.
|
|
10
|
+
//
|
|
11
|
+
// Core-Primitives (Kumikos eigene Components konsumieren sie):
|
|
12
|
+
//
|
|
13
|
+
// Button — submit/button, disabled, onClick, variant
|
|
14
|
+
// Banner — role=alert Box für Fehler/Info, optional Actions
|
|
15
|
+
// Field — label + issues um ein Input-Control
|
|
16
|
+
// Input — discriminated union über text/number/boolean/date
|
|
17
|
+
// DataTable — Spalten + Zeilen + onRowClick, Empty-State intern
|
|
18
|
+
// Form — submit-Wrapper (Web: <form>, Native: View + onSubmit)
|
|
19
|
+
// Section — titled Gruppe von Feldern (Web: <fieldset>+<legend>)
|
|
20
|
+
// Grid — columns-basiertes Layout innerhalb einer Section
|
|
21
|
+
// Text — semantische Text-Variante (body/small/code/required-mark)
|
|
22
|
+
//
|
|
23
|
+
// App-Primitives (der App-Dev füttert eigene):
|
|
24
|
+
//
|
|
25
|
+
// Das `AppPrimitives`-Interface ist leer — Devs erweitern es via
|
|
26
|
+
// Module-Augmentation mit ihren eigenen Components. Die wandern
|
|
27
|
+
// automatisch in `PrimitivesRegistry` und `usePrimitives()` liefert
|
|
28
|
+
// sie typed aus. Kumikos Components nutzen sie NICHT (und kennen sie
|
|
29
|
+
// nicht), aber der App-Code hat ein einheitliches Primitive-Vokabular
|
|
30
|
+
// über Core + Custom.
|
|
31
|
+
|
|
32
|
+
import type {
|
|
33
|
+
FieldIssue,
|
|
34
|
+
ListColumnViewModel,
|
|
35
|
+
ListRowViewModel,
|
|
36
|
+
} from "@cosmicdrift/kumiko-headless";
|
|
37
|
+
import {
|
|
38
|
+
type ComponentType,
|
|
39
|
+
createContext,
|
|
40
|
+
type FormEvent,
|
|
41
|
+
type ReactNode,
|
|
42
|
+
useContext,
|
|
43
|
+
} from "react";
|
|
44
|
+
|
|
45
|
+
// ---- Prop-Types (die Primitive-Contract-Oberfläche) ----
|
|
46
|
+
|
|
47
|
+
/** Standard-Button. `loading` zeigt einen Spinner statt der Children
|
|
48
|
+
* und sollte mit `disabled` kombiniert werden, wenn die Action wirklich
|
|
49
|
+
* blockiert bis das Loading durch ist (z.B. async submit). Native-
|
|
50
|
+
* Impls können den Spinner als Activity-Indicator rendern. */
|
|
51
|
+
export type ButtonProps = {
|
|
52
|
+
readonly type?: "button" | "submit";
|
|
53
|
+
readonly onClick?: () => void | Promise<void>;
|
|
54
|
+
readonly disabled?: boolean;
|
|
55
|
+
/** Spinner statt Children rendern. Caller sollte `disabled` mit-
|
|
56
|
+
* setzen wenn die Action blockiert bis das Loading abgeschlossen
|
|
57
|
+
* ist (verhindert Double-Submit). */
|
|
58
|
+
readonly loading?: boolean;
|
|
59
|
+
/** Semantische Klasse — default="primary". Custom-Impls entscheiden
|
|
60
|
+
* was daraus visuell wird; die Renderer verwenden "primary" für
|
|
61
|
+
* Save, "danger" für Delete, "secondary" für Confirm-State. */
|
|
62
|
+
readonly variant?: "primary" | "secondary" | "danger";
|
|
63
|
+
readonly children: ReactNode;
|
|
64
|
+
readonly testId?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Banner für inline-Message ODER Page-State (z.B. "Loading…",
|
|
68
|
+
* "Screen not found"). `padded` setzt einen Außenabstand damit der
|
|
69
|
+
* Banner nicht edge-to-edge an den Main-Border klebt — relevant
|
|
70
|
+
* seit `<main>` kein eigenes Padding mehr hat. */
|
|
71
|
+
export type BannerProps = {
|
|
72
|
+
/** "error" für Alerts (Konflikt, Netzfehler), "info" für neutrale
|
|
73
|
+
* Platzhalter (Not-Found, Loading), "loading" für Lade-States. */
|
|
74
|
+
readonly variant?: "error" | "info" | "loading";
|
|
75
|
+
readonly children: ReactNode;
|
|
76
|
+
/** Optional — weitere Knöpfe/Elemente rechts vom Text (z.B. "Neu
|
|
77
|
+
* laden"). Inline, nicht als eigener Block. */
|
|
78
|
+
readonly actions?: ReactNode;
|
|
79
|
+
/** Setzt einen Außenabstand um den Banner — für Page-States wo der
|
|
80
|
+
* Banner alleine im Main rendert (KumikoScreen "not-found",
|
|
81
|
+
* "loading", etc.). Web fügt p-6 als Margin um den Banner. */
|
|
82
|
+
readonly padded?: boolean;
|
|
83
|
+
readonly testId?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type FieldProps = {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
readonly label: string;
|
|
89
|
+
readonly required?: boolean;
|
|
90
|
+
readonly issues?: readonly FieldIssue[];
|
|
91
|
+
readonly children: ReactNode;
|
|
92
|
+
readonly testId?: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Discriminated union — jede Input-Sorte hat ihre eigene value/onChange
|
|
96
|
+
* Signatur. Custom-Impls dispatchen intern, rendern anders (Toggle
|
|
97
|
+
* statt Checkbox), oder nur einzelne kinds unterschiedlich. */
|
|
98
|
+
export type InputProps =
|
|
99
|
+
| {
|
|
100
|
+
readonly kind: "text";
|
|
101
|
+
readonly id: string;
|
|
102
|
+
readonly name: string;
|
|
103
|
+
readonly value: string;
|
|
104
|
+
readonly onChange: (v: string) => void;
|
|
105
|
+
readonly disabled?: boolean;
|
|
106
|
+
readonly required?: boolean;
|
|
107
|
+
readonly hasError?: boolean;
|
|
108
|
+
/** Hint-Text wenn das Feld leer ist. Used für Search-Inputs in
|
|
109
|
+
* Toolbars ("Suchen…") wo kein Label sinnvoll ist. */
|
|
110
|
+
readonly placeholder?: string;
|
|
111
|
+
/** Browser-Autofill / Native-Keyboard-Hint. Web setzt das auf
|
|
112
|
+
* `<input autocomplete=...>`, Native auf `textContentType`. */
|
|
113
|
+
readonly autoComplete?: string;
|
|
114
|
+
}
|
|
115
|
+
| {
|
|
116
|
+
readonly kind: "email";
|
|
117
|
+
readonly id: string;
|
|
118
|
+
readonly name: string;
|
|
119
|
+
readonly value: string;
|
|
120
|
+
readonly onChange: (v: string) => void;
|
|
121
|
+
readonly disabled?: boolean;
|
|
122
|
+
readonly required?: boolean;
|
|
123
|
+
readonly hasError?: boolean;
|
|
124
|
+
readonly placeholder?: string;
|
|
125
|
+
/** Default "email". Apps die "username" wollen (Login-Form mit
|
|
126
|
+
* Username-or-Email) reichen das durch. */
|
|
127
|
+
readonly autoComplete?: string;
|
|
128
|
+
}
|
|
129
|
+
| {
|
|
130
|
+
readonly kind: "password";
|
|
131
|
+
readonly id: string;
|
|
132
|
+
readonly name: string;
|
|
133
|
+
readonly value: string;
|
|
134
|
+
readonly onChange: (v: string) => void;
|
|
135
|
+
readonly disabled?: boolean;
|
|
136
|
+
readonly required?: boolean;
|
|
137
|
+
readonly hasError?: boolean;
|
|
138
|
+
/** "current-password" für Login, "new-password" für Reset/Signup —
|
|
139
|
+
* Browser-Password-Manager nutzen das für die Speicherentscheidung.
|
|
140
|
+
* Native: textContentType="password" / "newPassword". */
|
|
141
|
+
readonly autoComplete?: "current-password" | "new-password";
|
|
142
|
+
}
|
|
143
|
+
| {
|
|
144
|
+
readonly kind: "number";
|
|
145
|
+
readonly id: string;
|
|
146
|
+
readonly name: string;
|
|
147
|
+
readonly value: number | "";
|
|
148
|
+
readonly onChange: (v: number | undefined) => void;
|
|
149
|
+
readonly disabled?: boolean;
|
|
150
|
+
readonly required?: boolean;
|
|
151
|
+
readonly hasError?: boolean;
|
|
152
|
+
}
|
|
153
|
+
| {
|
|
154
|
+
readonly kind: "boolean";
|
|
155
|
+
readonly id: string;
|
|
156
|
+
readonly name: string;
|
|
157
|
+
readonly value: boolean;
|
|
158
|
+
readonly onChange: (v: boolean) => void;
|
|
159
|
+
readonly disabled?: boolean;
|
|
160
|
+
readonly required?: boolean;
|
|
161
|
+
readonly hasError?: boolean;
|
|
162
|
+
}
|
|
163
|
+
| {
|
|
164
|
+
readonly kind: "date";
|
|
165
|
+
readonly id: string;
|
|
166
|
+
readonly name: string;
|
|
167
|
+
readonly value: string;
|
|
168
|
+
readonly onChange: (v: string | undefined) => void;
|
|
169
|
+
/** Locale für die Datum-Formatierung im Trigger. Default = Browser-
|
|
170
|
+
* Locale via navigator.language. Apps mit eigenem LocaleResolver
|
|
171
|
+
* können ihren current locale durchreichen. */
|
|
172
|
+
readonly locale?: string;
|
|
173
|
+
readonly disabled?: boolean;
|
|
174
|
+
readonly required?: boolean;
|
|
175
|
+
readonly hasError?: boolean;
|
|
176
|
+
}
|
|
177
|
+
| {
|
|
178
|
+
readonly kind: "select";
|
|
179
|
+
readonly id: string;
|
|
180
|
+
readonly name: string;
|
|
181
|
+
readonly value: string;
|
|
182
|
+
readonly onChange: (v: string) => void;
|
|
183
|
+
/** Erlaubte Werte. Leeres Array → Dropdown ohne Optionen, ein
|
|
184
|
+
* required Field ist dann nicht erfüllbar (Author-Hinweis, nicht
|
|
185
|
+
* Endnutzer). String-Form für statische Listen (Wert == Label),
|
|
186
|
+
* {value,label}-Form für DB-getragene Refs (Tier 2.7e-3). */
|
|
187
|
+
readonly options:
|
|
188
|
+
| readonly string[]
|
|
189
|
+
| readonly { readonly value: string; readonly label: string }[];
|
|
190
|
+
readonly disabled?: boolean;
|
|
191
|
+
readonly required?: boolean;
|
|
192
|
+
readonly hasError?: boolean;
|
|
193
|
+
}
|
|
194
|
+
| ({
|
|
195
|
+
// Tier 2.1c: Combobox / Searchable-Select. Single-Mode (multiple
|
|
196
|
+
// false oder weggelassen): value ist string, onChange string.
|
|
197
|
+
// Multi-Mode (multiple: true): value ist string[], onChange
|
|
198
|
+
// string[]. Reference-Felder (Tier 2.7e-3+) nutzen das hier ohne
|
|
199
|
+
// explizit Combobox zu setzen — der Renderer wählt Combobox
|
|
200
|
+
// automatisch für reference-Field-Types.
|
|
201
|
+
// Discriminated-Union per `multiple`: Single-Mode hat string-
|
|
202
|
+
// value/onChange, Multi-Mode hat readonly string[] — Caller
|
|
203
|
+
// muss den Mode beim Build wählen, der Compiler zwingt die
|
|
204
|
+
// richtige Signatur.
|
|
205
|
+
readonly kind: "combobox";
|
|
206
|
+
readonly id: string;
|
|
207
|
+
readonly name: string;
|
|
208
|
+
readonly options: readonly { readonly value: string; readonly label: string }[];
|
|
209
|
+
readonly disabled?: boolean;
|
|
210
|
+
readonly required?: boolean;
|
|
211
|
+
readonly hasError?: boolean;
|
|
212
|
+
readonly placeholder?: string;
|
|
213
|
+
readonly searchPlaceholder?: string;
|
|
214
|
+
readonly emptyText?: string;
|
|
215
|
+
/** Tier 2.7e Remote-Search: gesetzt = Combobox läuft im Remote-
|
|
216
|
+
* Mode (cmdk-Local-Filter aus, Search-Input ruft onSearchChange
|
|
217
|
+
* debounced an den Caller). */
|
|
218
|
+
readonly onSearchChange?: (q: string) => void;
|
|
219
|
+
readonly loading?: boolean;
|
|
220
|
+
} & (
|
|
221
|
+
| {
|
|
222
|
+
readonly multiple?: false;
|
|
223
|
+
readonly value: string;
|
|
224
|
+
readonly onChange: (v: string) => void;
|
|
225
|
+
}
|
|
226
|
+
| {
|
|
227
|
+
readonly multiple: true;
|
|
228
|
+
readonly value: readonly string[];
|
|
229
|
+
readonly onChange: (v: readonly string[]) => void;
|
|
230
|
+
}
|
|
231
|
+
))
|
|
232
|
+
| {
|
|
233
|
+
readonly kind: "money";
|
|
234
|
+
readonly id: string;
|
|
235
|
+
readonly name: string;
|
|
236
|
+
/** Internal-Format: Cents/Minor-Units als Integer (z.B. 1299 für
|
|
237
|
+
* 12,99 EUR). Renderer übersetzt fürs UI in Major-Units mit
|
|
238
|
+
* Locale-formatierten Dezimalstellen. Empty-State = `""`. */
|
|
239
|
+
readonly value: number | "";
|
|
240
|
+
readonly onChange: (v: number | undefined) => void;
|
|
241
|
+
/** ISO-4217 Currency-Code, z.B. "EUR" / "USD" / "CHF". Default
|
|
242
|
+
* "EUR". Renderer zeigt das Symbol als Suffix und formatiert die
|
|
243
|
+
* Decimal-Stellen entsprechend (EUR/USD/CHF haben 2, JPY hat 0). */
|
|
244
|
+
readonly currency?: string;
|
|
245
|
+
/** Locale für Zahlen-Formatierung. Default "de-DE" (Komma als
|
|
246
|
+
* Dezimaltrenner). Apps mit eigenem LocaleResolver können ihren
|
|
247
|
+
* current locale durchreichen. */
|
|
248
|
+
readonly locale?: string;
|
|
249
|
+
readonly disabled?: boolean;
|
|
250
|
+
readonly required?: boolean;
|
|
251
|
+
readonly hasError?: boolean;
|
|
252
|
+
}
|
|
253
|
+
| {
|
|
254
|
+
readonly kind: "timestamp";
|
|
255
|
+
readonly id: string;
|
|
256
|
+
readonly name: string;
|
|
257
|
+
/** ISO-8601 Datetime-String inkl. Zeit ("2026-04-25T13:45").
|
|
258
|
+
* Empty-State = `""`. Web nutzt `<input type="datetime-local">`. */
|
|
259
|
+
readonly value: string;
|
|
260
|
+
readonly onChange: (v: string | undefined) => void;
|
|
261
|
+
readonly disabled?: boolean;
|
|
262
|
+
readonly required?: boolean;
|
|
263
|
+
readonly hasError?: boolean;
|
|
264
|
+
}
|
|
265
|
+
| {
|
|
266
|
+
readonly kind: "textarea";
|
|
267
|
+
readonly id: string;
|
|
268
|
+
readonly name: string;
|
|
269
|
+
readonly value: string;
|
|
270
|
+
readonly onChange: (v: string) => void;
|
|
271
|
+
/** Anzahl sichtbarer Zeilen. Default 4 in der Default-Primitive
|
|
272
|
+
* — hinreichend für Notes, vertikal-scrollbar drüber. */
|
|
273
|
+
readonly rows?: number;
|
|
274
|
+
readonly disabled?: boolean;
|
|
275
|
+
readonly required?: boolean;
|
|
276
|
+
readonly hasError?: boolean;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Sort-Wire-Format. `null`-State unterscheidet "User hat noch nichts
|
|
280
|
+
// gesortiert" vom "ich sortiere nach X" — wichtig für 3-State-Toggle
|
|
281
|
+
// (asc → desc → null → asc …). Renderer kennt den aktuellen sort und
|
|
282
|
+
// callt onSortChange wenn der User auf einen sortable-Header klickt.
|
|
283
|
+
export type DataTableSortDir = "asc" | "desc";
|
|
284
|
+
export type DataTableSort = {
|
|
285
|
+
readonly field: string;
|
|
286
|
+
readonly dir: DataTableSortDir;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Resolved-Form einer Row-Action (KumikoScreen baut das aus
|
|
290
|
+
// EntityListScreenDefinition.rowActions): Labels schon translated,
|
|
291
|
+
// handler-QN aufgelöst zu einer onTrigger-Function die den dispatcher
|
|
292
|
+
// kennt. DataTable rendert das ohne weiteres i18n/dispatcher-Wissen.
|
|
293
|
+
export type DataTableRowAction = {
|
|
294
|
+
/** Stable id für aria-labels und data-testids. */
|
|
295
|
+
readonly id: string;
|
|
296
|
+
/** Translated Label. */
|
|
297
|
+
readonly label: string;
|
|
298
|
+
/** Visual-Style — danger triggert in der Default-Primitive eine rote
|
|
299
|
+
* Variante UND erzwingt einen Confirm-Dialog wenn keiner gesetzt ist. */
|
|
300
|
+
readonly style?: "primary" | "secondary" | "danger";
|
|
301
|
+
/** Translated Confirm-Prompt (Description im Dialog) — wenn gesetzt,
|
|
302
|
+
* öffnet ein Modal vor der Ausführung. Bei style=danger ohne expliziten
|
|
303
|
+
* confirm sollte der Renderer einen generischen Default zeigen. */
|
|
304
|
+
readonly confirm?: string;
|
|
305
|
+
/** Translated Confirm-Button-Label im Dialog. Default = `label`
|
|
306
|
+
* (Action-Label wird wiederverwendet). */
|
|
307
|
+
readonly confirmLabel?: string;
|
|
308
|
+
/** Wird mit der ListRowViewModel der geklickten Row aufgerufen. Async
|
|
309
|
+
* erlaubt — der Renderer kann während der Promise-Resolution einen
|
|
310
|
+
* Loading-State auf dem Button zeigen. */
|
|
311
|
+
readonly onTrigger: (row: ListRowViewModel) => Promise<void> | void;
|
|
312
|
+
/** Conditional Visibility pro Row (z.B. "Start" nur wenn status==="scheduled"). */
|
|
313
|
+
readonly isVisible?: (row: ListRowViewModel) => boolean;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
export type DataTableProps = {
|
|
317
|
+
readonly columns: readonly ListColumnViewModel[];
|
|
318
|
+
readonly rows: readonly ListRowViewModel[];
|
|
319
|
+
readonly onRowClick?: (row: ListRowViewModel) => void;
|
|
320
|
+
/** Aktuelle Sortierung (oder null = unsorted). Wenn columns ein
|
|
321
|
+
* `sortable: true`-Feld haben und onSortChange gesetzt ist, rendert
|
|
322
|
+
* der Renderer Click-Header mit Asc/Desc-Indikator. Ohne onSortChange
|
|
323
|
+
* bleibt die Header-Click-Mechanik aus, columns.sortable ist dann nur
|
|
324
|
+
* semantischer Hinweis. */
|
|
325
|
+
readonly sort?: DataTableSort | null;
|
|
326
|
+
/** Wird gerufen mit dem nächsten Sort-State nach einem Header-Klick.
|
|
327
|
+
* 3-State-Toggle (Convention): asc → desc → null. Caller setzt damit
|
|
328
|
+
* seinen URL-State / Query-Param und triggert ein refetch. */
|
|
329
|
+
readonly onSortChange?: (next: DataTableSort | null) => void;
|
|
330
|
+
/** Pro-Row-Aktionen — eine Spalte am rechten Rand mit Inline-Buttons
|
|
331
|
+
* (≤2 Aktionen) oder Kebab-Dropdown (>2). Caller liefert Resolved-
|
|
332
|
+
* Form (Labels + onTrigger schon verdrahtet); DataTable kümmert
|
|
333
|
+
* sich nur um Render + Confirm-Dialog. */
|
|
334
|
+
readonly rowActions?: readonly DataTableRowAction[];
|
|
335
|
+
/** Custom Empty-State-Inhalt (z. B. Icon + Heading + CTA-Button).
|
|
336
|
+
* Default-Renderer rahmt ihn in einer dashed-border Box. */
|
|
337
|
+
readonly emptyState?: ReactNode;
|
|
338
|
+
/** Optionaler Titel-Slot ganz links der Toolbar — Screen-Titel
|
|
339
|
+
* ("Items", "Bestellungen"). Web rendert als font-medium Heading. */
|
|
340
|
+
readonly toolbarTitle?: ReactNode;
|
|
341
|
+
/** Toolbar-Slot mittig (typisch Search-Input). Renderer entscheidet
|
|
342
|
+
* das Layout — Web spreizt das Element als flex-1 mit max-Breite,
|
|
343
|
+
* Native könnte es als Header-Suchleiste rendern. */
|
|
344
|
+
readonly toolbarStart?: ReactNode;
|
|
345
|
+
/** Toolbar-Slot rechts (typisch + Neu Button, Filter, View-Switch).
|
|
346
|
+
* Web zieht den Cluster mit ml-auto an die rechte Kante. */
|
|
347
|
+
readonly toolbarEnd?: ReactNode;
|
|
348
|
+
/** Pagination-State + Callback. Wenn gesetzt, rendert der Renderer
|
|
349
|
+
* einen Pager unter der Tabelle (Web: Footer-Bar mit ← 1 ... N →).
|
|
350
|
+
* total/limit/page sind 1-basiert für die UI; Server-Translation
|
|
351
|
+
* zu offset = (page-1)*limit liegt beim Caller. Wenn page > total/limit
|
|
352
|
+
* würde, soll der Caller das vorab clampen. */
|
|
353
|
+
readonly pager?: {
|
|
354
|
+
readonly page: number;
|
|
355
|
+
readonly limit: number;
|
|
356
|
+
readonly total: number;
|
|
357
|
+
readonly onPageChange: (next: number) => void;
|
|
358
|
+
};
|
|
359
|
+
/** Infinite-Scroll Callback. Wenn gesetzt, rendert der Renderer einen
|
|
360
|
+
* Bottom-Sentinel und ruft `onReachEnd` wenn der ins Viewport rückt
|
|
361
|
+
* (Web: IntersectionObserver). Caller verwaltet accumulation +
|
|
362
|
+
* cursor + hasMore. `loadingMore=true` zeigt einen Spinner unter
|
|
363
|
+
* der Tabelle solange die nächste Page lädt. Inkompatibel mit
|
|
364
|
+
* `pager` — entweder Pager ODER Infinite-Scroll, nicht beides. */
|
|
365
|
+
readonly onReachEnd?: () => void;
|
|
366
|
+
readonly loadingMore?: boolean;
|
|
367
|
+
/** Wenn false und onReachEnd gesetzt, rendert der Renderer einen
|
|
368
|
+
* "Ende der Liste"-Hinweis statt des Sentinels. Default true. */
|
|
369
|
+
readonly hasMore?: boolean;
|
|
370
|
+
readonly testId?: string;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/** Submit-Wrapper. Web: `<form onSubmit>`, Native: View das einen
|
|
374
|
+
* onSubmit-Callback via Button-Press triggert. `onSubmit` bekommt
|
|
375
|
+
* eine abstrakte Signatur (keine FormEvent) damit Native-Impls das
|
|
376
|
+
* sinnvoll füllen können.
|
|
377
|
+
*
|
|
378
|
+
* `title`: linker Slot der sticky-top Bar — typisch der Screen-Titel
|
|
379
|
+
* ("Neuer Eintrag", "Bestellung bearbeiten"). Wenn gesetzt, rendert
|
|
380
|
+
* die Bar mit `justify-between` (Title links, Actions rechts).
|
|
381
|
+
* `actions`: optionaler Slot für die primären Form-Aktionen (Save,
|
|
382
|
+
* Cancel). Web rendert die Bar sticky-top, damit der Save-Button
|
|
383
|
+
* bei langen Forms beim Scrollen erreichbar bleibt. Native-Impls
|
|
384
|
+
* dürfen denselben Slot z. B. als Bottom-Bar rendern. */
|
|
385
|
+
export type FormProps = {
|
|
386
|
+
readonly onSubmit: (e?: FormEvent) => void;
|
|
387
|
+
readonly children: ReactNode;
|
|
388
|
+
readonly title?: ReactNode;
|
|
389
|
+
readonly actions?: ReactNode;
|
|
390
|
+
readonly testId?: string;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
/** Titled Gruppe von Feldern. Web: `<fieldset>` + `<legend>`, Native:
|
|
394
|
+
* View mit Header-Text. Native-Impls können den Title als Accordion
|
|
395
|
+
* oder Collapsible rendern. */
|
|
396
|
+
export type SectionProps = {
|
|
397
|
+
readonly title: string;
|
|
398
|
+
readonly children: ReactNode;
|
|
399
|
+
readonly testId?: string;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/** Columns-basiertes Layout. Web: CSS grid, Native: Flex-Wrap mit
|
|
403
|
+
* Width-%, oder react-native-grid. Jedes direkte Child kann eine
|
|
404
|
+
* `GridCell`-Wrapping bekommen für span-Kontrolle. */
|
|
405
|
+
export type GridProps = {
|
|
406
|
+
readonly columns: number;
|
|
407
|
+
readonly children: ReactNode;
|
|
408
|
+
readonly testId?: string;
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
/** Span-Wrapper für ein Kind innerhalb eines Grid. Web: `style={{gridColumn: span N}}`,
|
|
412
|
+
* Native: eigenes Width-Rechnen. */
|
|
413
|
+
export type GridCellProps = {
|
|
414
|
+
readonly span?: number;
|
|
415
|
+
readonly children: ReactNode;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/** Semantischer Text. Variants bilden Standard-Typografie-Rollen ab —
|
|
419
|
+
* `body` ist Default, `small` für sekundäre Labels, `code` für inline
|
|
420
|
+
* monospace (entityId, screen-id), `required-mark` für das Sternchen
|
|
421
|
+
* hinter Labels. Custom-Impls mappen auf ihren TypeScale. */
|
|
422
|
+
export type TextProps = {
|
|
423
|
+
readonly variant?: "body" | "small" | "code" | "required-mark";
|
|
424
|
+
readonly children: ReactNode;
|
|
425
|
+
readonly testId?: string;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/** Heading mit zwei Rollen — `page` als Page-Titel (Web: h1), `section`
|
|
429
|
+
* als Sub-Header über einer Group (Web: h2 mit uppercase + muted).
|
|
430
|
+
* Native-Impls mappen auf `<Text>` mit entsprechendem fontWeight/Size. */
|
|
431
|
+
export type HeadingProps = {
|
|
432
|
+
readonly variant?: "page" | "section";
|
|
433
|
+
readonly children: ReactNode;
|
|
434
|
+
readonly testId?: string;
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/** Modal-Dialog für Bestätigungen oder kompakte Sub-Forms. Web rendert
|
|
438
|
+
* Radix-Dialog (Focus-Trap, Esc-Schließen, Overlay-Click); Native
|
|
439
|
+
* würde ein Native-Modal nutzen. Apps öffnen den Dialog über einen
|
|
440
|
+
* External-State (`open` + `onOpenChange`); Confirm-Action läuft
|
|
441
|
+
* durch `onConfirm`, Cancel klappt zu via `onOpenChange(false)`.
|
|
442
|
+
*
|
|
443
|
+
* Variant `danger` markiert destruktive Bestätigungen visuell
|
|
444
|
+
* (rote Confirm-Button-Klasse), `default` für neutrale Dialoge. */
|
|
445
|
+
export type DialogProps = {
|
|
446
|
+
readonly open: boolean;
|
|
447
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
448
|
+
readonly title: string;
|
|
449
|
+
/** Optional Beschreibung — typisch ein Satz der die Konsequenz
|
|
450
|
+
* erklärt ("Diese Aktion lässt sich nicht rückgängig machen."). */
|
|
451
|
+
readonly description?: string;
|
|
452
|
+
/** Confirm-Button-Label. Default kommt aus i18n
|
|
453
|
+
* (`kumiko.dialog.confirm`). */
|
|
454
|
+
readonly confirmLabel?: string;
|
|
455
|
+
/** Cancel-Button-Label. Default `kumiko.dialog.cancel`. */
|
|
456
|
+
readonly cancelLabel?: string;
|
|
457
|
+
/** `default` = Confirm primary, `danger` = Confirm danger. */
|
|
458
|
+
readonly variant?: "default" | "danger";
|
|
459
|
+
/** Wird gefeuert wenn der User Confirm drückt. Async-Funktion ist
|
|
460
|
+
* ok — Dialog setzt automatisch loading-State, ruft danach
|
|
461
|
+
* onOpenChange(false). */
|
|
462
|
+
readonly onConfirm: () => void | Promise<void>;
|
|
463
|
+
/** Optional zusätzlicher Inhalt zwischen description und Buttons
|
|
464
|
+
* (z.B. ein Input wenn der Dialog auch Eingaben sammelt). */
|
|
465
|
+
readonly children?: ReactNode;
|
|
466
|
+
readonly testId?: string;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// ---- Core-Registry (Kumiko-eigene Primitives) ----
|
|
470
|
+
|
|
471
|
+
export type CorePrimitives = {
|
|
472
|
+
readonly Button: ComponentType<ButtonProps>;
|
|
473
|
+
readonly Banner: ComponentType<BannerProps>;
|
|
474
|
+
readonly Field: ComponentType<FieldProps>;
|
|
475
|
+
readonly Input: ComponentType<InputProps>;
|
|
476
|
+
readonly DataTable: ComponentType<DataTableProps>;
|
|
477
|
+
readonly Form: ComponentType<FormProps>;
|
|
478
|
+
readonly Section: ComponentType<SectionProps>;
|
|
479
|
+
readonly Grid: ComponentType<GridProps>;
|
|
480
|
+
readonly GridCell: ComponentType<GridCellProps>;
|
|
481
|
+
readonly Text: ComponentType<TextProps>;
|
|
482
|
+
readonly Heading: ComponentType<HeadingProps>;
|
|
483
|
+
readonly Dialog: ComponentType<DialogProps>;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
/** Offene Extension-Zone für App-eigene Primitives. Devs erweitern
|
|
487
|
+
* dieses Interface via TypeScript Module-Augmentation:
|
|
488
|
+
*
|
|
489
|
+
* declare module "@cosmicdrift/kumiko-renderer" {
|
|
490
|
+
* interface AppPrimitives {
|
|
491
|
+
* Chip: ComponentType<ChipProps>;
|
|
492
|
+
* Accordion: ComponentType<AccordionProps>;
|
|
493
|
+
* }
|
|
494
|
+
* }
|
|
495
|
+
*
|
|
496
|
+
* Nach der Augmentation tauchen die Keys in `PrimitivesRegistry`,
|
|
497
|
+
* `createKumikoApp({ primitives: { Chip, Accordion } })`, und
|
|
498
|
+
* `usePrimitives().Chip` auf — TypeScript-gestützt. Kumikos eigene
|
|
499
|
+
* Components nutzen ausschließlich `CorePrimitives`; App-Primitives
|
|
500
|
+
* sind ausschließlich für Dev-Code (Custom-Screens, Shell, eigene
|
|
501
|
+
* Components). */
|
|
502
|
+
// biome-ignore lint/suspicious/noEmptyInterface: extension point for module augmentation
|
|
503
|
+
export interface AppPrimitives {}
|
|
504
|
+
|
|
505
|
+
export type PrimitivesRegistry = CorePrimitives & AppPrimitives;
|
|
506
|
+
|
|
507
|
+
// ---- Context + Provider + Hook ----
|
|
508
|
+
|
|
509
|
+
const PrimitivesContext = createContext<PrimitivesRegistry | undefined>(undefined);
|
|
510
|
+
|
|
511
|
+
export type PrimitivesProviderProps = {
|
|
512
|
+
readonly children: ReactNode;
|
|
513
|
+
readonly value: PrimitivesRegistry;
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
export function PrimitivesProvider({ children, value }: PrimitivesProviderProps): ReactNode {
|
|
517
|
+
return <PrimitivesContext.Provider value={value}>{children}</PrimitivesContext.Provider>;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function usePrimitives(): PrimitivesRegistry {
|
|
521
|
+
const registry = useContext(PrimitivesContext);
|
|
522
|
+
if (registry === undefined) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
"usePrimitives: no <PrimitivesProvider> mounted. Wrap your app in one (createKumikoApp does this for you with defaultPrimitives from @cosmicdrift/kumiko-renderer-web).",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
return registry;
|
|
528
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Live-Events-Contract, plattform-neutral. Die Plattform (Web mit
|
|
2
|
+
// EventSource, Native mit polyfill) liefert einen `LiveEventSubscriber`
|
|
3
|
+
// via `<LiveEventsProvider value={...}>`; der shared-Layer konsumiert
|
|
4
|
+
// nur das Interface.
|
|
5
|
+
//
|
|
6
|
+
// Warum ein Context statt Module-Singleton wie vor dem Split: ein
|
|
7
|
+
// Module-Singleton koppelt an eine globale Verbindung. Mit Context
|
|
8
|
+
// kann der Caller pro Tree eine andere Subscribe-Quelle durchreichen
|
|
9
|
+
// — nützlich in Tests (Fake-Feed), bei Multi-Tenant-Bridges, und
|
|
10
|
+
// beim Native-Renderer wo die Verbindungs-Lifecycle oft nicht dem
|
|
11
|
+
// App-Lifecycle entspricht.
|
|
12
|
+
|
|
13
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
14
|
+
|
|
15
|
+
export type LiveEvent = {
|
|
16
|
+
readonly type: string;
|
|
17
|
+
readonly data: {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly aggregateType: string;
|
|
20
|
+
readonly version: number;
|
|
21
|
+
readonly payload: unknown;
|
|
22
|
+
readonly createdAt: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Abonniert Live-Events für eine Entity (aggregateType). Returnt die
|
|
27
|
+
* Unsubscribe-Funktion. Mehrere Subscriptions parallel auf dieselbe
|
|
28
|
+
* Entity sind erlaubt — jede bekommt ihr eigenes Event. */
|
|
29
|
+
export type LiveEventSubscriber = (
|
|
30
|
+
entityName: string,
|
|
31
|
+
listener: (event: LiveEvent) => void,
|
|
32
|
+
) => () => void;
|
|
33
|
+
|
|
34
|
+
// Wenn kein Provider da ist, liefern wir einen No-op-Subscriber statt
|
|
35
|
+
// zu crashen. Grund: useQuery({ live: true }) kann optimistisch
|
|
36
|
+
// ausgehakt werden ohne den ganzen Baum abzureißen, wenn die Plattform
|
|
37
|
+
// z.B. SSE temporär deaktiviert hat. Fehler-Signale würden stumm
|
|
38
|
+
// geschluckt — das ist explizit dokumentiert.
|
|
39
|
+
const noopSubscriber: LiveEventSubscriber = () => () => {};
|
|
40
|
+
|
|
41
|
+
const LiveEventsContext = createContext<LiveEventSubscriber>(noopSubscriber);
|
|
42
|
+
|
|
43
|
+
export type LiveEventsProviderProps = {
|
|
44
|
+
readonly children: ReactNode;
|
|
45
|
+
readonly value: LiveEventSubscriber;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function LiveEventsProvider({ children, value }: LiveEventsProviderProps): ReactNode {
|
|
49
|
+
return <LiveEventsContext.Provider value={value}>{children}</LiveEventsContext.Provider>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Hook für useQuery-like Consumer. Liefert die Subscribe-Function
|
|
53
|
+
* aus dem Context. Wenn kein Provider da ist, ist's ein No-op. */
|
|
54
|
+
export function useLiveEvents(): LiveEventSubscriber {
|
|
55
|
+
return useContext(LiveEventsContext);
|
|
56
|
+
}
|