@cosmicdrift/kumiko-renderer-web 0.186.2 → 0.187.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.186.2",
3
+ "version": "0.187.0",
4
4
  "description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -16,9 +16,9 @@
16
16
  "./styles.css": "./src/styles.css"
17
17
  },
18
18
  "dependencies": {
19
- "@cosmicdrift/kumiko-dispatcher-live": "0.186.2",
20
- "@cosmicdrift/kumiko-headless": "0.186.2",
21
- "@cosmicdrift/kumiko-renderer": "0.186.2",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.187.0",
20
+ "@cosmicdrift/kumiko-headless": "0.187.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.187.0",
22
22
  "@radix-ui/react-dialog": "^1.1.15",
23
23
  "@radix-ui/react-dropdown-menu": "^2.1.16",
24
24
  "@radix-ui/react-label": "^2.1.8",
@@ -8,7 +8,9 @@
8
8
  // abhängen werden.
9
9
 
10
10
  import { describe, expect, mock, test } from "bun:test";
11
+ import { type ColumnRendererProps, ColumnRenderersProvider } from "@cosmicdrift/kumiko-renderer";
11
12
  import userEvent from "@testing-library/user-event";
13
+ import type { ReactNode } from "react";
12
14
  import { defaultPrimitives, END_LABEL_MIN_ROWS } from "../primitives";
13
15
  import { PageSection, Stack } from "../primitives/layout";
14
16
  import { fireEvent, render, screen } from "./test-utils";
@@ -876,6 +878,125 @@ describe("DataTable", () => {
876
878
  expect(onRowClick).not.toHaveBeenCalled();
877
879
  });
878
880
  });
881
+
882
+ describe("highlighted column", () => {
883
+ const cols = [
884
+ { field: "name", label: "Name", type: "string", sortable: false },
885
+ { field: "y2026", label: "2026", type: "number", sortable: false, highlighted: true },
886
+ ] as const;
887
+ const oneRow = [{ id: "r1", values: { name: "Alice", y2026: 42 } }];
888
+
889
+ test("marks header + cell of the highlighted column, leaves others alone", () => {
890
+ render(<DataTable columns={cols} rows={oneRow} />);
891
+ expect(screen.getByTestId("column-y2026").getAttribute("data-highlighted")).toBe("true");
892
+ expect(screen.getByTestId("column-name").getAttribute("data-highlighted")).toBeNull();
893
+ expect(screen.getByTestId("cell-r1-y2026").getAttribute("data-highlighted")).toBe("true");
894
+ expect(screen.getByTestId("cell-r1-name").getAttribute("data-highlighted")).toBeNull();
895
+ });
896
+ });
897
+
898
+ describe("testId overrides", () => {
899
+ const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
900
+ const oneRow = [{ id: "r1", values: { name: "Alice" } }];
901
+
902
+ test("getRowTestId/getCellTestId override the row-<id>/cell-<id>-<field> defaults", () => {
903
+ render(
904
+ <DataTable
905
+ columns={cols}
906
+ rows={oneRow}
907
+ getRowTestId={(row) => `own-row-${row.id}`}
908
+ getCellTestId={(row, field) => `own-cell-${row.id}-${field}`}
909
+ />,
910
+ );
911
+ expect(screen.getByTestId("own-row-r1")).not.toBeNull();
912
+ expect(screen.getByTestId("own-cell-r1-name")).not.toBeNull();
913
+ expect(screen.queryByTestId("row-r1")).toBeNull();
914
+ });
915
+
916
+ test("without overrides, defaults stay row-<id>/cell-<id>-<field>", () => {
917
+ render(<DataTable columns={cols} rows={oneRow} />);
918
+ expect(screen.getByTestId("row-r1")).not.toBeNull();
919
+ expect(screen.getByTestId("cell-r1-name")).not.toBeNull();
920
+ });
921
+ });
922
+
923
+ describe("onCellChange (editable cell)", () => {
924
+ function EditableSwatch({ value, column, onChange }: ColumnRendererProps): ReactNode {
925
+ return (
926
+ <input
927
+ data-testid={`edit-${column.field}`}
928
+ value={String(value)}
929
+ onChange={(e) => onChange?.(e.target.value)}
930
+ />
931
+ );
932
+ }
933
+
934
+ test("column-renderer receives onChange bound to (rowId, field, value) when onCellChange is set", () => {
935
+ const onCellChange = mock();
936
+ const cols = [
937
+ {
938
+ field: "name",
939
+ label: "Name",
940
+ type: "string",
941
+ sortable: false,
942
+ renderer: { react: { __component: "EditableSwatch" } },
943
+ },
944
+ ] as const;
945
+ render(
946
+ <ColumnRenderersProvider value={{ EditableSwatch }}>
947
+ <DataTable
948
+ columns={cols}
949
+ rows={[{ id: "r1", values: { name: "Alice" } }]}
950
+ onCellChange={onCellChange}
951
+ />
952
+ </ColumnRenderersProvider>,
953
+ );
954
+ fireEvent.change(screen.getByTestId("edit-name"), { target: { value: "Bob" } });
955
+ expect(onCellChange).toHaveBeenCalledWith("r1", "name", "Bob");
956
+ });
957
+
958
+ test("without onCellChange, the component renderer gets no onChange (read-only)", () => {
959
+ const cols = [
960
+ {
961
+ field: "name",
962
+ label: "Name",
963
+ type: "string",
964
+ sortable: false,
965
+ renderer: { react: { __component: "EditableSwatch" } },
966
+ },
967
+ ] as const;
968
+ render(
969
+ <ColumnRenderersProvider value={{ EditableSwatch }}>
970
+ <DataTable columns={cols} rows={[{ id: "r1", values: { name: "Alice" } }]} />
971
+ </ColumnRenderersProvider>,
972
+ );
973
+ // onChange is undefined → EditableSwatch's onChange?.(...) is a no-op;
974
+ // rendering itself must still succeed (no crash) with a stable value.
975
+ expect((screen.getByTestId("edit-name") as HTMLInputElement).value).toBe("Alice");
976
+ });
977
+
978
+ test("format-spec renderer ignores onCellChange — no crash, plain formatted text", () => {
979
+ const onCellChange = mock();
980
+ const cols = [
981
+ {
982
+ field: "amount",
983
+ label: "Amount",
984
+ type: "number",
985
+ sortable: false,
986
+ renderer: { format: "currency", symbol: "€" },
987
+ },
988
+ ] as const;
989
+ render(
990
+ <DataTable
991
+ columns={cols}
992
+ rows={[{ id: "r1", values: { amount: 42 } }]}
993
+ onCellChange={onCellChange}
994
+ />,
995
+ );
996
+ expect(screen.getByTestId("cell-r1-amount").textContent).toBe("42 €");
997
+ expect(onCellChange).not.toHaveBeenCalled();
998
+ });
999
+ });
879
1000
  });
880
1001
 
881
1002
  describe("Form", () => {
@@ -6,7 +6,16 @@
6
6
 
7
7
  import { describe, expect, test } from "bun:test";
8
8
  import { Temporal } from "temporal-polyfill";
9
- import { formatDateForInput, parseIso, parseTypedDate, toIso } from "../date-parse";
9
+ import {
10
+ formatDateForInput,
11
+ formatDatePlaceholder,
12
+ parseIso,
13
+ parseTypedDate,
14
+ toIso,
15
+ } from "../date-parse";
16
+
17
+ const dePlaceholderLetters = { year: "J", month: "M", day: "T" };
18
+ const enPlaceholderLetters = { year: "Y", month: "M", day: "D" };
10
19
 
11
20
  describe("parseIso", () => {
12
21
  test("valid yyyy-mm-dd → PlainDate (no TZ conversion)", () => {
@@ -135,3 +144,19 @@ describe("formatDateForInput", () => {
135
144
  if (roundtrip !== undefined) expect(toIso(roundtrip)).toBe("2026-04-25");
136
145
  });
137
146
  });
147
+
148
+ // #1865: placeholder shows the locale's format pattern, not a hardcoded
149
+ // example date that reads as an already-filled-in value.
150
+ describe("formatDatePlaceholder", () => {
151
+ test("de-DE → day.month.year pattern with dot separator", () => {
152
+ expect(formatDatePlaceholder("de-DE", dePlaceholderLetters)).toBe("TT.MM.JJJJ");
153
+ });
154
+
155
+ test("en-US → month/day/year pattern with slash separator", () => {
156
+ expect(formatDatePlaceholder("en-US", enPlaceholderLetters)).toBe("MM/DD/YYYY");
157
+ });
158
+
159
+ test("en-GB → day/month/year pattern with slash separator", () => {
160
+ expect(formatDatePlaceholder("en-GB", enPlaceholderLetters)).toBe("DD/MM/YYYY");
161
+ });
162
+ });
@@ -605,3 +605,19 @@ describe("EmbeddedListInput — currency (#1839)", () => {
605
605
  expect(totals.textContent).toContain("€");
606
606
  });
607
607
  });
608
+
609
+ describe("EmbeddedListInput — desktop table width (solon#107)", () => {
610
+ test("the table keeps min-w-max so columns don't shrink below their width classes", () => {
611
+ const rows = [{ description: "A", quantity: 1, amount: 100 }];
612
+ renderWithLocale(<EmbeddedListInput {...baseProps({ rows })} />);
613
+ const desktop = screen.getByTestId("lines-desktop");
614
+ const table = desktop.querySelector("table");
615
+ if (table === null) throw new Error("expected a <table> in the desktop layout");
616
+ expect(table.className).toContain("min-w-max");
617
+
618
+ const headers = desktop.querySelectorAll("th");
619
+ expect(headers[0]?.className).toContain("min-w-[10rem]");
620
+ expect(headers[1]?.className).toContain("w-36");
621
+ expect(headers[2]?.className).toContain("w-36");
622
+ });
623
+ });
@@ -12,7 +12,14 @@ import { type ReactNode, useState } from "react";
12
12
  import { Temporal } from "temporal-polyfill";
13
13
  import { cn } from "../lib/cn";
14
14
  import { CalendarPopover } from "./calendar-popover";
15
- import { formatDateForInput, guessLocale, parseIso, parseTypedDate, toIso } from "./date-parse";
15
+ import {
16
+ formatDateForInput,
17
+ formatDatePlaceholder,
18
+ guessLocale,
19
+ parseIso,
20
+ parseTypedDate,
21
+ toIso,
22
+ } from "./date-parse";
16
23
 
17
24
  // CalendarPopover wraps react-day-picker, which only accepts native Date
18
25
  // objects — the PlainDate↔Date boundary conversion stays confined to
@@ -98,10 +105,11 @@ export function DateField({
98
105
  disabled={disabled}
99
106
  required={required}
100
107
  aria-invalid={hasError === true ? true : undefined}
101
- placeholder={formatDateForInput(
102
- Temporal.PlainDate.from({ year: 2026, month: 12, day: 31 }),
103
- resolvedLocale,
104
- )}
108
+ placeholder={formatDatePlaceholder(resolvedLocale, {
109
+ year: t("kumiko.field.dateField.placeholderYear"),
110
+ month: t("kumiko.field.dateField.placeholderMonth"),
111
+ day: t("kumiko.field.dateField.placeholderDay"),
112
+ })}
105
113
  onChange={(e) => {
106
114
  setDraft(e.target.value);
107
115
  commitTyped(e.target.value);
@@ -71,19 +71,23 @@ export function formatDateForInput(d: Temporal.PlainDate, locale: string): strin
71
71
 
72
72
  type DateSlot = "y" | "m" | "d";
73
73
 
74
- // Field order of the numeric locale format. de → [d,m,y], en-US →
75
- // [m,d,y], ISO-like locales [y,m,d]. formatToParts runs over an epoch-
76
- // millis number instead of a Date object (guard-compliant) timeZone:
74
+ // Shared by localeDateOrder and formatDatePlaceholder both need the
75
+ // locale's numeric formatToParts breakdown of the same reference date.
76
+ // epoch-millis input instead of a Date object (guard-compliant); timeZone:
77
77
  // "UTC" keeps the reference from shifting to the 1st depending on the
78
78
  // browser's TZ.
79
- function localeDateOrder(locale: string): readonly DateSlot[] {
79
+ 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);
84
+ }
85
+
86
+ // Field order of the numeric locale format. de → [d,m,y], en-US →
87
+ // [m,d,y], ISO-like locales → [y,m,d].
88
+ function localeDateOrder(locale: string): readonly DateSlot[] {
83
89
  const order: DateSlot[] = [];
84
- for (const part of new Intl.DateTimeFormat(locale, { timeZone: "UTC" }).formatToParts(
85
- refEpochMillis,
86
- )) {
90
+ for (const part of localeDateParts(locale)) {
87
91
  if (part.type === "year") order.push("y");
88
92
  else if (part.type === "month") order.push("m");
89
93
  else if (part.type === "day") order.push("d");
@@ -91,6 +95,25 @@ function localeDateOrder(locale: string): readonly DateSlot[] {
91
95
  return order;
92
96
  }
93
97
 
98
+ // Locale-shaped placeholder pattern, e.g. de "TT.MM.JJJJ", en-US
99
+ // "MM/DD/YYYY", en-GB "DD/MM/YYYY". Slot order and separator both come
100
+ // from formatToParts — nothing hardcoded per locale. `letters` is one
101
+ // character per slot (from i18n); repeated to the slot's digit count
102
+ // (day/month 2, year 4).
103
+ export function formatDatePlaceholder(
104
+ locale: string,
105
+ letters: { readonly year: string; readonly month: string; readonly day: string },
106
+ ): string {
107
+ return localeDateParts(locale)
108
+ .map((part) => {
109
+ if (part.type === "year") return letters.year.repeat(4);
110
+ if (part.type === "month") return letters.month.repeat(2);
111
+ if (part.type === "day") return letters.day.repeat(2);
112
+ return part.value;
113
+ })
114
+ .join("");
115
+ }
116
+
94
117
  // Typed input → PlainDate. Accepts ISO (yyyy-mm-dd) directly, plus three
95
118
  // numeric tokens in locale order with any separator (".", "/", "-", " ").
96
119
  // Two-digit years → 2000s. Partial/invalid input → undefined (caller
@@ -473,7 +473,8 @@ export function EmbeddedListInput({
473
473
  {!isMobile && (
474
474
  <div data-testid={testIdFor("desktop")} className="hidden md:block">
475
475
  <div className="overflow-hidden rounded-lg border bg-card">
476
- <Table>
476
+ {/* w-full on Table's <table> would shrink columns below columnWidthClass; min-w-max keeps declared widths and lets the wrapper scroll instead */}
477
+ <Table className="min-w-max">
477
478
  <TableHeader className="bg-muted">
478
479
  <TableRow className="hover:bg-transparent">
479
480
  {columns.map((column) => (
@@ -623,6 +623,9 @@ function DefaultDataTable({
623
623
  onFilterChange,
624
624
  onFilterReset,
625
625
  testId,
626
+ onCellChange,
627
+ getRowTestId,
628
+ getCellTestId,
626
629
  }: DataTableProps): ReactNode {
627
630
  // Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
628
631
  // beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
@@ -643,7 +646,18 @@ function DefaultDataTable({
643
646
  // Page-Background (z.B. Cream) matchen Listen sonst nicht die Cards.
644
647
  <div className="overflow-hidden rounded-lg border bg-card">
645
648
  <Table data-testid={testId}>
646
- {tableInner(columns, rows, onRowClick, sort, onSortChange, rowActions, rowActionMode)}
649
+ {tableInner(
650
+ columns,
651
+ rows,
652
+ onRowClick,
653
+ sort,
654
+ onSortChange,
655
+ rowActions,
656
+ rowActionMode,
657
+ onCellChange,
658
+ getRowTestId,
659
+ getCellTestId,
660
+ )}
647
661
  </Table>
648
662
  </div>
649
663
  );
@@ -742,6 +756,9 @@ function tableInner(
742
756
  onSortChange?: DataTableProps["onSortChange"],
743
757
  rowActions?: DataTableProps["rowActions"],
744
758
  rowActionMode?: DataTableProps["rowActionMode"],
759
+ onCellChange?: DataTableProps["onCellChange"],
760
+ getRowTestId?: DataTableProps["getRowTestId"],
761
+ getCellTestId?: DataTableProps["getCellTestId"],
745
762
  ): ReactNode {
746
763
  const hasActions = rowActions !== undefined && rowActions.length > 0;
747
764
  return (
@@ -754,6 +771,7 @@ function tableInner(
754
771
  field={col.field}
755
772
  label={col.label}
756
773
  sortable={col.sortable === true}
774
+ highlighted={col.highlighted === true}
757
775
  {...(sort !== undefined && sort !== null && { sort })}
758
776
  {...(onSortChange !== undefined && { onSortChange })}
759
777
  />
@@ -775,20 +793,21 @@ function tableInner(
775
793
  {rows.map((row) => (
776
794
  <TableRow
777
795
  key={row.id}
778
- data-testid={`row-${row.id}`}
796
+ data-testid={getRowTestId?.(row) ?? `row-${row.id}`}
779
797
  onClick={onRowClick !== undefined ? () => onRowClick(row) : undefined}
780
798
  className={cn(onRowClick !== undefined && "cursor-pointer")}
781
799
  >
782
800
  {columns.map((col) => (
783
801
  <TableCell
784
802
  key={col.field}
785
- data-testid={`cell-${row.id}-${col.field}`}
803
+ data-testid={getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`}
804
+ data-highlighted={col.highlighted === true ? "true" : undefined}
786
805
  // Cells truncaten lange Werte mit ellipsis statt umzu-
787
806
  // brechen — Lists bleiben einzeilig + scannbar (Linear-
788
807
  // Pattern). max-w-xs gibt eine vernünftige Default-
789
808
  // Obergrenze; der Table-Container scrollt horizontal
790
809
  // falls die Summe der Spalten zu breit wird.
791
- className="max-w-xs truncate"
810
+ className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
792
811
  title={cellTitle(row.values[col.field])}
793
812
  >
794
813
  <DataTableCell
@@ -798,6 +817,9 @@ function tableInner(
798
817
  type={col.type}
799
818
  renderer={col.renderer}
800
819
  {...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
820
+ {...(onCellChange !== undefined && {
821
+ onChange: (value: unknown) => onCellChange(row.id, col.field, value),
822
+ })}
801
823
  />
802
824
  </TableCell>
803
825
  ))}
@@ -1280,12 +1302,14 @@ function SortableHeader({
1280
1302
  field,
1281
1303
  label,
1282
1304
  sortable,
1305
+ highlighted,
1283
1306
  sort,
1284
1307
  onSortChange,
1285
1308
  }: {
1286
1309
  readonly field: string;
1287
1310
  readonly label: string;
1288
1311
  readonly sortable: boolean;
1312
+ readonly highlighted?: boolean;
1289
1313
  readonly sort?: DataTableSort;
1290
1314
  readonly onSortChange?: (next: DataTableSort | null) => void;
1291
1315
  }): ReactNode {
@@ -1298,7 +1322,8 @@ function SortableHeader({
1298
1322
  <TableHead
1299
1323
  data-testid={`column-${field}`}
1300
1324
  data-sortable={sortable === true ? true : undefined}
1301
- className="px-4 text-muted-foreground"
1325
+ data-highlighted={highlighted === true ? "true" : undefined}
1326
+ className={cn("px-4 text-muted-foreground", highlighted === true && "bg-accent/40")}
1302
1327
  >
1303
1328
  {label}
1304
1329
  </TableHead>
@@ -1312,8 +1337,9 @@ function SortableHeader({
1312
1337
  <TableHead
1313
1338
  data-testid={`column-${field}`}
1314
1339
  data-sortable="true"
1340
+ data-highlighted={highlighted === true ? "true" : undefined}
1315
1341
  aria-sort={ariaSort}
1316
- className="px-4 text-muted-foreground"
1342
+ className={cn("px-4 text-muted-foreground", highlighted === true && "bg-accent/40")}
1317
1343
  >
1318
1344
  <button
1319
1345
  type="button"
@@ -1443,6 +1469,7 @@ type DataTableCellProps = {
1443
1469
  readonly type: string;
1444
1470
  readonly renderer?: unknown;
1445
1471
  readonly optionLabels?: Readonly<Record<string, string>>;
1472
+ readonly onChange?: (value: unknown) => void;
1446
1473
  };
1447
1474
 
1448
1475
  // Cell-Renderer als Component (statt reiner Funktion) damit der
@@ -1461,6 +1488,7 @@ function DataTableCell({
1461
1488
  type,
1462
1489
  renderer,
1463
1490
  optionLabels,
1491
+ onChange,
1464
1492
  }: DataTableCellProps): ReactNode {
1465
1493
  const componentRef = isComponentRendererRef(renderer);
1466
1494
  const ResolvedComponent = useColumnRenderer(componentRef?.name);
@@ -1473,7 +1501,14 @@ function DataTableCell({
1473
1501
  }
1474
1502
  if (componentRef !== undefined) {
1475
1503
  if (ResolvedComponent !== undefined) {
1476
- return <ResolvedComponent value={value} row={row} column={{ field }} />;
1504
+ return (
1505
+ <ResolvedComponent
1506
+ value={value}
1507
+ row={row}
1508
+ column={{ field }}
1509
+ {...(onChange !== undefined && { onChange })}
1510
+ />
1511
+ );
1477
1512
  }
1478
1513
  // Renderer im Schema referenziert, aber client-side kein Map-Eintrag —
1479
1514
  // typischer Fall: clientFeatures.columnRenderers vergessen oder