@cosmicdrift/kumiko-renderer-web 0.248.0 → 0.250.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.248.0",
3
+ "version": "0.250.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.248.0",
20
- "@cosmicdrift/kumiko-headless": "0.248.0",
21
- "@cosmicdrift/kumiko-renderer": "0.248.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.250.0",
20
+ "@cosmicdrift/kumiko-headless": "0.250.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.250.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",
@@ -64,7 +64,7 @@
64
64
  "@types/react-dom": "^19.2.3",
65
65
  "jsdom": "^29.1.1",
66
66
  "tailwindcss": "^4.3.0",
67
- "@cosmicdrift/kumiko-locale-de": "0.248.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.250.0"
68
68
  },
69
69
  "repository": {
70
70
  "type": "git",
@@ -816,6 +816,69 @@ describe("KumikoScreen", () => {
816
816
  expect(screen.queryByText("raw fallback — must NOT appear")).toBeNull();
817
817
  });
818
818
 
819
+ // fw#2752: navigate rowActions may now carry style="danger" — it renders
820
+ // red like the writeHandler variant, but does NOT force a confirm dialog
821
+ // (the target form/screen is itself the confirmation).
822
+ test("entityList rowActions kind=navigate mit style=danger: rot, aber navigiert sofort ohne Dialog", async () => {
823
+ const navigateCalls: { screenId: string }[] = [];
824
+ const memoryNav = {
825
+ route: { screenId: "task-list" },
826
+ navigate: (target: NavTarget) => {
827
+ if ("screenId" in target) navigateCalls.push(target);
828
+ },
829
+ replace: () => undefined,
830
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
831
+ searchParams: {},
832
+ setSearchParams: () => undefined,
833
+ };
834
+ const dispatcher = makeDispatcher({
835
+ query: (async () => ({
836
+ isSuccess: true,
837
+ data: {
838
+ rows: [{ id: "r1", title: "Alpha", count: 1, done: false }],
839
+ nextCursor: null,
840
+ },
841
+ })) as unknown as Dispatcher["query"],
842
+ });
843
+
844
+ const screenWithDangerNav: EntityListScreenDefinition = {
845
+ id: "task-list",
846
+ type: "entityList",
847
+ entity: "task",
848
+ columns: ["title"],
849
+ rowActions: [
850
+ {
851
+ kind: "navigate",
852
+ id: "terminate",
853
+ label: "actions.terminate",
854
+ screen: "task-edit",
855
+ style: "danger",
856
+ },
857
+ ],
858
+ };
859
+
860
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
861
+ const user = userEvent.setup();
862
+ render(
863
+ <NavProvider value={memoryNav}>
864
+ <DispatcherProvider dispatcher={dispatcher}>
865
+ <KumikoScreen
866
+ schema={{ ...schema, screens: [screenWithDangerNav] }}
867
+ qn="tasks:screen:task-list"
868
+ />
869
+ </DispatcherProvider>
870
+ </NavProvider>,
871
+ );
872
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
873
+
874
+ const button = screen.getByTestId("row-r1-action-terminate");
875
+ expect(button.className).toContain("text-destructive");
876
+
877
+ await user.click(button);
878
+ await waitFor(() => expect(navigateCalls).toEqual([{ screenId: "task-edit" }]));
879
+ expect(screen.queryByTestId("row-r1-action-terminate-dialog")).toBeNull();
880
+ });
881
+
819
882
  // Tier 2.7e-1: rowAction kind="navigate" — Click ruft nav.navigate
820
883
  // mit screen-id, ggf. mit URL-Search-Params aus params(row).
821
884
  // Reihenfolge ist Teil des Contracts: navigate ZUERST, dann
@@ -1229,6 +1292,65 @@ describe("KumikoScreen", () => {
1229
1292
  expect(navigateCalls).toEqual([{ screenId: "task-edit" }]);
1230
1293
  });
1231
1294
 
1295
+ // fw#2752: same decoupling as the rowAction variant — style="danger" on a
1296
+ // navigate toolbarAction renders the destructive button variant without
1297
+ // forcing a confirm dialog.
1298
+ test("entityList toolbarActions navigate-kind mit style=danger: destructive Button, navigiert sofort ohne Dialog", async () => {
1299
+ const navigateCalls: { screenId: string }[] = [];
1300
+ const dispatcher = makeDispatcher({
1301
+ query: (async () => ({
1302
+ isSuccess: true,
1303
+ data: { rows: [{ id: "r1", title: "x", count: 0, done: false }], nextCursor: null },
1304
+ })) as unknown as Dispatcher["query"],
1305
+ });
1306
+ const memoryNav = {
1307
+ route: { screenId: "task-list" },
1308
+ navigate: (target: NavTarget) => {
1309
+ if ("screenId" in target) navigateCalls.push(target);
1310
+ },
1311
+ replace: () => undefined,
1312
+ hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
1313
+ searchParams: {},
1314
+ setSearchParams: () => undefined,
1315
+ };
1316
+ const screenWithToolbar: EntityListScreenDefinition = {
1317
+ id: "task-list",
1318
+ type: "entityList",
1319
+ entity: "task",
1320
+ columns: ["title"],
1321
+ toolbarActions: [
1322
+ {
1323
+ kind: "navigate",
1324
+ id: "terminate-all",
1325
+ label: "actions.terminate-all",
1326
+ screen: "task-edit",
1327
+ style: "danger",
1328
+ },
1329
+ ],
1330
+ };
1331
+
1332
+ const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
1333
+ const user = userEvent.setup();
1334
+ render(
1335
+ <NavProvider value={memoryNav}>
1336
+ <DispatcherProvider dispatcher={dispatcher}>
1337
+ <KumikoScreen
1338
+ schema={{ ...schema, screens: [screenWithToolbar] }}
1339
+ qn="tasks:screen:task-list"
1340
+ />
1341
+ </DispatcherProvider>
1342
+ </NavProvider>,
1343
+ );
1344
+ await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
1345
+
1346
+ const button = screen.getByTestId("render-list-toolbar-action-terminate-all");
1347
+ expect(button.getAttribute("data-variant")).toBe("destructive");
1348
+
1349
+ await user.click(button);
1350
+ await waitFor(() => expect(navigateCalls).toEqual([{ screenId: "task-edit" }]));
1351
+ expect(screen.queryByTestId("render-list-toolbar-action-terminate-all-dialog")).toBeNull();
1352
+ });
1353
+
1232
1354
  test("entityList toolbarActions writeHandler-kind: Click → dispatcher.write", async () => {
1233
1355
  const writeCalls: { type: string; payload: unknown }[] = [];
1234
1356
  const dispatcher = makeDispatcher({
@@ -1789,6 +1911,67 @@ describe("KumikoScreen", () => {
1789
1911
  expect(writeCalls[0]?.payload).toEqual({ title: "New Task", priority: 1 });
1790
1912
  });
1791
1913
 
1914
+ // fw#2752: actionForm submitStyle reaches the submit button.
1915
+ test("actionForm submitStyle='danger': submit button renders destructive variant", async () => {
1916
+ const dispatcher = makeDispatcher({
1917
+ write: (async () => ({
1918
+ isSuccess: true,
1919
+ data: { id: "new-id" },
1920
+ })) as unknown as Dispatcher["write"],
1921
+ });
1922
+
1923
+ const actionScreen: ActionFormScreenDefinition = {
1924
+ id: "quick-add",
1925
+ type: "actionForm",
1926
+ handler: "tasks:write:task:quick-add",
1927
+ submitStyle: "danger",
1928
+ fields: {
1929
+ title: { type: "text", required: true },
1930
+ priority: { type: "number", default: 1 },
1931
+ },
1932
+ layout: { sections: [{ title: "Basics", fields: ["title", "priority"] }] },
1933
+ };
1934
+
1935
+ render(
1936
+ <DispatcherProvider dispatcher={dispatcher}>
1937
+ <KumikoScreen schema={{ ...schema, screens: [actionScreen] }} qn="tasks:screen:quick-add" />
1938
+ </DispatcherProvider>,
1939
+ );
1940
+
1941
+ expect(screen.getByTestId("render-edit-submit").getAttribute("data-variant")).toBe(
1942
+ "destructive",
1943
+ );
1944
+ });
1945
+
1946
+ // fw#2752: actionForm submitStyle reaches the submit button.
1947
+ test("actionForm ohne submitStyle: submit button renders default variant", async () => {
1948
+ const dispatcher = makeDispatcher({
1949
+ write: (async () => ({
1950
+ isSuccess: true,
1951
+ data: { id: "new-id" },
1952
+ })) as unknown as Dispatcher["write"],
1953
+ });
1954
+
1955
+ const actionScreen: ActionFormScreenDefinition = {
1956
+ id: "quick-add",
1957
+ type: "actionForm",
1958
+ handler: "tasks:write:task:quick-add",
1959
+ fields: {
1960
+ title: { type: "text", required: true },
1961
+ priority: { type: "number", default: 1 },
1962
+ },
1963
+ layout: { sections: [{ title: "Basics", fields: ["title", "priority"] }] },
1964
+ };
1965
+
1966
+ render(
1967
+ <DispatcherProvider dispatcher={dispatcher}>
1968
+ <KumikoScreen schema={{ ...schema, screens: [actionScreen] }} qn="tasks:screen:quick-add" />
1969
+ </DispatcherProvider>,
1970
+ );
1971
+
1972
+ expect(screen.getByTestId("render-edit-submit").getAttribute("data-variant")).toBe("default");
1973
+ });
1974
+
1792
1975
  test("actionForm mit redirect auf entityList: nach success → navigate ohne entityId (#2419)", async () => {
1793
1976
  const navigateCalls: NavTarget[] = [];
1794
1977
  const dispatcher = makeDispatcher({
@@ -102,6 +102,39 @@ describe("RenderEdit", () => {
102
102
  expect(screen.queryByTestId("field-notes")).toBeNull();
103
103
  });
104
104
 
105
+ // fw#2752: submitVariant carries an actionForm's submitStyle through to the
106
+ // submit button — "danger" for a destructive handler (terminate, revoke).
107
+ test("submitVariant='danger' renders the destructive submit button; default stays primary", () => {
108
+ const { rerender } = render(
109
+ <DispatcherProvider dispatcher={makeDispatcher()}>
110
+ <RenderEdit<TestValues>
111
+ screen={makeScreen()}
112
+ entity={orderEntity}
113
+ featureName="orders"
114
+ initial={{ title: "", count: 0, isUrgent: false }}
115
+ writeCommand="order:create"
116
+ submitVariant="danger"
117
+ />
118
+ </DispatcherProvider>,
119
+ );
120
+ expect(screen.getByTestId("render-edit-submit").getAttribute("data-variant")).toBe(
121
+ "destructive",
122
+ );
123
+
124
+ rerender(
125
+ <DispatcherProvider dispatcher={makeDispatcher()}>
126
+ <RenderEdit<TestValues>
127
+ screen={makeScreen()}
128
+ entity={orderEntity}
129
+ featureName="orders"
130
+ initial={{ title: "", count: 0, isUrgent: false }}
131
+ writeCommand="order:create"
132
+ />
133
+ </DispatcherProvider>,
134
+ );
135
+ expect(screen.getByTestId("render-edit-submit").getAttribute("data-variant")).toBe("default");
136
+ });
137
+
105
138
  // A hidden field must not leave an empty grid cell behind — the cell count
106
139
  // has to track the visible field count exactly, in both directions.
107
140
  test("a hidden field claims no grid cell; toggling visibility adds/removes exactly one cell", () => {
@@ -10,8 +10,8 @@ import { defaultPrimitives } from "../index";
10
10
 
11
11
  const { Field, Input } = defaultPrimitives;
12
12
 
13
- // Six options, every label past the 14-char threshold — the heuristic would
14
- // render a dropdown for these.
13
+ // Six options, past the heuristic's four-option threshold — it would render a
14
+ // dropdown for these.
15
15
  const HEURISTIC_REJECTS = [
16
16
  "Background Jobs Queue",
17
17
  "Inbound Mail Processing",
@@ -21,7 +21,7 @@ const HEURISTIC_REJECTS = [
21
21
  "Search Index Rebuild",
22
22
  ];
23
23
 
24
- // Three short labels — the heuristic would render the radio group for these.
24
+ // Three options — the heuristic would render the radio group for these.
25
25
  const HEURISTIC_ACCEPTS = ["Draft", "Review", "Done"];
26
26
 
27
27
  function renderSelect(
@@ -1,7 +1,8 @@
1
1
  // Segment control for `kind: "select"` with a small closed option set
2
2
  // (edit-existing screenshot feedback): a Status field with 3 short values
3
- // no longer stretches into a full-width dropdown. Threshold: ≤4 options,
4
- // each label ≤14 chars — otherwise the unchanged ComboboxInput dropdown.
3
+ // no longer stretches into a full-width dropdown. Threshold: ≤4 options
4
+ // otherwise the unchanged ComboboxInput dropdown. Label length is explicitly
5
+ // not part of the threshold (#2606).
5
6
 
6
7
  import { describe, expect, test } from "bun:test";
7
8
  import { fireEvent } from "@testing-library/react";
@@ -52,10 +53,10 @@ describe("DefaultInput select → segmented control (edit-existing feedback)", (
52
53
  expect(screen.getByTestId("combobox-status")).toBeTruthy();
53
54
  });
54
55
 
55
- test("3 options with one long label (>14 chars) keep the dropdown", () => {
56
+ test("a long label no longer pushes the field into the dropdown", () => {
56
57
  renderSelect(["Draft", "Review", "Published Status"]);
57
- expect(screen.queryByRole("radiogroup")).toBeNull();
58
- expect(screen.getByTestId("combobox-status")).toBeTruthy();
58
+ expect(screen.queryByTestId("combobox-status")).toBeNull();
59
+ expect(screen.getAllByRole("radio")).toHaveLength(3);
59
60
  });
60
61
 
61
62
  test("clicking a segment reports the same value the dropdown's onChange would give", () => {
@@ -82,3 +83,54 @@ describe("DefaultInput select → segmented control (edit-existing feedback)", (
82
83
  expect(changes).toEqual([]);
83
84
  });
84
85
  });
86
+
87
+ // offlot-app's language picker from #2606: same four option values, the German
88
+ // translations land at 14 chars and the English ones above it.
89
+ const LANGUAGE_OPTIONS_EN = [
90
+ { value: "", label: "Select a language" },
91
+ { value: "de", label: "German" },
92
+ { value: "en", label: "English" },
93
+ { value: "es", label: "Spanish" },
94
+ ];
95
+
96
+ const LANGUAGE_OPTIONS_DE = [
97
+ { value: "", label: "Sprache wählen" },
98
+ { value: "de", label: "Deutsch" },
99
+ { value: "en", label: "Englisch" },
100
+ { value: "es", label: "Spanisch" },
101
+ ];
102
+
103
+ function renderedSelectWidget(
104
+ options: readonly { readonly value: string; readonly label: string }[],
105
+ ): "radiogroup" | "combobox" | "neither" {
106
+ const { container, unmount } = render(
107
+ <Field id="language" label="Language" testId="field-language">
108
+ <Input
109
+ kind="select"
110
+ id="language"
111
+ name="language"
112
+ value=""
113
+ onChange={() => {}}
114
+ options={options}
115
+ />
116
+ </Field>,
117
+ );
118
+ const hasRadioGroup = container.querySelector('[role="radiogroup"]') !== null;
119
+ const hasCombobox = container.querySelector('[data-testid="combobox-language"]') !== null;
120
+ unmount();
121
+ if (hasRadioGroup) return "radiogroup";
122
+ return hasCombobox ? "combobox" : "neither";
123
+ }
124
+
125
+ describe("DefaultInput select → widget choice is independent of the UI language", () => {
126
+ test("translated labels of different lengths pick the same widget", () => {
127
+ expect(renderedSelectWidget(LANGUAGE_OPTIONS_EN)).toBe(
128
+ renderedSelectWidget(LANGUAGE_OPTIONS_DE),
129
+ );
130
+ });
131
+
132
+ test("and that widget is the segmented control both times", () => {
133
+ expect(renderedSelectWidget(LANGUAGE_OPTIONS_EN)).toBe("radiogroup");
134
+ expect(renderedSelectWidget(LANGUAGE_OPTIONS_DE)).toBe("radiogroup");
135
+ });
136
+ });
@@ -426,16 +426,14 @@ function withUnitSuffix(unit: string | undefined, input: ReactNode): ReactNode {
426
426
  // Default presentation for `kind: "select"` with a small closed option set —
427
427
  // a 4-value Status field looked wrong stretched into a full-width dropdown
428
428
  // (edit-existing screenshot feedback). Only consulted when the caller states
429
- // no `display` of its own; an explicit `display` wins (#2711).
429
+ // no `display` of its own; an explicit `display` wins (#2711). The option
430
+ // count is the only criterion: labels arrive already translated, so the former
431
+ // label-length threshold made the widget type depend on the active UI language
432
+ // (#2606). Labels that no longer fit wrap inside the group.
430
433
  const SEGMENTED_SELECT_MAX_OPTIONS = 4;
431
- const SEGMENTED_SELECT_MAX_LABEL_LENGTH = 14;
432
434
 
433
- function isSegmentedSelectEligible(options: readonly { readonly label: string }[]): boolean {
434
- return (
435
- options.length > 0 &&
436
- options.length <= SEGMENTED_SELECT_MAX_OPTIONS &&
437
- options.every((o) => o.label.length <= SEGMENTED_SELECT_MAX_LABEL_LENGTH)
438
- );
435
+ function isSegmentedSelectEligible(options: readonly unknown[]): boolean {
436
+ return options.length > 0 && options.length <= SEGMENTED_SELECT_MAX_OPTIONS;
439
437
  }
440
438
 
441
439
  // WAI-ARIA radiogroup pattern (role="radiogroup" + role="radio" children):
@@ -701,7 +699,7 @@ function DefaultInput(props: InputProps): ReactNode {
701
699
  );
702
700
  // An explicit `display` is an author decision and outranks the
703
701
  // heuristic in both directions — a requested radio group renders as
704
- // one even when the labels are long or numerous (#2711).
702
+ // one even when the options outnumber the heuristic's threshold (#2711).
705
703
  const wantsRadioGroup =
706
704
  props.display === "radio" ||
707
705
  (props.display === undefined && isSegmentedSelectEligible(comboOptions));
@@ -1327,10 +1325,13 @@ function RowActionsCell({
1327
1325
  // Components hatten denselben State-Block dupliziert + parallel zur
1328
1326
  // Confirm-Dialog-Render-Logic — der Hook konsolidiert das.
1329
1327
  //
1330
- // "needsConfirm" Helper kapselt die Regel: explizites confirm ODER
1331
- // style=danger triggert den Dialog, alles andere fired direkt.
1328
+ // The rule: an explicit confirm OR style=danger opens the dialog,
1329
+ // everything else fires straight through.
1330
+ // `confirmRequired` overrides the danger-implies-confirm default (e.g.
1331
+ // schema-driven navigate/drawer actions where the target form is itself
1332
+ // the confirmation).
1332
1333
  function needsConfirm(action: DataTableRowAction): boolean {
1333
- return action.confirm !== undefined || action.style === "danger";
1334
+ return action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
1334
1335
  }
1335
1336
 
1336
1337
  function useRowActionTrigger(row: ListRowViewModel) {