@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.
Files changed (46) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/create-app.test.tsx +1 -1
  3. package/src/__tests__/entity-edit-redirect.test.tsx +111 -0
  4. package/src/__tests__/field-hide-label.test.tsx +166 -1
  5. package/src/__tests__/kumiko-screen.test.tsx +59 -0
  6. package/src/__tests__/nav-tree.test.tsx +43 -11
  7. package/src/__tests__/primitives.test.tsx +33 -2
  8. package/src/__tests__/render-edit.test.tsx +344 -9
  9. package/src/__tests__/test-utils.tsx +20 -0
  10. package/src/__tests__/wizard-form-validation.test.tsx +129 -1
  11. package/src/app/__tests__/merge-content-editors.test.ts +2 -2
  12. package/src/app/__tests__/plain-content-editor.test.tsx +4 -0
  13. package/src/app/__tests__/rich-content-editor.test.tsx +5 -2
  14. package/src/app/__tests__/tiptap-editor.test.tsx +50 -0
  15. package/src/app/client-plugin.tsx +3 -3
  16. package/src/app/create-app.tsx +40 -49
  17. package/src/app/tiptap-editor.tsx +10 -0
  18. package/src/layout/__tests__/workspace-switcher.test.tsx +40 -0
  19. package/src/layout/sidebar-panel.tsx +1 -4
  20. package/src/layout/workspace-switcher.tsx +20 -13
  21. package/src/lib/__tests__/resize-image.test.ts +152 -0
  22. package/src/lib/clamp.ts +3 -0
  23. package/src/lib/resize-image.ts +61 -0
  24. package/src/primitives/__tests__/data-table-logic.test.ts +15 -3
  25. package/src/primitives/__tests__/date-field.test.tsx +37 -0
  26. package/src/primitives/__tests__/date-parse.test.ts +14 -2
  27. package/src/primitives/__tests__/embedded-list-input.test.tsx +36 -2
  28. package/src/primitives/__tests__/file-upload.test.tsx +24 -0
  29. package/src/primitives/date-field.tsx +8 -0
  30. package/src/primitives/date-parse.ts +6 -1
  31. package/src/primitives/dropdown-menu.tsx +23 -0
  32. package/src/primitives/embedded-list-input.tsx +14 -5
  33. package/src/primitives/file-upload.tsx +6 -1
  34. package/src/primitives/index.tsx +114 -120
  35. package/src/primitives/money-input.tsx +4 -7
  36. package/src/ui/sheet.tsx +3 -1
  37. package/src/widgets/__tests__/drawer.test.tsx +293 -0
  38. package/src/widgets/__tests__/infinity-list.test.tsx +38 -0
  39. package/src/widgets/__tests__/upload-zone.test.tsx +125 -1
  40. package/src/widgets/__tests__/widgets.test.tsx +25 -0
  41. package/src/widgets/ai-text-field.tsx +3 -1
  42. package/src/widgets/drawer.tsx +54 -29
  43. package/src/widgets/infinity-list.tsx +18 -5
  44. package/src/widgets/progress-bar.tsx +3 -1
  45. package/src/widgets/step-bar.tsx +1 -1
  46. 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
- const dePlaceholderLetters = { year: "J", month: "M", day: "T" };
18
- const enPlaceholderLetters = { year: "Y", month: "M", day: "D" };
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
- window.innerWidth = 500;
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
- window.innerWidth = originalWidth;
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, { timeZone: "UTC" }).formatToParts(refEpochMillis);
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", 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
  />
@@ -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
- id={id}
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 && <span className="ml-0.5 text-destructive">*</span>}
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
- {tableInner(
669
- columns,
670
- rows,
671
- onRowClick,
672
- sort,
673
- onSortChange,
674
- rowActions,
675
- rowActionMode,
676
- onCellChange,
677
- getRowTestId,
678
- getCellTestId,
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-[max(1rem,env(safe-area-inset-bottom))]",
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
  };