@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.
Files changed (58) hide show
  1. package/package.json +63 -0
  2. package/src/__tests__/avatar.test.tsx +34 -0
  3. package/src/__tests__/combobox.test.tsx +240 -0
  4. package/src/__tests__/config-edit.test.tsx +172 -0
  5. package/src/__tests__/create-app.test.tsx +261 -0
  6. package/src/__tests__/date-input.test.tsx +91 -0
  7. package/src/__tests__/default-app-shell.test.tsx +60 -0
  8. package/src/__tests__/dispatcher-context.test.tsx +101 -0
  9. package/src/__tests__/dispatcher-status-wiring.test.tsx +119 -0
  10. package/src/__tests__/kumiko-screen.test.tsx +1014 -0
  11. package/src/__tests__/language-switcher.test.tsx +100 -0
  12. package/src/__tests__/money-input.test.tsx +232 -0
  13. package/src/__tests__/nav-base-path.test.tsx +388 -0
  14. package/src/__tests__/nav-search-params.test.tsx +88 -0
  15. package/src/__tests__/nav-tree.test.tsx +183 -0
  16. package/src/__tests__/nav.test.tsx +253 -0
  17. package/src/__tests__/primitives.test.tsx +936 -0
  18. package/src/__tests__/render-edit.test.tsx +178 -0
  19. package/src/__tests__/render-list-column-renderer.test.tsx +124 -0
  20. package/src/__tests__/render-list-debounce.test.tsx +128 -0
  21. package/src/__tests__/render-list.test.tsx +151 -0
  22. package/src/__tests__/sidebar.test.tsx +59 -0
  23. package/src/__tests__/test-utils.tsx +144 -0
  24. package/src/__tests__/theme-toggle.test.tsx +101 -0
  25. package/src/__tests__/toast.test.tsx +162 -0
  26. package/src/__tests__/use-form.test.tsx +112 -0
  27. package/src/__tests__/use-query-live.test.tsx +152 -0
  28. package/src/__tests__/use-query.test.tsx +88 -0
  29. package/src/__tests__/use-store.test.tsx +139 -0
  30. package/src/__tests__/workspace-shell.test.tsx +772 -0
  31. package/src/app/browser-locale.ts +85 -0
  32. package/src/app/client-plugin.tsx +63 -0
  33. package/src/app/create-app.tsx +380 -0
  34. package/src/app/nav.tsx +226 -0
  35. package/src/index.ts +137 -0
  36. package/src/layout/app-layout.tsx +35 -0
  37. package/src/layout/avatar.tsx +93 -0
  38. package/src/layout/default-app-shell.tsx +74 -0
  39. package/src/layout/language-switcher.tsx +101 -0
  40. package/src/layout/nav-tree.tsx +281 -0
  41. package/src/layout/profile-menu.tsx +40 -0
  42. package/src/layout/sidebar.tsx +65 -0
  43. package/src/layout/theme-toggle.tsx +44 -0
  44. package/src/layout/topbar.tsx +22 -0
  45. package/src/layout/workspace-shell.tsx +282 -0
  46. package/src/layout/workspace-switcher.tsx +62 -0
  47. package/src/lib/cn.ts +10 -0
  48. package/src/primitives/action-menu.tsx +111 -0
  49. package/src/primitives/combobox.tsx +261 -0
  50. package/src/primitives/date-input.tsx +165 -0
  51. package/src/primitives/dialog.tsx +119 -0
  52. package/src/primitives/dropdown-menu.tsx +103 -0
  53. package/src/primitives/index.tsx +1271 -0
  54. package/src/primitives/money-input.tsx +192 -0
  55. package/src/primitives/toast.tsx +166 -0
  56. package/src/sse/live-events.ts +90 -0
  57. package/src/styles.css +113 -0
  58. package/src/tokens.ts +63 -0
@@ -0,0 +1,261 @@
1
+ // Tier 2.1c: Searchable Select / Combobox.
2
+ //
3
+ // Pattern ist das shadcn-Standard-Combobox: Popover-Trigger als Button
4
+ // (zeigt aktuellen Wert oder Placeholder), Popover-Content enthält
5
+ // cmdk-Command mit Search-Input + filterable Item-List.
6
+ //
7
+ // cmdk ist Radix's Standard-Combobox (Library für Command-K + Searchable
8
+ // Selects), client-side Fuzzy-Match via `Command.Filter` mit Default-
9
+ // Behavior. Server-side Remote-Search (debounced query) ist nicht im
10
+ // MVP — kommt später als zweiter Mode-Schalter.
11
+ //
12
+ // Multi-Mode: `multiple: true` schaltet auf Tag-Anzeige + Mehrfach-
13
+ // Auswahl. Selected-Tags rendern als kleine entfernbare Chips, das
14
+ // Search-Input bleibt offen für weitere Auswahl.
15
+
16
+ import { REFERENCE_SEARCH_DEBOUNCE_MS, useTranslation } from "@cosmicdrift/kumiko-renderer";
17
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
18
+ import { Command } from "cmdk";
19
+ import { Check, ChevronDown, Loader2 } from "lucide-react";
20
+ import { type ReactNode, useEffect, useState } from "react";
21
+ import { cn } from "../lib/cn";
22
+
23
+ export type ComboboxOption = { readonly value: string; readonly label: string };
24
+
25
+ // Discriminated Union per `multiple`-Flag — Single-Mode hat string-
26
+ // value/onChange, Multi-Mode hat string[]/string[]. Caller muss den
27
+ // Mode beim Build entscheiden, der Compiler zwingt dann die richtige
28
+ // Signature ohne Runtime-narrow.
29
+ type ComboboxBaseProps = {
30
+ readonly id: string;
31
+ readonly name: string;
32
+ readonly options: readonly ComboboxOption[];
33
+ readonly disabled?: boolean;
34
+ readonly required?: boolean;
35
+ readonly hasError?: boolean;
36
+ readonly placeholder?: string;
37
+ readonly searchPlaceholder?: string;
38
+ readonly emptyText?: string;
39
+ /** Tier 2.7e Remote-Search: wenn gesetzt, wechselt der Combobox in
40
+ * Remote-Mode. cmdk's client-side Filter wird deaktiviert (Server
41
+ * hat schon gefiltert), und der Search-Input ruft onSearchChange
42
+ * debounced (300ms). Ohne diesen Callback bleibt die Local-Filter-
43
+ * Variante (cmdk fuzzy-match auf den geladenen options). */
44
+ readonly onSearchChange?: (q: string) => void;
45
+ /** Spinner im Trigger + Popover-Footer wenn remote search läuft. */
46
+ readonly loading?: boolean;
47
+ /** Test-Hook: forciert den initial open-state des Popovers. In
48
+ * jsdom + Radix-Popover triggert userEvent.click auf den Trigger
49
+ * nicht zuverlässig PointerEvents — Tests setzen defaultOpen=true,
50
+ * damit der Popover sofort gerendert ist. Production-Code lässt
51
+ * den Default (false). */
52
+ readonly defaultOpen?: boolean;
53
+ };
54
+
55
+ export type ComboboxInputProps = ComboboxBaseProps &
56
+ (
57
+ | {
58
+ readonly multiple?: false;
59
+ readonly value: string;
60
+ readonly onChange: (v: string) => void;
61
+ }
62
+ | {
63
+ readonly multiple: true;
64
+ readonly value: readonly string[];
65
+ readonly onChange: (v: readonly string[]) => void;
66
+ }
67
+ );
68
+
69
+ const triggerClass =
70
+ "flex h-9 w-full items-center justify-between rounded-md border border-input " +
71
+ "bg-transparent px-3 py-1 text-sm shadow-sm transition-colors " +
72
+ "placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 " +
73
+ "focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50";
74
+
75
+ const popoverContentClass =
76
+ "z-50 w-[var(--radix-popover-trigger-width)] rounded-md border bg-popover " +
77
+ "p-0 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in " +
78
+ "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 " +
79
+ "data-[state=open]:fade-in-0";
80
+
81
+ const tagClass =
82
+ "inline-flex items-center gap-1 rounded-sm bg-muted px-2 py-0.5 text-xs " +
83
+ "font-medium text-muted-foreground";
84
+
85
+ // Debounce-Default lebt zentral (hooks/reference-limits.ts) damit
86
+ // app-weit ein consistentes Tipp-Window gilt.
87
+
88
+ // Substring-Filter für cmdk's Local-Mode. cmdk's Default ist
89
+ // `command-score` (fuzzy), das matcht "Ad" auch gegen "Backend" weil 'a'
90
+ // und 'd' irgendwo enthalten sind — für Reference-Lookups irreführend
91
+ // (User erwartet Prefix/Substring-Verhalten, nicht Subsequence-Match).
92
+ // Returns 1 wenn search im value enthalten, 0 sonst — cmdk hidet Items
93
+ // mit Score 0.
94
+ function substringFilter(value: string, search: string): number {
95
+ return value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
96
+ }
97
+
98
+ export function ComboboxInput(props: ComboboxInputProps): ReactNode {
99
+ const {
100
+ id,
101
+ name,
102
+ options,
103
+ disabled,
104
+ required,
105
+ hasError,
106
+ placeholder,
107
+ searchPlaceholder,
108
+ emptyText,
109
+ onSearchChange,
110
+ loading,
111
+ defaultOpen,
112
+ } = props;
113
+ // i18n-Defaults aus dem Framework-Bundle (kumikoDefaultTranslations).
114
+ // Caller-Override gewinnt; Bundle-Override greift wenn der Caller
115
+ // den Prop nicht setzt; raw-Key-Fallback wenn weder noch.
116
+ const t = useTranslation();
117
+ const effectivePlaceholder = placeholder ?? t("kumiko.combobox.placeholder");
118
+ const effectiveSearchPlaceholder = searchPlaceholder ?? t("kumiko.combobox.search-placeholder");
119
+ const effectiveEmptyText = emptyText ?? t("kumiko.combobox.empty");
120
+ const loadingText = t("kumiko.combobox.loading");
121
+ const multiple = props.multiple === true;
122
+ const [open, setOpen] = useState(defaultOpen === true);
123
+ // Local Search-Buffer für Remote-Mode. Tipps werden mit 300ms
124
+ // Debounce an onSearchChange weitergereicht, damit pro Tastendruck
125
+ // nicht ein Server-Roundtrip fliegt. Im Local-Mode ist das State
126
+ // ungenutzt (cmdk steuert sein Search-Input intern).
127
+ const [searchTerm, setSearchTerm] = useState("");
128
+ const isRemote = onSearchChange !== undefined;
129
+ useEffect(() => {
130
+ if (!isRemote) return;
131
+ const timer = setTimeout(() => onSearchChange(searchTerm), REFERENCE_SEARCH_DEBOUNCE_MS);
132
+ return () => clearTimeout(timer);
133
+ }, [isRemote, searchTerm, onSearchChange]);
134
+ // Beim Schließen des Popovers den Suchbegriff zurücksetzen damit
135
+ // beim nächsten Öffnen nicht der vorherige term hängt. Würde sonst
136
+ // den Server-State stale halten zwischen Aufrufen.
137
+ useEffect(() => {
138
+ if (!open && searchTerm !== "") setSearchTerm("");
139
+ }, [open, searchTerm]);
140
+ // Discriminated-Union per `multiple`: TS narrowt props.value/onChange
141
+ // automatisch — Runtime-Cast entfällt.
142
+ const selectedValues = multiple ? new Set(props.value) : new Set<string>();
143
+ const singleValue = !multiple ? props.value : "";
144
+ const singleLabel = options.find((o) => o.value === singleValue)?.label ?? "";
145
+
146
+ const toggleMulti = (v: string): void => {
147
+ if (!multiple) return;
148
+ const current = [...props.value];
149
+ const idx = current.indexOf(v);
150
+ if (idx >= 0) current.splice(idx, 1);
151
+ else current.push(v);
152
+ props.onChange(current);
153
+ };
154
+
155
+ const triggerLabel = multiple ? null : singleLabel === "" ? placeholder : singleLabel;
156
+
157
+ return (
158
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
159
+ <input type="hidden" name={name} value={multiple ? props.value.join(",") : props.value} />
160
+ <PopoverPrimitive.Trigger
161
+ id={id}
162
+ data-testid={`combobox-${id}`}
163
+ type="button"
164
+ disabled={disabled}
165
+ aria-required={required}
166
+ aria-invalid={hasError === true ? true : undefined}
167
+ aria-haspopup="listbox"
168
+ aria-expanded={open}
169
+ className={cn(
170
+ triggerClass,
171
+ hasError === true && "border-destructive focus-visible:ring-destructive",
172
+ )}
173
+ >
174
+ {multiple ? (
175
+ <span className="flex flex-wrap items-center gap-1">
176
+ {selectedValues.size === 0 ? (
177
+ <span className="text-muted-foreground">{effectivePlaceholder}</span>
178
+ ) : (
179
+ // Tags sind read-only Anzeige (kein nested <button>
180
+ // möglich im Trigger-<button>). Entfernen via Re-Click
181
+ // im Combobox-Popup — der Item-Toggle deselektiert die
182
+ // entsprechende Option (analog shadcn-Standard-Combobox).
183
+ [...selectedValues].map((v) => {
184
+ const opt = options.find((o) => o.value === v);
185
+ return (
186
+ <span key={v} className={tagClass}>
187
+ {opt?.label ?? v}
188
+ </span>
189
+ );
190
+ })
191
+ )}
192
+ </span>
193
+ ) : (
194
+ <span className={singleLabel === "" ? "text-muted-foreground" : ""}>{triggerLabel}</span>
195
+ )}
196
+ <ChevronDown className="h-4 w-4 opacity-50" />
197
+ </PopoverPrimitive.Trigger>
198
+ <PopoverPrimitive.Portal>
199
+ <PopoverPrimitive.Content className={popoverContentClass} align="start" sideOffset={4}>
200
+ <Command shouldFilter={!isRemote} filter={substringFilter}>
201
+ <div className="relative">
202
+ <Command.Input
203
+ placeholder={effectiveSearchPlaceholder}
204
+ value={isRemote ? searchTerm : undefined}
205
+ onValueChange={isRemote ? setSearchTerm : undefined}
206
+ className="flex h-9 w-full border-0 border-b border-border bg-transparent px-3 py-1 text-sm outline-none placeholder:text-muted-foreground"
207
+ />
208
+ {loading === true && (
209
+ <Loader2 className="absolute right-2 top-2.5 h-4 w-4 animate-spin text-muted-foreground" />
210
+ )}
211
+ </div>
212
+ <Command.List className="max-h-64 overflow-y-auto p-1">
213
+ <Command.Empty className="py-3 text-center text-sm text-muted-foreground">
214
+ {loading === true ? loadingText : effectiveEmptyText}
215
+ </Command.Empty>
216
+ {options.map((opt) => {
217
+ const isSelected = multiple
218
+ ? selectedValues.has(opt.value)
219
+ : opt.value === singleValue;
220
+ return (
221
+ <Command.Item
222
+ key={opt.value}
223
+ value={opt.label}
224
+ onSelect={() => {
225
+ if (props.multiple === true) {
226
+ toggleMulti(opt.value);
227
+ } else {
228
+ props.onChange(opt.value);
229
+ setOpen(false);
230
+ }
231
+ }}
232
+ // Browser-Click-Bug-Fix: Item-className enthielt vorher
233
+ // `data-[disabled]:pointer-events-none`. Im Showcase
234
+ // (mit Tailwind-CSS aktiv) waren Mouse-Clicks lautlos
235
+ // tot — Keyboard-Select via Pfeiltasten funktionierte.
236
+ // jsdom-Tests grün, weil dort kein Tailwind-CSS greift.
237
+ // Genauer Trigger-Mechanismus (welches state setzt
238
+ // `data-disabled` auf das Item?) wurde nicht weiter
239
+ // untersucht — Class-Removal war ausreichend, um den
240
+ // Click zu reaktivieren. Defensiv: keine pointer-events-
241
+ // Schalter-Klassen mehr auf dem Item, sodass auch ein
242
+ // zukünftig wieder gesetztes `data-disabled` keinen
243
+ // stillen Click-Verlust mehr produzieren kann.
244
+ className="relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground"
245
+ >
246
+ {isSelected && (
247
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
248
+ <Check className="h-4 w-4" />
249
+ </span>
250
+ )}
251
+ <span>{opt.label}</span>
252
+ </Command.Item>
253
+ );
254
+ })}
255
+ </Command.List>
256
+ </Command>
257
+ </PopoverPrimitive.Content>
258
+ </PopoverPrimitive.Portal>
259
+ </PopoverPrimitive.Root>
260
+ );
261
+ }
@@ -0,0 +1,165 @@
1
+ // DateInput — Radix-Popover + react-day-picker. Trigger ist ein
2
+ // Button im Input-Style der das formatierte Datum zeigt; Popover
3
+ // öffnet eine Calendar-Sheet zur Auswahl. Underlying-Wert bleibt
4
+ // ISO `yyyy-mm-dd` damit Server-/Wire-Serialisierung unverändert
5
+ // funktioniert.
6
+ //
7
+ // Warum nicht type=date: Native date-Inputs sehen je nach Browser/OS
8
+ // völlig anders aus, der Picker-Overlay ist nicht stylebar, und der
9
+ // Format-Match ist Locale-spezifisch außerhalb unserer Kontrolle.
10
+ // Mit Popover + Calendar haben wir konsistentes Linear-Look-and-Feel.
11
+
12
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
13
+ import { Calendar as CalendarIcon } from "lucide-react";
14
+ import { type ReactNode, useState } from "react";
15
+ import { DayPicker } from "react-day-picker";
16
+ import { cn } from "../lib/cn";
17
+
18
+ export type DateInputProps = {
19
+ readonly id: string;
20
+ readonly name: string;
21
+ readonly value: string;
22
+ readonly onChange: (v: string | undefined) => void;
23
+ readonly disabled?: boolean;
24
+ readonly required?: boolean;
25
+ readonly hasError?: boolean;
26
+ readonly locale?: string;
27
+ };
28
+
29
+ const triggerClass =
30
+ "flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent " +
31
+ "px-3 py-1 text-sm shadow-sm transition-colors text-left " +
32
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring " +
33
+ "disabled:cursor-not-allowed disabled:opacity-50";
34
+
35
+ const popoverClass =
36
+ "z-50 rounded-md border bg-popover p-3 text-popover-foreground shadow-md " +
37
+ "data-[state=open]:animate-in data-[state=closed]:animate-out " +
38
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 " +
39
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95";
40
+
41
+ export function DateInput({
42
+ id,
43
+ name,
44
+ value,
45
+ onChange,
46
+ disabled,
47
+ required,
48
+ hasError,
49
+ locale,
50
+ }: DateInputProps): ReactNode {
51
+ const [open, setOpen] = useState(false);
52
+ const resolvedLocale = locale ?? guessLocale();
53
+ const selected = parseIso(value);
54
+
55
+ const display =
56
+ selected !== undefined
57
+ ? selected.toLocaleDateString(resolvedLocale, {
58
+ year: "numeric",
59
+ month: "long",
60
+ day: "numeric",
61
+ })
62
+ : "";
63
+
64
+ return (
65
+ <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
66
+ <PopoverPrimitive.Trigger asChild>
67
+ <button
68
+ type="button"
69
+ id={id}
70
+ name={name}
71
+ disabled={disabled}
72
+ // aria-required wird auf <button> nicht unterstützt — stattdessen
73
+ // markiert <Field>-Label das Required mit "*" für Sehende und
74
+ // gibt den Status über aria-invalid (bei Fehler) durch.
75
+ data-required={required === true ? "true" : undefined}
76
+ aria-invalid={hasError === true ? true : undefined}
77
+ className={cn(
78
+ triggerClass,
79
+ hasError === true && "border-destructive focus-visible:ring-destructive",
80
+ )}
81
+ >
82
+ <span className={cn(display === "" && "text-muted-foreground")}>
83
+ {display === "" ? "—" : display}
84
+ </span>
85
+ <CalendarIcon className="size-4 text-muted-foreground" aria-hidden="true" />
86
+ </button>
87
+ </PopoverPrimitive.Trigger>
88
+ <PopoverPrimitive.Portal>
89
+ <PopoverPrimitive.Content className={popoverClass} align="start" sideOffset={4}>
90
+ <DayPicker
91
+ mode="single"
92
+ selected={selected}
93
+ // Ohne defaultMonth zeigt DayPicker today statt selected —
94
+ // unintuitiv wenn der User schon ein Datum gewählt hat und
95
+ // den Calendar nochmal öffnet.
96
+ defaultMonth={selected}
97
+ onSelect={(d) => {
98
+ onChange(d !== undefined ? toIso(d) : undefined);
99
+ setOpen(false);
100
+ }}
101
+ classNames={dayPickerClasses}
102
+ />
103
+ </PopoverPrimitive.Content>
104
+ </PopoverPrimitive.Portal>
105
+ </PopoverPrimitive.Root>
106
+ );
107
+ }
108
+
109
+ // classNames-Map für react-day-picker v9 — überschreibt die default-
110
+ // Klassen mit Tailwind/shadcn-Tokens. Nur die wichtigsten Slots; der
111
+ // Rest erbt die rdp-Default-Styles. Padding/Größen sind klein gehalten
112
+ // damit der Popover nicht das halbe Viewport einnimmt.
113
+ const dayPickerClasses = {
114
+ root: "rdp-root",
115
+ months: "flex flex-col gap-2",
116
+ month: "flex flex-col gap-2",
117
+ month_caption: "flex justify-center items-center h-7 text-sm font-medium",
118
+ caption_label: "text-sm font-medium",
119
+ nav: "flex items-center gap-1 absolute right-3 top-3",
120
+ button_previous: "inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent",
121
+ button_next: "inline-flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent",
122
+ weekdays: "flex",
123
+ weekday: "w-8 text-xs font-normal text-muted-foreground",
124
+ week: "flex mt-1",
125
+ day: "w-8 h-8 p-0 text-sm",
126
+ day_button:
127
+ "inline-flex h-8 w-8 items-center justify-center rounded-sm hover:bg-accent " +
128
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
129
+ selected:
130
+ "[&_button]:bg-primary [&_button]:text-primary-foreground [&_button]:hover:bg-primary/90",
131
+ today: "[&_button]:underline",
132
+ outside: "text-muted-foreground/50",
133
+ disabled: "text-muted-foreground/30 pointer-events-none",
134
+ };
135
+
136
+ function parseIso(v: string): Date | undefined {
137
+ if (v === "") return undefined;
138
+ // Date(yyyy-mm-dd) parses as UTC — wir wollen local damit "2026-04-25"
139
+ // im Calendar nicht je nach Timezone als 24. oder 25. erscheint.
140
+ const parts = v.split("-");
141
+ if (parts.length !== 3) return undefined;
142
+ const [y, m, d] = parts.map(Number);
143
+ if (
144
+ y === undefined ||
145
+ m === undefined ||
146
+ d === undefined ||
147
+ Number.isNaN(y) ||
148
+ Number.isNaN(m) ||
149
+ Number.isNaN(d)
150
+ )
151
+ return undefined;
152
+ return new Date(y, m - 1, d);
153
+ }
154
+
155
+ function toIso(d: Date): string {
156
+ const y = d.getFullYear();
157
+ const m = String(d.getMonth() + 1).padStart(2, "0");
158
+ const day = String(d.getDate()).padStart(2, "0");
159
+ return `${y}-${m}-${day}`;
160
+ }
161
+
162
+ function guessLocale(): string {
163
+ if (typeof navigator !== "undefined" && navigator.language) return navigator.language;
164
+ return "en-US";
165
+ }
@@ -0,0 +1,119 @@
1
+ // shadcn-Style Dialog via @radix-ui/react-dialog. Confirm-Button-
2
+ // Variant kommt aus dem Standard-Button-Pattern; Cancel ist immer
3
+ // secondary. Async-onConfirm wird über loading-State gerendert
4
+ // (Spinner im Confirm-Button bis der Promise resolved).
5
+ //
6
+ // Ausgelagert von primitives/index.tsx weil das Radix-Setup mehrere
7
+ // Dependencies und Sub-Exports zieht — hält das Primitives-Hauptfile
8
+ // schlank.
9
+
10
+ import type { DialogProps } from "@cosmicdrift/kumiko-renderer";
11
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
12
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
13
+ import { Loader2, X } from "lucide-react";
14
+ import { type ReactNode, useState } from "react";
15
+ import { cn } from "../lib/cn";
16
+
17
+ export function DefaultDialog({
18
+ open,
19
+ onOpenChange,
20
+ title,
21
+ description,
22
+ confirmLabel,
23
+ cancelLabel,
24
+ variant = "default",
25
+ onConfirm,
26
+ children,
27
+ testId,
28
+ }: DialogProps): ReactNode {
29
+ const t = useTranslation();
30
+ const [loading, setLoading] = useState(false);
31
+
32
+ const effectiveConfirmLabel = confirmLabel ?? t("kumiko.dialog.confirm");
33
+ const effectiveCancelLabel = cancelLabel ?? t("kumiko.dialog.cancel");
34
+
35
+ async function handleConfirm(): Promise<void> {
36
+ setLoading(true);
37
+ try {
38
+ await onConfirm();
39
+ onOpenChange(false);
40
+ } finally {
41
+ setLoading(false);
42
+ }
43
+ }
44
+
45
+ const isDanger = variant === "danger";
46
+ const confirmClass = isDanger
47
+ ? "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90"
48
+ : "bg-primary text-primary-foreground shadow hover:bg-primary/90";
49
+
50
+ return (
51
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
52
+ <DialogPrimitive.Portal>
53
+ <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
54
+ <DialogPrimitive.Content
55
+ data-testid={testId}
56
+ // Wenn description fehlt, signalisieren wir Radix explizit
57
+ // `aria-describedby={undefined}` — sonst warnt Radix zur
58
+ // Laufzeit (aria-Best-Practice: Modals sollen entweder eine
59
+ // Description haben oder den Hinweis tragen dass keine da ist).
60
+ {...(description === undefined && { "aria-describedby": undefined })}
61
+ className={cn(
62
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg",
63
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
64
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
65
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
66
+ "rounded-lg",
67
+ )}
68
+ >
69
+ <div className="flex flex-col gap-1.5">
70
+ <DialogPrimitive.Title className="text-lg font-semibold tracking-tight">
71
+ {title}
72
+ </DialogPrimitive.Title>
73
+ {description !== undefined && (
74
+ <DialogPrimitive.Description className="text-sm text-muted-foreground">
75
+ {description}
76
+ </DialogPrimitive.Description>
77
+ )}
78
+ </div>
79
+ {children !== undefined && <div>{children}</div>}
80
+ <div className="flex items-center justify-end gap-2">
81
+ <DialogPrimitive.Close asChild>
82
+ <button
83
+ type="button"
84
+ disabled={loading}
85
+ data-testid={testId !== undefined ? `${testId}-cancel` : undefined}
86
+ className="inline-flex h-9 items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
87
+ >
88
+ {effectiveCancelLabel}
89
+ </button>
90
+ </DialogPrimitive.Close>
91
+ <button
92
+ type="button"
93
+ onClick={() => void handleConfirm()}
94
+ disabled={loading}
95
+ data-testid={testId !== undefined ? `${testId}-confirm` : undefined}
96
+ className={cn(
97
+ "inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium",
98
+ "disabled:pointer-events-none disabled:opacity-50",
99
+ confirmClass,
100
+ )}
101
+ >
102
+ {loading && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
103
+ {effectiveConfirmLabel}
104
+ </button>
105
+ </div>
106
+ <DialogPrimitive.Close asChild>
107
+ <button
108
+ type="button"
109
+ aria-label={t("kumiko.dialog.close")}
110
+ className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
111
+ >
112
+ <X className="size-4" />
113
+ </button>
114
+ </DialogPrimitive.Close>
115
+ </DialogPrimitive.Content>
116
+ </DialogPrimitive.Portal>
117
+ </DialogPrimitive.Root>
118
+ );
119
+ }
@@ -0,0 +1,103 @@
1
+ // shadcn-style DropdownMenu auf Radix-Basis. Wrappers um
2
+ // @radix-ui/react-dropdown-menu mit den Tailwind-Token-Klassen die
3
+ // die anderen Primitives auch nutzen — Trigger-Button-Look kommt vom
4
+ // Caller (asChild-Pattern), wir liefern nur Content/Item/Label/
5
+ // Separator/CheckboxItem mit konsistenter Optik.
6
+ //
7
+ // Vorteile gegenüber dem self-rolled useDropdownMenu-Hook:
8
+ // - Click-outside, Escape, Focus-Trap, Roving-Tabindex, ARIA-Roles,
9
+ // Pointer-vs-Keyboard-Subtleties — alles geschenkt von Radix.
10
+ // - Portal'd Content rendert über Stacking-Context-Grenzen (nützlich
11
+ // in Dialogs/Popovers).
12
+ // - Keyboard-Nav (↑↓ + Home/End) funktioniert von Haus aus.
13
+
14
+ import * as Primitive from "@radix-ui/react-dropdown-menu";
15
+ import { Check } from "lucide-react";
16
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
17
+ import { cn } from "../lib/cn";
18
+
19
+ export const DropdownMenu = Primitive.Root;
20
+ export const DropdownMenuTrigger = Primitive.Trigger;
21
+ export const DropdownMenuPortal = Primitive.Portal;
22
+
23
+ const contentClass =
24
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 " +
25
+ "text-popover-foreground shadow-md " +
26
+ "data-[state=open]:animate-in data-[state=closed]:animate-out " +
27
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 " +
28
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 " +
29
+ "data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2";
30
+
31
+ export function DropdownMenuContent({
32
+ className,
33
+ sideOffset = 4,
34
+ ...props
35
+ }: ComponentPropsWithoutRef<typeof Primitive.Content>): ReactNode {
36
+ return (
37
+ <Primitive.Portal>
38
+ <Primitive.Content
39
+ sideOffset={sideOffset}
40
+ className={cn(contentClass, className)}
41
+ {...props}
42
+ />
43
+ </Primitive.Portal>
44
+ );
45
+ }
46
+
47
+ const itemClass =
48
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm " +
49
+ "outline-none transition-colors " +
50
+ "focus:bg-accent focus:text-accent-foreground " +
51
+ "data-[disabled]:pointer-events-none data-[disabled]:opacity-50";
52
+
53
+ export function DropdownMenuItem({
54
+ className,
55
+ ...props
56
+ }: ComponentPropsWithoutRef<typeof Primitive.Item>): ReactNode {
57
+ return <Primitive.Item className={cn(itemClass, className)} {...props} />;
58
+ }
59
+
60
+ // CheckboxItem mit Check-Icon links — für "ausgewählter Eintrag in
61
+ // einer Liste"-Patterns (TenantSwitcher, LanguageSwitcher). Radix
62
+ // rendert <ItemIndicator> nur wenn checked=true, kein eigener Branch
63
+ // nötig.
64
+ export function DropdownMenuCheckboxItem({
65
+ className,
66
+ children,
67
+ checked,
68
+ ...props
69
+ }: ComponentPropsWithoutRef<typeof Primitive.CheckboxItem>): ReactNode {
70
+ return (
71
+ <Primitive.CheckboxItem
72
+ checked={checked}
73
+ className={cn(itemClass, "pl-8 pr-2", className)}
74
+ {...props}
75
+ >
76
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
77
+ <Primitive.ItemIndicator>
78
+ <Check className="h-3.5 w-3.5" />
79
+ </Primitive.ItemIndicator>
80
+ </span>
81
+ {children}
82
+ </Primitive.CheckboxItem>
83
+ );
84
+ }
85
+
86
+ export function DropdownMenuLabel({
87
+ className,
88
+ ...props
89
+ }: ComponentPropsWithoutRef<typeof Primitive.Label>): ReactNode {
90
+ return (
91
+ <Primitive.Label
92
+ className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
93
+ {...props}
94
+ />
95
+ );
96
+ }
97
+
98
+ export function DropdownMenuSeparator({
99
+ className,
100
+ ...props
101
+ }: ComponentPropsWithoutRef<typeof Primitive.Separator>): ReactNode {
102
+ return <Primitive.Separator className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />;
103
+ }