@cosmicdrift/kumiko-renderer-web 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 +63 -0
- package/src/__tests__/avatar.test.tsx +34 -0
- package/src/__tests__/combobox.test.tsx +240 -0
- package/src/__tests__/config-edit.test.tsx +172 -0
- package/src/__tests__/create-app.test.tsx +261 -0
- package/src/__tests__/date-input.test.tsx +91 -0
- package/src/__tests__/default-app-shell.test.tsx +60 -0
- package/src/__tests__/dispatcher-context.test.tsx +101 -0
- package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
- package/src/__tests__/kumiko-screen.test.tsx +1014 -0
- package/src/__tests__/language-switcher.test.tsx +100 -0
- package/src/__tests__/money-input.test.tsx +232 -0
- package/src/__tests__/nav-base-path.test.tsx +388 -0
- package/src/__tests__/nav-search-params.test.tsx +88 -0
- package/src/__tests__/nav-tree.test.tsx +183 -0
- package/src/__tests__/nav.test.tsx +253 -0
- package/src/__tests__/primitives.test.tsx +936 -0
- package/src/__tests__/render-edit.test.tsx +178 -0
- package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
- package/src/__tests__/render-list-debounce.test.tsx +128 -0
- package/src/__tests__/render-list.test.tsx +151 -0
- package/src/__tests__/sidebar.test.tsx +59 -0
- package/src/__tests__/test-utils.tsx +144 -0
- package/src/__tests__/theme-toggle.test.tsx +101 -0
- package/src/__tests__/toast.test.tsx +162 -0
- package/src/__tests__/use-form.test.tsx +112 -0
- package/src/__tests__/use-query-live.test.tsx +152 -0
- package/src/__tests__/use-query.test.tsx +88 -0
- package/src/__tests__/use-store.test.tsx +139 -0
- package/src/__tests__/workspace-shell.test.tsx +772 -0
- package/src/app/browser-locale.ts +85 -0
- package/src/app/client-plugin.tsx +63 -0
- package/src/app/create-app.tsx +380 -0
- package/src/app/nav.tsx +226 -0
- package/src/index.ts +137 -0
- package/src/layout/app-layout.tsx +35 -0
- package/src/layout/avatar.tsx +93 -0
- package/src/layout/default-app-shell.tsx +74 -0
- package/src/layout/language-switcher.tsx +101 -0
- package/src/layout/nav-tree.tsx +281 -0
- package/src/layout/profile-menu.tsx +40 -0
- package/src/layout/sidebar.tsx +65 -0
- package/src/layout/theme-toggle.tsx +44 -0
- package/src/layout/topbar.tsx +22 -0
- package/src/layout/workspace-shell.tsx +282 -0
- package/src/layout/workspace-switcher.tsx +62 -0
- package/src/lib/cn.ts +10 -0
- package/src/primitives/action-menu.tsx +111 -0
- package/src/primitives/combobox.tsx +261 -0
- package/src/primitives/date-input.tsx +165 -0
- package/src/primitives/dialog.tsx +119 -0
- package/src/primitives/dropdown-menu.tsx +103 -0
- package/src/primitives/index.tsx +1271 -0
- package/src/primitives/money-input.tsx +192 -0
- package/src/primitives/toast.tsx +166 -0
- package/src/sse/live-events.ts +90 -0
- package/src/styles.css +113 -0
- package/src/tokens.ts +63 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// MoneyInput — type=text mit focus-aware Locale-Format. Canonical-Wert
|
|
2
|
+
// bleiben Minor-Units (Cents) wie auf der Wire; während Focus zeigt das
|
|
3
|
+
// Input einen rohen, editierbaren Decimal-String. Bei Blur formatiert
|
|
4
|
+
// Intl.NumberFormat mit `style: "currency"` — das liefert Currency-
|
|
5
|
+
// Symbol (€/$/¥) UND Tausender-Trenner UND korrekte Decimals in einem
|
|
6
|
+
// Aufruf.
|
|
7
|
+
//
|
|
8
|
+
// Warum nicht type=number: number-Inputs lehnen formatierte Strings
|
|
9
|
+
// ("1.234,56 €") ab — kein Browser akzeptiert Locale-Decimals (Komma)
|
|
10
|
+
// in number-Inputs. inputMode="decimal" gibt mobiles Numpad-Keyboard
|
|
11
|
+
// trotzdem.
|
|
12
|
+
//
|
|
13
|
+
// +/- Buttons mutieren den Canonical-Wert direkt (1 Major-Unit pro
|
|
14
|
+
// Klick — also 100 cents bei EUR/USD, 1 yen bei JPY). User der nur
|
|
15
|
+
// Cent-genaue Steps will tippt halt im Focus-Modus.
|
|
16
|
+
|
|
17
|
+
import { Minus, Plus } from "lucide-react";
|
|
18
|
+
import { type FocusEvent, type ReactNode, useState } from "react";
|
|
19
|
+
import { cn } from "../lib/cn";
|
|
20
|
+
|
|
21
|
+
export type MoneyInputProps = {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly value: number | "";
|
|
25
|
+
readonly onChange: (v: number | undefined) => void;
|
|
26
|
+
readonly currency: string;
|
|
27
|
+
readonly locale?: string;
|
|
28
|
+
readonly disabled?: boolean;
|
|
29
|
+
readonly required?: boolean;
|
|
30
|
+
readonly hasError?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const inputClass =
|
|
34
|
+
"flex h-9 w-full rounded-md border border-input bg-transparent pl-3 pr-1 text-sm shadow-sm " +
|
|
35
|
+
"transition-colors placeholder:text-muted-foreground focus-visible:outline-none " +
|
|
36
|
+
"focus-visible:ring-1 focus-visible:ring-ring " +
|
|
37
|
+
"disabled:cursor-not-allowed disabled:opacity-50 " +
|
|
38
|
+
// Numerische Inputs rechtsbündig — wie native type=number — damit
|
|
39
|
+
// Beträge unter Listen-Spalten an den Tausender-Stellen alignen.
|
|
40
|
+
"text-right tabular-nums";
|
|
41
|
+
|
|
42
|
+
const stepBtnClass =
|
|
43
|
+
"inline-flex h-7 w-7 items-center justify-center rounded-sm text-muted-foreground " +
|
|
44
|
+
"hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 " +
|
|
45
|
+
"focus-visible:ring-ring disabled:opacity-40 disabled:pointer-events-none";
|
|
46
|
+
|
|
47
|
+
export function MoneyInput({
|
|
48
|
+
id,
|
|
49
|
+
name,
|
|
50
|
+
value,
|
|
51
|
+
onChange,
|
|
52
|
+
currency,
|
|
53
|
+
locale,
|
|
54
|
+
disabled,
|
|
55
|
+
required,
|
|
56
|
+
hasError,
|
|
57
|
+
}: MoneyInputProps): ReactNode {
|
|
58
|
+
const decimals = currencyDecimals(currency);
|
|
59
|
+
const factor = 10 ** decimals;
|
|
60
|
+
const resolvedLocale = locale ?? guessLocale();
|
|
61
|
+
const [focused, setFocused] = useState(false);
|
|
62
|
+
// Raw-Edit-Buffer während Focus. Sonst würde jeder Tipp-Step durch
|
|
63
|
+
// Math.round → format-Roundtrip jagen und der Cursor würde springen.
|
|
64
|
+
const [draft, setDraft] = useState<string>("");
|
|
65
|
+
|
|
66
|
+
const major = value === "" ? null : value / factor;
|
|
67
|
+
|
|
68
|
+
const formatted =
|
|
69
|
+
major === null
|
|
70
|
+
? ""
|
|
71
|
+
: new Intl.NumberFormat(resolvedLocale, {
|
|
72
|
+
style: "currency",
|
|
73
|
+
currency,
|
|
74
|
+
minimumFractionDigits: decimals,
|
|
75
|
+
maximumFractionDigits: decimals,
|
|
76
|
+
}).format(major);
|
|
77
|
+
|
|
78
|
+
// Edit-Mode: Decimal-String ohne Tausender-Trenner. Konsistente Helper-
|
|
79
|
+
// Funktion damit Focus-Init und Render-Fallback nicht auseinanderdriften.
|
|
80
|
+
const toEditable = (m: number | null): string =>
|
|
81
|
+
m === null
|
|
82
|
+
? ""
|
|
83
|
+
: m.toLocaleString(resolvedLocale, {
|
|
84
|
+
minimumFractionDigits: decimals,
|
|
85
|
+
maximumFractionDigits: decimals,
|
|
86
|
+
useGrouping: false,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const editable = focused ? draft : toEditable(major);
|
|
90
|
+
|
|
91
|
+
const handleFocus = (_e: FocusEvent<HTMLInputElement>): void => {
|
|
92
|
+
setDraft(toEditable(major));
|
|
93
|
+
setFocused(true);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleBlur = (): void => {
|
|
97
|
+
setFocused(false);
|
|
98
|
+
if (draft.trim() === "") {
|
|
99
|
+
onChange(undefined);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const parsed = parseLocaleNumber(draft, resolvedLocale);
|
|
103
|
+
if (Number.isNaN(parsed)) return;
|
|
104
|
+
onChange(Math.round(parsed * factor));
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const bump = (delta: number): void => {
|
|
108
|
+
const current = value === "" ? 0 : value;
|
|
109
|
+
onChange(current + delta * factor);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="relative w-full">
|
|
114
|
+
<input
|
|
115
|
+
id={id}
|
|
116
|
+
name={name}
|
|
117
|
+
type="text"
|
|
118
|
+
inputMode="decimal"
|
|
119
|
+
autoComplete="off"
|
|
120
|
+
disabled={disabled}
|
|
121
|
+
aria-required={required}
|
|
122
|
+
aria-invalid={hasError === true ? true : undefined}
|
|
123
|
+
value={focused ? editable : formatted}
|
|
124
|
+
onFocus={handleFocus}
|
|
125
|
+
onBlur={handleBlur}
|
|
126
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
127
|
+
className={cn(
|
|
128
|
+
inputClass,
|
|
129
|
+
"pr-20",
|
|
130
|
+
hasError === true && "border-destructive focus-visible:ring-destructive",
|
|
131
|
+
)}
|
|
132
|
+
/>
|
|
133
|
+
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
aria-label="−"
|
|
137
|
+
tabIndex={-1}
|
|
138
|
+
disabled={disabled}
|
|
139
|
+
onClick={() => bump(-1)}
|
|
140
|
+
className={stepBtnClass}
|
|
141
|
+
>
|
|
142
|
+
<Minus className="size-3.5" aria-hidden="true" />
|
|
143
|
+
</button>
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
aria-label="+"
|
|
147
|
+
tabIndex={-1}
|
|
148
|
+
disabled={disabled}
|
|
149
|
+
onClick={() => bump(1)}
|
|
150
|
+
className={stepBtnClass}
|
|
151
|
+
>
|
|
152
|
+
<Plus className="size-3.5" aria-hidden="true" />
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Currency-Decimal-Stellen — überdeckt die wichtigsten Welt-Währungen.
|
|
160
|
+
// Default 2 wenn Code unbekannt.
|
|
161
|
+
export function currencyDecimals(code: string): number {
|
|
162
|
+
if (code === "JPY" || code === "KRW" || code === "VND" || code === "ISK") return 0;
|
|
163
|
+
if (code === "BHD" || code === "JOD" || code === "KWD" || code === "OMR" || code === "TND")
|
|
164
|
+
return 3;
|
|
165
|
+
return 2;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function guessLocale(): string {
|
|
169
|
+
if (typeof navigator !== "undefined" && navigator.language) return navigator.language;
|
|
170
|
+
return "en-US";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Locale-Decimal-Parse: erkennt automatisch ob Komma oder Punkt der
|
|
174
|
+
// Decimal-Separator ist. Intl.NumberFormat liefert die Trenner für
|
|
175
|
+
// das Locale, daraus bauen wir den Reverse-Parser. Strict beim
|
|
176
|
+
// Vorzeichen: ein `-` darf NUR ganz vorne stehen — `1-23` ist invalid,
|
|
177
|
+
// nicht `-123` (sonst würden vertippte Inputs zu falschen Beträgen).
|
|
178
|
+
export function parseLocaleNumber(raw: string, locale: string): number {
|
|
179
|
+
const parts = new Intl.NumberFormat(locale).formatToParts(1234.5);
|
|
180
|
+
const groupSep = parts.find((p) => p.type === "group")?.value ?? ",";
|
|
181
|
+
const decimalSep = parts.find((p) => p.type === "decimal")?.value ?? ".";
|
|
182
|
+
const trimmed = raw.trim();
|
|
183
|
+
const negative = trimmed.startsWith("-");
|
|
184
|
+
const body = negative ? trimmed.slice(1) : trimmed;
|
|
185
|
+
// Body darf nur noch Ziffern, Group- und Decimal-Separator enthalten.
|
|
186
|
+
// Alles andere (zweites Minus, Buchstaben, etc.) → NaN, damit Caller
|
|
187
|
+
// (handleBlur) den Wert verwirft statt eine korrupte Zahl zu setzen.
|
|
188
|
+
const cleaned = body.split(groupSep).join("").split(decimalSep).join(".");
|
|
189
|
+
if (!/^[0-9]*\.?[0-9]*$/.test(cleaned) || cleaned === "" || cleaned === ".") return Number.NaN;
|
|
190
|
+
const n = Number(cleaned);
|
|
191
|
+
return negative ? -n : n;
|
|
192
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Toast-Primitive auf @radix-ui/react-toast. Ein globaler Provider
|
|
2
|
+
// + ein useToast()-Hook der von überall in der App aus Toasts auslösen
|
|
3
|
+
// kann. Kein Context-Boilerplate für Konsumenten — der Hook returned
|
|
4
|
+
// einen `toast()`-Trigger, mehr Public-API gibt's nicht.
|
|
5
|
+
//
|
|
6
|
+
// Variants: "default" (neutral) und "destructive" (Fehler-Akzent).
|
|
7
|
+
// Auto-dismiss nach 5s, manuell schließbar via X-Button. Der ARIA-
|
|
8
|
+
// Live-Region-Setup kommt komplett aus Radix.
|
|
9
|
+
|
|
10
|
+
import * as Primitive from "@radix-ui/react-toast";
|
|
11
|
+
import { X } from "lucide-react";
|
|
12
|
+
import {
|
|
13
|
+
createContext,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
useCallback,
|
|
16
|
+
useContext,
|
|
17
|
+
useId,
|
|
18
|
+
useMemo,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
} from "react";
|
|
22
|
+
import { cn } from "../lib/cn";
|
|
23
|
+
|
|
24
|
+
export type ToastVariant = "default" | "destructive";
|
|
25
|
+
|
|
26
|
+
export type ToastOptions = {
|
|
27
|
+
readonly title: string;
|
|
28
|
+
readonly description?: string;
|
|
29
|
+
readonly variant?: ToastVariant;
|
|
30
|
+
// Self-service deep-link (z.B. KumikoError.docsUrl). Wenn gesetzt
|
|
31
|
+
// rendert der Toast einen "Mehr erfahren →" Link der in neuem Tab
|
|
32
|
+
// öffnet. Label override via `docsLinkLabel` (Default: deutsch).
|
|
33
|
+
readonly docsUrl?: string;
|
|
34
|
+
readonly docsLinkLabel?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type ToastEntry = ToastOptions & {
|
|
38
|
+
readonly id: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type ToastApi = {
|
|
42
|
+
readonly toast: (opts: ToastOptions) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const ToastContext = createContext<ToastApi | null>(null);
|
|
46
|
+
|
|
47
|
+
export function useToast(): ToastApi {
|
|
48
|
+
const ctx = useContext(ToastContext);
|
|
49
|
+
if (ctx === null) {
|
|
50
|
+
// Nicht throwen — Tests die einen Component ohne Provider rendern
|
|
51
|
+
// sollen nicht crashen. No-op statt fehlerhaftes Mount.
|
|
52
|
+
return { toast: () => undefined };
|
|
53
|
+
}
|
|
54
|
+
return ctx;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ToastProviderProps = {
|
|
58
|
+
readonly children: ReactNode;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function ToastProvider({ children }: ToastProviderProps): ReactNode {
|
|
62
|
+
const [entries, setEntries] = useState<readonly ToastEntry[]>([]);
|
|
63
|
+
// Monoton wachsender Counter via useRef — useState hätte einen
|
|
64
|
+
// closure-Bug: zwei toast()-Calls im selben Tick lesen denselben
|
|
65
|
+
// alten Wert aus dem Closure und kollidieren bei der ID. Ref ist
|
|
66
|
+
// synchron-mutabel, jeder Aufruf bekommt eine garantiert neue ID.
|
|
67
|
+
const idPrefix = useId();
|
|
68
|
+
const counterRef = useRef(0);
|
|
69
|
+
|
|
70
|
+
const toast = useCallback(
|
|
71
|
+
(opts: ToastOptions) => {
|
|
72
|
+
counterRef.current += 1;
|
|
73
|
+
const id = `${idPrefix}-${counterRef.current}`;
|
|
74
|
+
setEntries((current) => [...current, { ...opts, id }]);
|
|
75
|
+
},
|
|
76
|
+
[idPrefix],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const api = useMemo<ToastApi>(() => ({ toast }), [toast]);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<ToastContext.Provider value={api}>
|
|
83
|
+
<Primitive.Provider swipeDirection="right" duration={5000}>
|
|
84
|
+
{children}
|
|
85
|
+
{entries.map((entry) => (
|
|
86
|
+
<ToastItem
|
|
87
|
+
key={entry.id}
|
|
88
|
+
entry={entry}
|
|
89
|
+
onClose={() => setEntries((current) => current.filter((e) => e.id !== entry.id))}
|
|
90
|
+
/>
|
|
91
|
+
))}
|
|
92
|
+
<Primitive.Viewport
|
|
93
|
+
className={cn(
|
|
94
|
+
"fixed top-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4",
|
|
95
|
+
"sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
|
96
|
+
)}
|
|
97
|
+
/>
|
|
98
|
+
</Primitive.Provider>
|
|
99
|
+
</ToastContext.Provider>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rootClass =
|
|
104
|
+
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 " +
|
|
105
|
+
"overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all " +
|
|
106
|
+
"data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] " +
|
|
107
|
+
"data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none " +
|
|
108
|
+
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out " +
|
|
109
|
+
"data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full " +
|
|
110
|
+
"data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full";
|
|
111
|
+
|
|
112
|
+
function ToastItem({
|
|
113
|
+
entry,
|
|
114
|
+
onClose,
|
|
115
|
+
}: {
|
|
116
|
+
readonly entry: ToastEntry;
|
|
117
|
+
readonly onClose: () => void;
|
|
118
|
+
}): ReactNode {
|
|
119
|
+
const variantClass =
|
|
120
|
+
entry.variant === "destructive"
|
|
121
|
+
? "destructive group border-destructive bg-destructive text-destructive-foreground"
|
|
122
|
+
: "border bg-background text-foreground";
|
|
123
|
+
return (
|
|
124
|
+
<Primitive.Root
|
|
125
|
+
className={cn(rootClass, variantClass)}
|
|
126
|
+
onOpenChange={(open) => {
|
|
127
|
+
if (!open) onClose();
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
<div className="grid gap-1">
|
|
131
|
+
<Primitive.Title className="text-sm font-semibold">{entry.title}</Primitive.Title>
|
|
132
|
+
{entry.description !== undefined && (
|
|
133
|
+
<Primitive.Description className="text-sm opacity-90">
|
|
134
|
+
{entry.description}
|
|
135
|
+
</Primitive.Description>
|
|
136
|
+
)}
|
|
137
|
+
{entry.docsUrl !== undefined && (
|
|
138
|
+
<Primitive.Action altText={entry.docsLinkLabel ?? "Mehr erfahren"} asChild>
|
|
139
|
+
<a
|
|
140
|
+
href={entry.docsUrl}
|
|
141
|
+
target="_blank"
|
|
142
|
+
rel="noopener noreferrer"
|
|
143
|
+
className={cn(
|
|
144
|
+
"text-xs underline opacity-90 hover:opacity-100",
|
|
145
|
+
"focus:outline-none focus:ring-1 focus:ring-current rounded",
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
{entry.docsLinkLabel ?? "Mehr erfahren"} →
|
|
149
|
+
</a>
|
|
150
|
+
</Primitive.Action>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
<Primitive.Close
|
|
154
|
+
className={cn(
|
|
155
|
+
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity",
|
|
156
|
+
"hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1",
|
|
157
|
+
"group-hover:opacity-100",
|
|
158
|
+
"group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50",
|
|
159
|
+
)}
|
|
160
|
+
aria-label="Close"
|
|
161
|
+
>
|
|
162
|
+
<X className="h-4 w-4" />
|
|
163
|
+
</Primitive.Close>
|
|
164
|
+
</Primitive.Root>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { LiveEvent, LiveEventSubscriber } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
|
|
3
|
+
// EventSource-backed Live-Events für den Web-Renderer. Der shared
|
|
4
|
+
// Layer konsumiert nur das `LiveEventSubscriber`-Interface; diese Datei
|
|
5
|
+
// liefert eine Factory die intern eine EventSource auf /api/sse aufbaut,
|
|
6
|
+
// pro (entity, verb)-Kombi einen addEventListener verdrahtet und
|
|
7
|
+
// subscriptions routet.
|
|
8
|
+
//
|
|
9
|
+
// Verbindungs-Lifecycle: lazy beim ersten subscribe, close wenn der
|
|
10
|
+
// letzte unsubscribe feuert. Mehrere Consumer teilen sich dieselbe
|
|
11
|
+
// EventSource, sparen CPU + Server-Load.
|
|
12
|
+
|
|
13
|
+
const VERBS = ["created", "updated", "deleted", "restored"] as const;
|
|
14
|
+
|
|
15
|
+
type EntitySubscriber = {
|
|
16
|
+
readonly entityName: string;
|
|
17
|
+
readonly listener: (event: LiveEvent) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type CreateEventSourceLiveEventsOptions = {
|
|
21
|
+
/** URL des SSE-Endpoints. Default: /api/sse (das ist wo
|
|
22
|
+
* createSseRoute im Kumiko-Server mountet). Override wenn der
|
|
23
|
+
* Mountpath divergiert. */
|
|
24
|
+
readonly url?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Liefert einen `LiveEventSubscriber` der EventSource-backed ist.
|
|
28
|
+
* Normalerweise einmal im App-Bootstrap gerufen und als value an
|
|
29
|
+
* `<LiveEventsProvider>` durchgereicht — createKumikoApp tut das. */
|
|
30
|
+
export function createEventSourceLiveEvents(
|
|
31
|
+
options: CreateEventSourceLiveEventsOptions = {},
|
|
32
|
+
): LiveEventSubscriber {
|
|
33
|
+
const url = options.url ?? "/api/sse";
|
|
34
|
+
|
|
35
|
+
const subscribers = new Set<EntitySubscriber>();
|
|
36
|
+
let source: EventSource | undefined;
|
|
37
|
+
const wiredTypes = new Set<string>();
|
|
38
|
+
|
|
39
|
+
const handleEvent = (type: string, raw: string): void => {
|
|
40
|
+
let parsed: LiveEvent["data"];
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw) as LiveEvent["data"];
|
|
43
|
+
} catch {
|
|
44
|
+
// Malformed payload — drop it. Besser als einen fangenden Listener
|
|
45
|
+
// zu crashen und alle anderen subscribers mitzureißen.
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const event: LiveEvent = { type, data: parsed };
|
|
49
|
+
for (const sub of subscribers) {
|
|
50
|
+
if (sub.entityName === parsed.aggregateType) sub.listener(event);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const ensureConnected = (): void => {
|
|
55
|
+
if (source !== undefined) return;
|
|
56
|
+
if (typeof window === "undefined" || typeof EventSource === "undefined") return;
|
|
57
|
+
source = new EventSource(url);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const ensureListenersForEntity = (entityName: string): void => {
|
|
61
|
+
if (source === undefined) return;
|
|
62
|
+
for (const verb of VERBS) {
|
|
63
|
+
const type = `${entityName}.${verb}`;
|
|
64
|
+
if (wiredTypes.has(type)) continue;
|
|
65
|
+
source.addEventListener(type, (e) => {
|
|
66
|
+
handleEvent(type, (e as MessageEvent).data);
|
|
67
|
+
});
|
|
68
|
+
wiredTypes.add(type);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const closeIfEmpty = (): void => {
|
|
73
|
+
if (subscribers.size > 0) return;
|
|
74
|
+
if (source === undefined) return;
|
|
75
|
+
source.close();
|
|
76
|
+
source = undefined;
|
|
77
|
+
wiredTypes.clear();
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (entityName, listener) => {
|
|
81
|
+
ensureConnected();
|
|
82
|
+
ensureListenersForEntity(entityName);
|
|
83
|
+
const sub: EntitySubscriber = { entityName, listener };
|
|
84
|
+
subscribers.add(sub);
|
|
85
|
+
return () => {
|
|
86
|
+
subscribers.delete(sub);
|
|
87
|
+
closeIfEmpty();
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/* Tailwind v4 + shadcn-Token-Schema. Die :root + .dark Blocks
|
|
2
|
+
halten die Design-Tokens als CSS-Variablen. Theme-Toggle =
|
|
3
|
+
`document.documentElement.classList.toggle("dark")` — keine
|
|
4
|
+
JS-State-Management-Pipeline nötig.
|
|
5
|
+
|
|
6
|
+
Tokens-Namen folgen der shadcn-Konvention (background/foreground/
|
|
7
|
+
primary/secondary/muted/accent/destructive/border/input/ring).
|
|
8
|
+
Werte sind HSL-triplets ohne hsl() wrapper, damit Tailwind-
|
|
9
|
+
Klassen wie `bg-primary` direkt funktionieren.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
@import "tailwindcss";
|
|
13
|
+
|
|
14
|
+
/* react-day-picker v9 liefert seine Struktur-CSS separat (kein bundled
|
|
15
|
+
Default mehr seit v9). Ohne diesen Import bricht das Calendar-Layout
|
|
16
|
+
— Wochen rutschen ineinander, weil rdp den table-Reset (border-
|
|
17
|
+
collapse, cell-Geometrie) extern hält. Unsere classNames-Map in
|
|
18
|
+
`date-input.tsx` überschreibt nur Slot-Tokens, die strukturellen
|
|
19
|
+
Defaults bleiben rdp's eigenes Stylesheet. */
|
|
20
|
+
@import "react-day-picker/style.css";
|
|
21
|
+
|
|
22
|
+
/* Tailwind v4 scannt per default vom Import-Ort aus; da das CSS
|
|
23
|
+
aus einem temp-dir kompiliert wird, findet es sonst keine
|
|
24
|
+
Source-Dateien. Explizite @source-Direktiven decken alle
|
|
25
|
+
Workspace-Sources ab — der CLI scannt dann unsere Primitives,
|
|
26
|
+
Layout-Components UND die Sample-App. */
|
|
27
|
+
@source "../../renderer-web/src/**/*.{ts,tsx}";
|
|
28
|
+
@source "../../renderer/src/**/*.{ts,tsx}";
|
|
29
|
+
@source "../../../samples/**/src/**/*.{ts,tsx}";
|
|
30
|
+
|
|
31
|
+
/* Dark-Mode via class-selector (nicht media). Erlaubt JS-Toggle
|
|
32
|
+
und überschreibt System-Preference wenn die App will. */
|
|
33
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
34
|
+
|
|
35
|
+
@theme {
|
|
36
|
+
/* Dark-Mode (Default) — Linear-Style: blau-graues Background statt
|
|
37
|
+
pures Schwarz. background <-> card haben minimal-Distinction
|
|
38
|
+
(1-2% lightness shift) damit Cards subtle "schweben" ohne harte
|
|
39
|
+
Borders. Primary ist das Linear-Brand-Purple (~#5e6ad2). */
|
|
40
|
+
--color-background: hsl(220 13% 9%);
|
|
41
|
+
--color-foreground: hsl(220 14% 96%);
|
|
42
|
+
--color-card: hsl(220 13% 11%);
|
|
43
|
+
--color-card-foreground: hsl(220 14% 96%);
|
|
44
|
+
--color-popover: hsl(220 13% 11%);
|
|
45
|
+
--color-popover-foreground: hsl(220 14% 96%);
|
|
46
|
+
--color-primary: hsl(232 60% 64%);
|
|
47
|
+
--color-primary-foreground: hsl(0 0% 100%);
|
|
48
|
+
--color-secondary: hsl(220 13% 16%);
|
|
49
|
+
--color-secondary-foreground: hsl(220 14% 96%);
|
|
50
|
+
--color-muted: hsl(220 13% 14%);
|
|
51
|
+
--color-muted-foreground: hsl(220 8% 56%);
|
|
52
|
+
--color-accent: hsl(220 13% 16%);
|
|
53
|
+
--color-accent-foreground: hsl(220 14% 96%);
|
|
54
|
+
--color-destructive: hsl(0 75% 58%);
|
|
55
|
+
--color-destructive-foreground: hsl(0 0% 100%);
|
|
56
|
+
--color-border: hsl(220 13% 18%);
|
|
57
|
+
--color-input: hsl(220 13% 16%);
|
|
58
|
+
--color-ring: hsl(232 60% 64%);
|
|
59
|
+
|
|
60
|
+
/* Radius-Skala: Linear hat größere Radii — Cards/Modals 10-12px,
|
|
61
|
+
Buttons/Inputs 6-8px, Pills voll. */
|
|
62
|
+
--radius-sm: 0.25rem;
|
|
63
|
+
--radius-md: 0.375rem;
|
|
64
|
+
--radius-lg: 0.5rem;
|
|
65
|
+
--radius-xl: 0.75rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Light-Mode-Overrides — aktiv wenn `<html>` KEINE `.dark` Klasse
|
|
69
|
+
hat. Dark ist der Default (siehe @theme oben); toggled der User
|
|
70
|
+
auf light, greifen diese Werte. */
|
|
71
|
+
@layer base {
|
|
72
|
+
:root:not(.dark) {
|
|
73
|
+
/* Light-Mode — Linear-Style: warmes weiß-grau Background, Cards
|
|
74
|
+
leicht heller. Primary bleibt Brand-Purple (gleicher hue wie
|
|
75
|
+
dark, höhere saturation für Kontrast auf weiß). Borders sehr
|
|
76
|
+
dezent (rgba-style alpha statt full grey-200). */
|
|
77
|
+
--color-background: hsl(0 0% 100%);
|
|
78
|
+
--color-foreground: hsl(220 14% 14%);
|
|
79
|
+
--color-card: hsl(0 0% 100%);
|
|
80
|
+
--color-card-foreground: hsl(220 14% 14%);
|
|
81
|
+
--color-popover: hsl(0 0% 100%);
|
|
82
|
+
--color-popover-foreground: hsl(220 14% 14%);
|
|
83
|
+
--color-primary: hsl(232 60% 58%);
|
|
84
|
+
--color-primary-foreground: hsl(0 0% 100%);
|
|
85
|
+
--color-secondary: hsl(220 14% 96%);
|
|
86
|
+
--color-secondary-foreground: hsl(220 14% 14%);
|
|
87
|
+
--color-muted: hsl(220 14% 97%);
|
|
88
|
+
--color-muted-foreground: hsl(220 8% 46%);
|
|
89
|
+
--color-accent: hsl(220 14% 96%);
|
|
90
|
+
--color-accent-foreground: hsl(220 14% 14%);
|
|
91
|
+
--color-destructive: hsl(0 72% 51%);
|
|
92
|
+
--color-destructive-foreground: hsl(0 0% 100%);
|
|
93
|
+
--color-border: hsl(220 14% 92%);
|
|
94
|
+
--color-input: hsl(220 14% 92%);
|
|
95
|
+
--color-ring: hsl(232 60% 58%);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
* {
|
|
99
|
+
border-color: var(--color-border);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
body {
|
|
103
|
+
background-color: var(--color-background);
|
|
104
|
+
color: var(--color-foreground);
|
|
105
|
+
font-family:
|
|
106
|
+
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
107
|
+
font-size: 14px;
|
|
108
|
+
line-height: 1.5;
|
|
109
|
+
margin: 0;
|
|
110
|
+
-webkit-font-smoothing: antialiased;
|
|
111
|
+
-moz-osx-font-smoothing: grayscale;
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createStore } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import {
|
|
3
|
+
cssVarTokens,
|
|
4
|
+
type ThemeMode,
|
|
5
|
+
type Tokens,
|
|
6
|
+
type TokensApi,
|
|
7
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import { useSyncExternalStore } from "react";
|
|
9
|
+
|
|
10
|
+
// Web-spezifische TokensApi-Impl. Theme-Toggle via `.dark`-Class auf
|
|
11
|
+
// <html>. Die echten Farben leben in styles.css; hier ist nur die
|
|
12
|
+
// JS-Seite die den class-switch triggert und React-Consumer mit
|
|
13
|
+
// useSyncExternalStore darüber informiert.
|
|
14
|
+
//
|
|
15
|
+
// Source-of-truth ist der DOM (`<html class="dark">`); der Store ist
|
|
16
|
+
// reiner Notification-Bus (Tick-Counter), den setMode/toggleMode bei
|
|
17
|
+
// jedem Class-Wechsel hochzählen. So bleibt die DOM-Klasse die einzige
|
|
18
|
+
// Wahrheit — readCurrentMode liest sie frisch bei jedem getSnapshot.
|
|
19
|
+
|
|
20
|
+
const themeTick = createStore(0);
|
|
21
|
+
|
|
22
|
+
function readCurrentMode(): ThemeMode {
|
|
23
|
+
if (typeof document === "undefined") return "dark";
|
|
24
|
+
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function notifyThemeChange(): void {
|
|
28
|
+
themeTick.setState((t) => t + 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Hook der eine TokensApi für den Browser baut. Wird von
|
|
32
|
+
* createKumikoApp genutzt; App-Code der einen eigenen Token-State
|
|
33
|
+
* braucht (z.B. User-Präferenz aus localStorage) kann selber
|
|
34
|
+
* `<TokensProvider value={...}>` mounten. */
|
|
35
|
+
export function useBrowserTokensApi(): TokensApi {
|
|
36
|
+
const mode = useSyncExternalStore(themeTick.subscribe, readCurrentMode, () => "dark" as const);
|
|
37
|
+
return {
|
|
38
|
+
tokens: cssVarTokens,
|
|
39
|
+
mode,
|
|
40
|
+
setMode: (next) => {
|
|
41
|
+
if (typeof document === "undefined") return;
|
|
42
|
+
document.documentElement.classList.toggle("dark", next === "dark");
|
|
43
|
+
notifyThemeChange();
|
|
44
|
+
},
|
|
45
|
+
toggleMode: () => {
|
|
46
|
+
if (typeof document === "undefined") return;
|
|
47
|
+
document.documentElement.classList.toggle("dark");
|
|
48
|
+
notifyThemeChange();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Default-Tokens — identisch zu `cssVarTokens` (var-string-refs).
|
|
54
|
+
* Light- und Dark-Werte switchen via `.dark`-class auf <html>. */
|
|
55
|
+
export const defaultTokens: Tokens = cssVarTokens;
|
|
56
|
+
export const lightTokens: Tokens = cssVarTokens;
|
|
57
|
+
|
|
58
|
+
/** Historisch: schrieb Tokens auf :root als CSS-vars. Jetzt no-op —
|
|
59
|
+
* die CSS-vars leben in styles.css, nicht in JS. Bleibt als Export
|
|
60
|
+
* damit alter App-Code nicht bricht. */
|
|
61
|
+
export function applyTokensToCssVars(_tokens: Tokens): void {
|
|
62
|
+
// Absichtlich leer — siehe Kommentar oben.
|
|
63
|
+
}
|