@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,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
|
+
}
|