@cosmicdrift/kumiko-renderer-web 0.193.0 → 0.194.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 +4 -4
- package/src/__tests__/create-app.test.tsx +1 -1
- package/src/__tests__/entity-edit-redirect.test.tsx +111 -0
- package/src/__tests__/field-hide-label.test.tsx +166 -1
- package/src/__tests__/kumiko-screen.test.tsx +59 -0
- package/src/__tests__/nav-tree.test.tsx +43 -11
- package/src/__tests__/primitives.test.tsx +33 -2
- package/src/__tests__/render-edit.test.tsx +344 -9
- package/src/__tests__/test-utils.tsx +20 -0
- package/src/__tests__/wizard-form-validation.test.tsx +129 -1
- package/src/app/__tests__/merge-content-editors.test.ts +2 -2
- package/src/app/__tests__/plain-content-editor.test.tsx +4 -0
- package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
- package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
- package/src/app/client-plugin.tsx +3 -3
- package/src/app/create-app.tsx +40 -49
- package/src/app/tiptap-editor.tsx +10 -0
- package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
- package/src/layout/sidebar-panel.tsx +1 -4
- package/src/layout/workspace-switcher.tsx +20 -13
- package/src/lib/__tests__/resize-image.test.ts +152 -0
- package/src/lib/clamp.ts +3 -0
- package/src/lib/resize-image.ts +61 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
- package/src/primitives/__tests__/date-field.test.tsx +37 -0
- package/src/primitives/__tests__/date-parse.test.ts +14 -2
- package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
- package/src/primitives/__tests__/file-upload.test.tsx +24 -0
- package/src/primitives/date-field.tsx +8 -0
- package/src/primitives/date-parse.ts +6 -1
- package/src/primitives/dropdown-menu.tsx +23 -0
- package/src/primitives/embedded-list-input.tsx +14 -5
- package/src/primitives/file-upload.tsx +6 -1
- package/src/primitives/index.tsx +114 -120
- package/src/primitives/money-input.tsx +4 -7
- package/src/ui/sheet.tsx +3 -1
- package/src/widgets/__tests__/drawer.test.tsx +293 -0
- package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
- package/src/widgets/__tests__/upload-zone.test.tsx +125 -1
- package/src/widgets/__tests__/widgets.test.tsx +25 -0
- package/src/widgets/ai-text-field.tsx +3 -1
- package/src/widgets/drawer.tsx +54 -29
- package/src/widgets/infinity-list.tsx +18 -5
- package/src/widgets/progress-bar.tsx +3 -1
- package/src/widgets/step-bar.tsx +1 -1
- package/src/widgets/upload-zone.tsx +42 -3
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// rejection.
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { kumikoDefaultTranslations } from "@cosmicdrift/kumiko-renderer";
|
|
8
9
|
import { Temporal } from "temporal-polyfill";
|
|
9
10
|
import {
|
|
10
11
|
formatDateForInput,
|
|
@@ -14,8 +15,19 @@ import {
|
|
|
14
15
|
toIso,
|
|
15
16
|
} from "../date-parse";
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// Derived from kumikoDefaultTranslations instead of hardcoded so a drift in
|
|
19
|
+
// the i18n defaults (e.g. renaming "T" → "D") fails this test too.
|
|
20
|
+
function placeholderLetters(locale: "de" | "en"): { year: string; month: string; day: string } {
|
|
21
|
+
const bundle = kumikoDefaultTranslations[locale];
|
|
22
|
+
return {
|
|
23
|
+
year: bundle?.["kumiko.field.dateField.placeholderYear"] ?? "",
|
|
24
|
+
month: bundle?.["kumiko.field.dateField.placeholderMonth"] ?? "",
|
|
25
|
+
day: bundle?.["kumiko.field.dateField.placeholderDay"] ?? "",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const dePlaceholderLetters = placeholderLetters("de");
|
|
30
|
+
const enPlaceholderLetters = placeholderLetters("en");
|
|
19
31
|
|
|
20
32
|
describe("parseIso", () => {
|
|
21
33
|
test("valid yyyy-mm-dd → PlainDate (no TZ conversion)", () => {
|
|
@@ -14,6 +14,12 @@ import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
|
14
14
|
import { type ReactElement, useState } from "react";
|
|
15
15
|
import { EmbeddedListInput } from "../embedded-list-input";
|
|
16
16
|
|
|
17
|
+
function setViewportWidth(width: number): void {
|
|
18
|
+
(
|
|
19
|
+
window as unknown as { happyDOM: { setInnerWidth: (n: number) => void } }
|
|
20
|
+
).happyDOM.setInnerWidth(width);
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
function renderWithLocale(ui: ReactElement) {
|
|
18
24
|
return render(
|
|
19
25
|
<LocaleProvider
|
|
@@ -119,14 +125,14 @@ describe("EmbeddedListInput — desktop/mobile are mutually exclusive mounts (#1
|
|
|
119
125
|
|
|
120
126
|
test("mobile viewport mounts only the card layout, not the table", () => {
|
|
121
127
|
const originalWidth = window.innerWidth;
|
|
122
|
-
|
|
128
|
+
setViewportWidth(500);
|
|
123
129
|
try {
|
|
124
130
|
renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
|
|
125
131
|
expect(screen.getByTestId("lines-mobile")).toBeTruthy();
|
|
126
132
|
expect(screen.queryByTestId("lines-desktop")).toBeNull();
|
|
127
133
|
expect(document.querySelectorAll('[data-cell-id="lines-0-amount"]').length).toBe(1);
|
|
128
134
|
} finally {
|
|
129
|
-
|
|
135
|
+
setViewportWidth(originalWidth);
|
|
130
136
|
}
|
|
131
137
|
});
|
|
132
138
|
});
|
|
@@ -626,6 +632,34 @@ describe("EmbeddedListInput — currency (#1839)", () => {
|
|
|
626
632
|
});
|
|
627
633
|
});
|
|
628
634
|
|
|
635
|
+
describe("EmbeddedListInput — totals follow the app locale, not the browser locale", () => {
|
|
636
|
+
test("a de-DE app locale formats both the money total and a plain number total with '.' thousands / ',' decimal", () => {
|
|
637
|
+
const rows = [{ description: "A", quantity: 1234.5, amount: 150099 }];
|
|
638
|
+
render(
|
|
639
|
+
<LocaleProvider
|
|
640
|
+
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
641
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
642
|
+
>
|
|
643
|
+
<EmbeddedListInput
|
|
644
|
+
{...baseProps({
|
|
645
|
+
rows,
|
|
646
|
+
currency: "EUR",
|
|
647
|
+
totals: [
|
|
648
|
+
{ field: "amount", label: "Sum", value: 150099 },
|
|
649
|
+
{ field: "quantity", label: "Qty total", value: 1234.5 },
|
|
650
|
+
],
|
|
651
|
+
})}
|
|
652
|
+
/>
|
|
653
|
+
</LocaleProvider>,
|
|
654
|
+
);
|
|
655
|
+
const totals = within(screen.getByTestId("lines-desktop")).getByTestId("lines-totals");
|
|
656
|
+
// de-DE: money "1.500,99 €", plain number "1.234,5" — both use "." as
|
|
657
|
+
// the thousands separator and "," as the decimal separator.
|
|
658
|
+
expect(totals.textContent).toContain("1.500,99");
|
|
659
|
+
expect(totals.textContent).toContain("1.234,5");
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
629
663
|
describe("EmbeddedListInput — desktop table width (solon#107)", () => {
|
|
630
664
|
test("the table keeps min-w-max so columns don't shrink below their width classes", () => {
|
|
631
665
|
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
@@ -23,3 +23,27 @@ describe("FileUploadInput — image variant preview src (#1950)", () => {
|
|
|
23
23
|
expect(container.querySelector("img")?.getAttribute("src")).toBe("/api/files/abc");
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
describe("FileUploadInput — camera capture (#1953)", () => {
|
|
28
|
+
test("with capture set, the <input type=file> carries the capture attribute", () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<FileUploadInput
|
|
31
|
+
kind="image"
|
|
32
|
+
id="avatar"
|
|
33
|
+
value={null}
|
|
34
|
+
onChange={noop}
|
|
35
|
+
capture="environment"
|
|
36
|
+
/>,
|
|
37
|
+
);
|
|
38
|
+
expect(container.querySelector('input[type="file"]')?.getAttribute("capture")).toBe(
|
|
39
|
+
"environment",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("without capture, the <input type=file> has no capture attribute", () => {
|
|
44
|
+
const { container } = render(
|
|
45
|
+
<FileUploadInput kind="image" id="avatar" value={null} onChange={noop} />,
|
|
46
|
+
);
|
|
47
|
+
expect(container.querySelector('input[type="file"]')?.hasAttribute("capture")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -105,6 +105,14 @@ export function DateField({
|
|
|
105
105
|
disabled={disabled}
|
|
106
106
|
required={required}
|
|
107
107
|
aria-invalid={hasError === true ? true : undefined}
|
|
108
|
+
// Deliberate: slot order/separator follow resolvedLocale (the
|
|
109
|
+
// `locale` prop, or the browser locale), but the letters follow
|
|
110
|
+
// the app's UI locale via t() — a caller rendering a date in a
|
|
111
|
+
// locale that differs from the UI locale gets a mixed-locale
|
|
112
|
+
// placeholder (e.g. German UI + locale="en-US" → "MM/TT/JJJJ").
|
|
113
|
+
// Accepted for now (fw#1879): a static per-locale letter lookup
|
|
114
|
+
// would need its own locale-root resolution and tests for a
|
|
115
|
+
// placeholder-only, Low-severity mismatch.
|
|
108
116
|
placeholder={formatDatePlaceholder(resolvedLocale, {
|
|
109
117
|
year: t("kumiko.field.dateField.placeholderYear"),
|
|
110
118
|
month: t("kumiko.field.dateField.placeholderMonth"),
|
|
@@ -80,7 +80,12 @@ function localeDateParts(locale: string): Intl.DateTimeFormatPart[] {
|
|
|
80
80
|
const refEpochMillis = activeTemporal()
|
|
81
81
|
.PlainDate.from({ year: 2026, month: 1, day: 2 })
|
|
82
82
|
.toZonedDateTime("UTC").epochMilliseconds;
|
|
83
|
-
return new Intl.DateTimeFormat(locale, {
|
|
83
|
+
return new Intl.DateTimeFormat(locale, {
|
|
84
|
+
timeZone: "UTC",
|
|
85
|
+
year: "numeric",
|
|
86
|
+
month: "2-digit",
|
|
87
|
+
day: "2-digit",
|
|
88
|
+
}).formatToParts(refEpochMillis);
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
// Field order of the numeric locale format. de → [d,m,y], en-US →
|
|
@@ -83,6 +83,29 @@ export function DropdownMenuCheckboxItem({
|
|
|
83
83
|
);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
export const DropdownMenuRadioGroup = Primitive.RadioGroup;
|
|
87
|
+
|
|
88
|
+
// RadioItem with a dot indicator on the left — for exclusive single-choice
|
|
89
|
+
// selection (e.g. WorkspaceSwitcher). Unlike CheckboxItem, Radix renders
|
|
90
|
+
// role="menuitemradio" + aria-checked here instead of role="menuitemcheckbox",
|
|
91
|
+
// which announces "one of several" to screen readers instead of "on/off".
|
|
92
|
+
export function DropdownMenuRadioItem({
|
|
93
|
+
className,
|
|
94
|
+
children,
|
|
95
|
+
...props
|
|
96
|
+
}: ComponentPropsWithoutRef<typeof Primitive.RadioItem>): ReactNode {
|
|
97
|
+
return (
|
|
98
|
+
<Primitive.RadioItem className={cn(itemClass, "pl-8 pr-2", className)} {...props}>
|
|
99
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
100
|
+
<Primitive.ItemIndicator>
|
|
101
|
+
<Check className="h-3.5 w-3.5" />
|
|
102
|
+
</Primitive.ItemIndicator>
|
|
103
|
+
</span>
|
|
104
|
+
{children}
|
|
105
|
+
</Primitive.RadioItem>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
86
109
|
export function DropdownMenuLabel({
|
|
87
110
|
className,
|
|
88
111
|
...props
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
// viewport, so the `hidden md:block` / `md:hidden` classes stay on the
|
|
12
12
|
// wrapper divs too: on a phone that first (wrong) render is desktop but
|
|
13
13
|
// CSS-hidden, not a visible flash of the wrong layout.
|
|
14
|
+
//
|
|
15
|
+
// ponytail: crossing the mobile/desktop breakpoint unmounts the active
|
|
16
|
+
// layout and mounts the other, so an in-progress cell draft (uncommitted
|
|
17
|
+
// MoneyInput text, IME composition) and focus are lost — accepted trade-off
|
|
18
|
+
// for the single-mount-per-side fix above. Upgrade: lift per-cell draft
|
|
19
|
+
// state into a `draftsByCellId` map here that survives the layout swap.
|
|
14
20
|
|
|
15
21
|
import type { FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
16
22
|
import type {
|
|
@@ -19,7 +25,7 @@ import type {
|
|
|
19
25
|
EmbeddedListInputProps,
|
|
20
26
|
EmbeddedListTotal,
|
|
21
27
|
} from "@cosmicdrift/kumiko-renderer";
|
|
22
|
-
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
28
|
+
import { useLocale, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
23
29
|
import { ArrowDown, ArrowUp, Copy, Plus, Trash2 } from "lucide-react";
|
|
24
30
|
import {
|
|
25
31
|
type ClipboardEvent,
|
|
@@ -94,10 +100,11 @@ function formatTotalValue(
|
|
|
94
100
|
total: EmbeddedListTotal,
|
|
95
101
|
columns: readonly EmbeddedListColumn[],
|
|
96
102
|
currency: string,
|
|
103
|
+
locale: string,
|
|
97
104
|
): string {
|
|
98
105
|
const column = columns.find((c) => c.field === total.field);
|
|
99
|
-
if (column?.type === "money") return formatMoney(total.value, currency);
|
|
100
|
-
return total.value.toLocaleString();
|
|
106
|
+
if (column?.type === "money") return formatMoney(total.value, currency, locale);
|
|
107
|
+
return total.value.toLocaleString(locale);
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
// Tab/newline-delimited clipboard text → 2D string grid. Not a regex
|
|
@@ -375,6 +382,7 @@ export function EmbeddedListInput({
|
|
|
375
382
|
// callers/tests that predate #1839) keep getting the same "EUR" this
|
|
376
383
|
// component always hardcoded.
|
|
377
384
|
const effectiveCurrency = currency ?? "EUR";
|
|
385
|
+
const resolvedLocale = useLocale().locale();
|
|
378
386
|
const isMobile = useIsMobile();
|
|
379
387
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
380
388
|
const [pendingFocusCellId, setPendingFocusCellId] = useState<string | undefined>(undefined);
|
|
@@ -419,6 +427,7 @@ export function EmbeddedListInput({
|
|
|
419
427
|
const handlePaste = (rowIndex: number, columnIndex: number) => {
|
|
420
428
|
return (event: ClipboardEvent<HTMLElement>): void => {
|
|
421
429
|
if (onPasteCells === undefined) return;
|
|
430
|
+
if (disabled === true) return;
|
|
422
431
|
const text = event.clipboardData?.getData("text") ?? "";
|
|
423
432
|
const grid = parsePasteGrid(text);
|
|
424
433
|
const isMultiCell = grid.length > 1 || (grid[0]?.length ?? 0) > 1;
|
|
@@ -596,7 +605,7 @@ export function EmbeddedListInput({
|
|
|
596
605
|
<div key={total.field} className="flex items-baseline gap-2">
|
|
597
606
|
<span className="text-muted-foreground">{total.label}</span>
|
|
598
607
|
<span className="font-medium tabular-nums">
|
|
599
|
-
{formatTotalValue(total, columns, effectiveCurrency)}
|
|
608
|
+
{formatTotalValue(total, columns, effectiveCurrency, resolvedLocale)}
|
|
600
609
|
</span>
|
|
601
610
|
</div>
|
|
602
611
|
))}
|
|
@@ -699,7 +708,7 @@ export function EmbeddedListInput({
|
|
|
699
708
|
<div key={total.field} className="flex items-center justify-between">
|
|
700
709
|
<span className="text-muted-foreground">{total.label}</span>
|
|
701
710
|
<span className="font-medium tabular-nums">
|
|
702
|
-
{formatTotalValue(total, columns, effectiveCurrency)}
|
|
711
|
+
{formatTotalValue(total, columns, effectiveCurrency, resolvedLocale)}
|
|
703
712
|
</span>
|
|
704
713
|
</div>
|
|
705
714
|
))}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
7
7
|
import { ImageIcon, Loader2, Upload } from "lucide-react";
|
|
8
8
|
import { type ChangeEvent, type ReactNode, useRef, useState } from "react";
|
|
9
|
+
import { resizeImageBeforeUpload } from "../lib/resize-image";
|
|
9
10
|
import { Button as UiButton } from "../ui/button";
|
|
10
11
|
|
|
11
12
|
export type FileUploadInputProps = {
|
|
@@ -18,6 +19,7 @@ export type FileUploadInputProps = {
|
|
|
18
19
|
readonly entityType?: string;
|
|
19
20
|
readonly fieldName?: string;
|
|
20
21
|
readonly imageVariant?: string;
|
|
22
|
+
readonly capture?: "environment" | "user";
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
// "jpg" → ".jpg", "image/png" bleibt. Leere Liste → kein accept-Attribut.
|
|
@@ -36,6 +38,7 @@ export function FileUploadInput({
|
|
|
36
38
|
entityType,
|
|
37
39
|
fieldName,
|
|
38
40
|
imageVariant,
|
|
41
|
+
capture,
|
|
39
42
|
}: FileUploadInputProps): ReactNode {
|
|
40
43
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
41
44
|
const [uploading, setUploading] = useState(false);
|
|
@@ -47,8 +50,9 @@ export function FileUploadInput({
|
|
|
47
50
|
setUploading(true);
|
|
48
51
|
setError(null);
|
|
49
52
|
try {
|
|
53
|
+
const uploadFile = await resizeImageBeforeUpload(file);
|
|
50
54
|
const fd = new FormData();
|
|
51
|
-
fd.append("file",
|
|
55
|
+
fd.append("file", uploadFile);
|
|
52
56
|
if (entityType !== undefined) fd.append("entityType", entityType);
|
|
53
57
|
if (fieldName !== undefined) fd.append("fieldName", fieldName);
|
|
54
58
|
// Double-Submit-CSRF wie der Dispatcher: kumiko_csrf-Cookie → Header.
|
|
@@ -103,6 +107,7 @@ export function FileUploadInput({
|
|
|
103
107
|
type="file"
|
|
104
108
|
className="hidden"
|
|
105
109
|
{...(acceptAttr !== undefined && { accept: acceptAttr })}
|
|
110
|
+
{...(capture !== undefined && { capture })}
|
|
106
111
|
onChange={(e) => void onPick(e)}
|
|
107
112
|
disabled={disabled}
|
|
108
113
|
/>
|
package/src/primitives/index.tsx
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type TextProps,
|
|
38
38
|
useColumnRenderer,
|
|
39
39
|
useTranslation,
|
|
40
|
+
type WizardStepGroupProps,
|
|
40
41
|
WriteFailedError,
|
|
41
42
|
} from "@cosmicdrift/kumiko-renderer";
|
|
42
43
|
import { cva } from "class-variance-authority";
|
|
@@ -188,11 +189,16 @@ function DefaultBanner({
|
|
|
188
189
|
id,
|
|
189
190
|
}: BannerProps): ReactNode {
|
|
190
191
|
const isError = variant === "error";
|
|
192
|
+
// `id` is set when a <Field> wraps this Banner as its control (see
|
|
193
|
+
// BannerProps.id). A <div> isn't labelable, so a <label htmlFor> pointing
|
|
194
|
+
// at it is inert for screen readers — role="group" + aria-labelledby is
|
|
195
|
+
// the actual mechanism, pointed at the label id Field emits (fieldLabelId).
|
|
196
|
+
const bannerRole = id !== undefined ? "group" : isError ? "alert" : undefined;
|
|
191
197
|
const banner = (
|
|
192
198
|
<div
|
|
193
|
-
|
|
199
|
+
role={bannerRole}
|
|
200
|
+
{...(id !== undefined && { "aria-labelledby": fieldLabelId(id) })}
|
|
194
201
|
data-testid={testId}
|
|
195
|
-
role={isError ? "alert" : undefined}
|
|
196
202
|
data-variant={variant}
|
|
197
203
|
className={cn(
|
|
198
204
|
"relative w-full rounded-lg border px-4 py-3 text-sm flex items-center gap-3",
|
|
@@ -213,6 +219,13 @@ function DefaultBanner({
|
|
|
213
219
|
|
|
214
220
|
// ---- Field (Label + Error) ----
|
|
215
221
|
|
|
222
|
+
// Matches DefaultBanner's aria-labelledby target: a non-labelable control
|
|
223
|
+
// (e.g. Banner's <div>) can't take a real htmlFor, so Field always also
|
|
224
|
+
// gives its label a stable id that such controls can point to.
|
|
225
|
+
function fieldLabelId(id: string): string {
|
|
226
|
+
return `${id}-label`;
|
|
227
|
+
}
|
|
228
|
+
|
|
216
229
|
function DefaultField({
|
|
217
230
|
id,
|
|
218
231
|
label,
|
|
@@ -229,6 +242,7 @@ function DefaultField({
|
|
|
229
242
|
const hasError = issues !== undefined && issues.length > 0;
|
|
230
243
|
const labelEl = (
|
|
231
244
|
<UiLabel
|
|
245
|
+
id={fieldLabelId(id)}
|
|
232
246
|
htmlFor={id}
|
|
233
247
|
className={cn(
|
|
234
248
|
hasError ? "text-destructive" : "text-foreground",
|
|
@@ -236,7 +250,11 @@ function DefaultField({
|
|
|
236
250
|
)}
|
|
237
251
|
>
|
|
238
252
|
{label}
|
|
239
|
-
{required === true &&
|
|
253
|
+
{required === true && (
|
|
254
|
+
<span data-required className="ml-0.5 text-destructive">
|
|
255
|
+
*
|
|
256
|
+
</span>
|
|
257
|
+
)}
|
|
240
258
|
</UiLabel>
|
|
241
259
|
);
|
|
242
260
|
const errorsEl = hasError ? (
|
|
@@ -435,6 +453,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
435
453
|
{...(props.entityType !== undefined && { entityType: props.entityType })}
|
|
436
454
|
{...(props.fieldName !== undefined && { fieldName: props.fieldName })}
|
|
437
455
|
{...(props.imageVariant !== undefined && { imageVariant: props.imageVariant })}
|
|
456
|
+
{...(props.capture !== undefined && { capture: props.capture })}
|
|
438
457
|
/>
|
|
439
458
|
);
|
|
440
459
|
case "date":
|
|
@@ -554,6 +573,7 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
554
573
|
return (
|
|
555
574
|
<Textarea
|
|
556
575
|
{...common}
|
|
576
|
+
readOnly={props.readOnly}
|
|
557
577
|
value={props.value}
|
|
558
578
|
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => props.onChange(e.target.value)}
|
|
559
579
|
rows={props.rows ?? 4}
|
|
@@ -650,6 +670,7 @@ function DefaultDataTable({
|
|
|
650
670
|
// beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
|
|
651
671
|
// scrollen typischerweise mit dem Page-Container, nicht intern.
|
|
652
672
|
// Sticky würde mit der Topbar konkurrieren.
|
|
673
|
+
const hasTableActions = rowActions !== undefined && rowActions.length > 0;
|
|
653
674
|
const tableContent =
|
|
654
675
|
rows.length === 0 ? (
|
|
655
676
|
<div
|
|
@@ -665,18 +686,86 @@ function DefaultDataTable({
|
|
|
665
686
|
// Page-Background (z.B. Cream) matchen Listen sonst nicht die Cards.
|
|
666
687
|
<div className="overflow-hidden rounded-lg border bg-card">
|
|
667
688
|
<Table data-testid={testId}>
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
689
|
+
<TableHeader className="bg-muted">
|
|
690
|
+
<TableRow className="hover:bg-transparent">
|
|
691
|
+
{columns.map((col) => (
|
|
692
|
+
<SortableHeader
|
|
693
|
+
key={col.field}
|
|
694
|
+
field={col.field}
|
|
695
|
+
label={col.label}
|
|
696
|
+
sortable={col.sortable === true}
|
|
697
|
+
highlighted={col.highlighted === true}
|
|
698
|
+
{...(sort !== undefined && sort !== null && { sort })}
|
|
699
|
+
{...(onSortChange !== undefined && { onSortChange })}
|
|
700
|
+
/>
|
|
701
|
+
))}
|
|
702
|
+
{hasTableActions && (
|
|
703
|
+
<TableHead
|
|
704
|
+
data-testid="column-actions"
|
|
705
|
+
// sticky right-0 + bg-muted (= header tone) so the action
|
|
706
|
+
// column stays at the right edge during horizontal scroll.
|
|
707
|
+
// No border-l: a permanent divider looks heavy; the sticky
|
|
708
|
+
// bg already sets the column apart during scroll anyway.
|
|
709
|
+
className="sticky right-0 z-10 w-px bg-muted text-right text-muted-foreground"
|
|
710
|
+
aria-label="Actions"
|
|
711
|
+
/>
|
|
712
|
+
)}
|
|
713
|
+
</TableRow>
|
|
714
|
+
</TableHeader>
|
|
715
|
+
<TableBody>
|
|
716
|
+
{rows.map((row) => (
|
|
717
|
+
<TableRow
|
|
718
|
+
key={row.id}
|
|
719
|
+
data-testid={getRowTestId?.(row) ?? `row-${row.id}`}
|
|
720
|
+
onClick={onRowClick !== undefined ? () => onRowClick(row) : undefined}
|
|
721
|
+
className={cn(onRowClick !== undefined && "cursor-pointer")}
|
|
722
|
+
>
|
|
723
|
+
{columns.map((col) => (
|
|
724
|
+
<TableCell
|
|
725
|
+
key={col.field}
|
|
726
|
+
data-testid={getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`}
|
|
727
|
+
data-highlighted={col.highlighted === true ? "true" : undefined}
|
|
728
|
+
// Cells truncate long values with ellipsis instead of
|
|
729
|
+
// wrapping — lists stay single-line + scannable (Linear
|
|
730
|
+
// pattern). max-w-xs gives a sensible default upper
|
|
731
|
+
// bound; the table container scrolls horizontally
|
|
732
|
+
// if the sum of the columns gets too wide.
|
|
733
|
+
className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
|
|
734
|
+
title={cellTitle(row.values[col.field])}
|
|
735
|
+
>
|
|
736
|
+
<DataTableCell
|
|
737
|
+
value={row.values[col.field]}
|
|
738
|
+
row={row.values}
|
|
739
|
+
field={col.field}
|
|
740
|
+
type={col.type}
|
|
741
|
+
renderer={col.renderer}
|
|
742
|
+
{...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
|
|
743
|
+
{...(onCellChange !== undefined && {
|
|
744
|
+
onChange: (value: unknown) => onCellChange(row.id, col.field, value),
|
|
745
|
+
})}
|
|
746
|
+
/>
|
|
747
|
+
</TableCell>
|
|
748
|
+
))}
|
|
749
|
+
{hasTableActions && (
|
|
750
|
+
<TableCell
|
|
751
|
+
data-testid={getCellTestId?.(row, "actions") ?? `cell-${row.id}-actions`}
|
|
752
|
+
// Sticky-right so the actions stay visible on the right
|
|
753
|
+
// edge during horizontal scroll. bg-background sets the
|
|
754
|
+
// column apart during scroll — no border-l (divider too heavy).
|
|
755
|
+
className="sticky right-0 z-10 bg-background text-right"
|
|
756
|
+
// Action-cell events must not trigger the row click/activation
|
|
757
|
+
// (typically "Open Detail" — the user wanted the action,
|
|
758
|
+
// not navigation). We stopPropagation for mouse and
|
|
759
|
+
// keyboard so a11y stays consistent.
|
|
760
|
+
onClick={(e) => e.stopPropagation()}
|
|
761
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
762
|
+
>
|
|
763
|
+
<RowActionsCell row={row} actions={rowActions} mode={rowActionMode} />
|
|
764
|
+
</TableCell>
|
|
765
|
+
)}
|
|
766
|
+
</TableRow>
|
|
767
|
+
))}
|
|
768
|
+
</TableBody>
|
|
680
769
|
</Table>
|
|
681
770
|
</div>
|
|
682
771
|
);
|
|
@@ -767,105 +856,6 @@ function DefaultDataTable({
|
|
|
767
856
|
);
|
|
768
857
|
}
|
|
769
858
|
|
|
770
|
-
function tableInner(
|
|
771
|
-
columns: DataTableProps["columns"],
|
|
772
|
-
rows: DataTableProps["rows"],
|
|
773
|
-
onRowClick?: DataTableProps["onRowClick"],
|
|
774
|
-
sort?: DataTableProps["sort"],
|
|
775
|
-
onSortChange?: DataTableProps["onSortChange"],
|
|
776
|
-
rowActions?: DataTableProps["rowActions"],
|
|
777
|
-
rowActionMode?: DataTableProps["rowActionMode"],
|
|
778
|
-
onCellChange?: DataTableProps["onCellChange"],
|
|
779
|
-
getRowTestId?: DataTableProps["getRowTestId"],
|
|
780
|
-
getCellTestId?: DataTableProps["getCellTestId"],
|
|
781
|
-
): ReactNode {
|
|
782
|
-
const hasActions = rowActions !== undefined && rowActions.length > 0;
|
|
783
|
-
return (
|
|
784
|
-
<>
|
|
785
|
-
<TableHeader className="bg-muted">
|
|
786
|
-
<TableRow className="hover:bg-transparent">
|
|
787
|
-
{columns.map((col) => (
|
|
788
|
-
<SortableHeader
|
|
789
|
-
key={col.field}
|
|
790
|
-
field={col.field}
|
|
791
|
-
label={col.label}
|
|
792
|
-
sortable={col.sortable === true}
|
|
793
|
-
highlighted={col.highlighted === true}
|
|
794
|
-
{...(sort !== undefined && sort !== null && { sort })}
|
|
795
|
-
{...(onSortChange !== undefined && { onSortChange })}
|
|
796
|
-
/>
|
|
797
|
-
))}
|
|
798
|
-
{hasActions && (
|
|
799
|
-
<TableHead
|
|
800
|
-
data-testid="column-actions"
|
|
801
|
-
// sticky right-0 + bg-muted (= Header-Ton) damit die Action-
|
|
802
|
-
// Spalte beim horizontalen Scroll am rechten Rand bleibt. Kein
|
|
803
|
-
// border-l: der ständige Trenner wirkt schwer; die sticky-bg
|
|
804
|
-
// grenzt die Spalte beim Scroll ohnehin ab.
|
|
805
|
-
className="sticky right-0 z-10 w-px bg-muted text-right text-muted-foreground"
|
|
806
|
-
aria-label="Actions"
|
|
807
|
-
/>
|
|
808
|
-
)}
|
|
809
|
-
</TableRow>
|
|
810
|
-
</TableHeader>
|
|
811
|
-
<TableBody>
|
|
812
|
-
{rows.map((row) => (
|
|
813
|
-
<TableRow
|
|
814
|
-
key={row.id}
|
|
815
|
-
data-testid={getRowTestId?.(row) ?? `row-${row.id}`}
|
|
816
|
-
onClick={onRowClick !== undefined ? () => onRowClick(row) : undefined}
|
|
817
|
-
className={cn(onRowClick !== undefined && "cursor-pointer")}
|
|
818
|
-
>
|
|
819
|
-
{columns.map((col) => (
|
|
820
|
-
<TableCell
|
|
821
|
-
key={col.field}
|
|
822
|
-
data-testid={getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`}
|
|
823
|
-
data-highlighted={col.highlighted === true ? "true" : undefined}
|
|
824
|
-
// Cells truncaten lange Werte mit ellipsis statt umzu-
|
|
825
|
-
// brechen — Lists bleiben einzeilig + scannbar (Linear-
|
|
826
|
-
// Pattern). max-w-xs gibt eine vernünftige Default-
|
|
827
|
-
// Obergrenze; der Table-Container scrollt horizontal
|
|
828
|
-
// falls die Summe der Spalten zu breit wird.
|
|
829
|
-
className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
|
|
830
|
-
title={cellTitle(row.values[col.field])}
|
|
831
|
-
>
|
|
832
|
-
<DataTableCell
|
|
833
|
-
value={row.values[col.field]}
|
|
834
|
-
row={row.values}
|
|
835
|
-
field={col.field}
|
|
836
|
-
type={col.type}
|
|
837
|
-
renderer={col.renderer}
|
|
838
|
-
{...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
|
|
839
|
-
{...(onCellChange !== undefined && {
|
|
840
|
-
onChange: (value: unknown) => onCellChange(row.id, col.field, value),
|
|
841
|
-
})}
|
|
842
|
-
/>
|
|
843
|
-
</TableCell>
|
|
844
|
-
))}
|
|
845
|
-
{hasActions && (
|
|
846
|
-
<TableCell
|
|
847
|
-
data-testid={`cell-${row.id}-actions`}
|
|
848
|
-
// Sticky-right damit beim horizontalen Scroll die Actions
|
|
849
|
-
// am rechten Rand sichtbar bleiben. bg-background grenzt die
|
|
850
|
-
// Spalte beim Scroll ab — kein border-l (Trenner zu schwer).
|
|
851
|
-
className="sticky right-0 z-10 bg-background text-right"
|
|
852
|
-
// Action-Cell-Events dürfen nicht den Row-Click/Activation
|
|
853
|
-
// triggern (typisch "Open Detail" — der User wollte ja die
|
|
854
|
-
// Action, nicht navigieren). Wir stopPropagation für Mouse
|
|
855
|
-
// UND Keyboard, damit a11y konsistent ist.
|
|
856
|
-
onClick={(e) => e.stopPropagation()}
|
|
857
|
-
onKeyDown={(e) => e.stopPropagation()}
|
|
858
|
-
>
|
|
859
|
-
<RowActionsCell row={row} actions={rowActions} mode={rowActionMode} />
|
|
860
|
-
</TableCell>
|
|
861
|
-
)}
|
|
862
|
-
</TableRow>
|
|
863
|
-
))}
|
|
864
|
-
</TableBody>
|
|
865
|
-
</>
|
|
866
|
-
);
|
|
867
|
-
}
|
|
868
|
-
|
|
869
859
|
// RowActionsCell — rendert die Row-Actions je nach mode:
|
|
870
860
|
// - "adaptive" (Default): ≤2 sichtbare Actions inline (rechtsbündig),
|
|
871
861
|
// >2 als Kebab-Dropdown.
|
|
@@ -1666,7 +1656,7 @@ function DefaultForm({
|
|
|
1666
1656
|
// `overflow-hidden` (only transform/filter/contain ancestors trap
|
|
1667
1657
|
// it, confirmed against AppLayout/SidebarInset — neither sets those).
|
|
1668
1658
|
stickyActions === true &&
|
|
1669
|
-
"max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)] max-sm:pb-
|
|
1659
|
+
"max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)] max-sm:pb-4",
|
|
1670
1660
|
)}
|
|
1671
1661
|
>
|
|
1672
1662
|
{actions}
|
|
@@ -1722,7 +1712,6 @@ function DefaultSection({
|
|
|
1722
1712
|
actions,
|
|
1723
1713
|
variant = "default",
|
|
1724
1714
|
testId,
|
|
1725
|
-
hidden,
|
|
1726
1715
|
}: SectionProps): ReactNode {
|
|
1727
1716
|
const insideForm = useContext(InsideFormContext);
|
|
1728
1717
|
|
|
@@ -1758,11 +1747,9 @@ function DefaultSection({
|
|
|
1758
1747
|
return (
|
|
1759
1748
|
<section
|
|
1760
1749
|
data-testid={testId}
|
|
1761
|
-
hidden={hidden}
|
|
1762
1750
|
className={cn(
|
|
1763
1751
|
"flex flex-col gap-4 px-6 py-6",
|
|
1764
1752
|
variant === "destructive" && "border-l-2 border-destructive/40",
|
|
1765
|
-
hidden && "hidden",
|
|
1766
1753
|
)}
|
|
1767
1754
|
>
|
|
1768
1755
|
{header}
|
|
@@ -1789,12 +1776,10 @@ function DefaultSection({
|
|
|
1789
1776
|
return (
|
|
1790
1777
|
<div
|
|
1791
1778
|
data-testid={testId}
|
|
1792
|
-
hidden={hidden}
|
|
1793
1779
|
className={cn(
|
|
1794
1780
|
cardSurface(),
|
|
1795
1781
|
"overflow-hidden",
|
|
1796
1782
|
variant === "destructive" && "border-destructive/40",
|
|
1797
|
-
hidden && "hidden",
|
|
1798
1783
|
)}
|
|
1799
1784
|
>
|
|
1800
1785
|
<div className="flex flex-col gap-4 px-6 py-6">
|
|
@@ -1929,6 +1914,14 @@ function DefaultStepBar({
|
|
|
1929
1914
|
);
|
|
1930
1915
|
}
|
|
1931
1916
|
|
|
1917
|
+
function DefaultWizardStepGroup({ hidden, children }: WizardStepGroupProps): ReactNode {
|
|
1918
|
+
return (
|
|
1919
|
+
<fieldset disabled={hidden} hidden={hidden} className={hidden ? undefined : "contents"}>
|
|
1920
|
+
{children}
|
|
1921
|
+
</fieldset>
|
|
1922
|
+
);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1932
1925
|
function DefaultHeading({ variant = "page", children, testId }: HeadingProps): ReactNode {
|
|
1933
1926
|
// Page-Heading = h1, sehr selten in einer App (max 1 pro Screen).
|
|
1934
1927
|
// Section-Heading = h2 mit uppercase + muted-foreground — derselbe
|
|
@@ -2024,4 +2017,5 @@ export const defaultPrimitives: CorePrimitives = {
|
|
|
2024
2017
|
Link: DefaultLink,
|
|
2025
2018
|
Progress: DefaultProgress,
|
|
2026
2019
|
StepBar: DefaultStepBar,
|
|
2020
|
+
WizardStepGroup: DefaultWizardStepGroup,
|
|
2027
2021
|
};
|