@cosmicdrift/kumiko-renderer-web 0.249.0 → 0.251.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.249.0",
3
+ "version": "0.251.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.249.0",
20
- "@cosmicdrift/kumiko-headless": "0.249.0",
21
- "@cosmicdrift/kumiko-renderer": "0.249.0",
19
+ "@cosmicdrift/kumiko-dispatcher-live": "0.251.0",
20
+ "@cosmicdrift/kumiko-headless": "0.251.0",
21
+ "@cosmicdrift/kumiko-renderer": "0.251.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.249.0"
67
+ "@cosmicdrift/kumiko-locale-de": "0.251.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({
@@ -1516,10 +1516,15 @@ describe("Form", () => {
1516
1516
  expect(contentContainer.className).not.toContain("max-sm:pb-32");
1517
1517
  });
1518
1518
 
1519
- // fillHeight (fw#2722): the flex-fill chain RenderEdit opts a lone
1520
- // relatedList tab into, so its table can scroll inside the tab panel
1521
- // instead of the whole page stretching to the row count.
1522
- test("fillHeight: form root and its content container size to h-full/flex-1 min-h-0", () => {
1519
+ // fillHeight (fw#2722, height fw#2778): the flex chain RenderEdit opts a
1520
+ // lone relatedList tab into, so its table can scroll inside the tab panel
1521
+ // instead of the whole page stretching to the row count. The chain caps
1522
+ // at h-full but does NOT flex-1/grow past its content — a short table
1523
+ // must not stretch the card to the bottom of the panel (fw#2778); only
1524
+ // the innermost table wrapper (primitives.test.tsx's DataTable coverage)
1525
+ // keeps flex-1 to actually claim the leftover height once the ancestor
1526
+ // chain is force-shrunk.
1527
+ test("fillHeight: form root caps at h-full/min-h-0, its card sizes to content instead of growing (fw#2778)", () => {
1523
1528
  render(
1524
1529
  <Form onSubmit={() => undefined} testId="form" fillHeight>
1525
1530
  <div>content</div>
@@ -1528,10 +1533,11 @@ describe("Form", () => {
1528
1533
  const form = screen.getByTestId("form");
1529
1534
  expect(form.className).toContain("h-full");
1530
1535
  expect(form.className).toContain("min-h-0");
1531
- // form > FormScreenShell > card(overflow-hidden) — the card is the
1532
- // flex-1 min-h-0 child that claims the remaining height below headerRegion.
1536
+ // form > FormScreenShell > card(overflow-hidden) — the card sizes to
1537
+ // its content (no flex-1) and only shrinks (min-h-0) once the chain
1538
+ // above it is itself height-constrained.
1533
1539
  const card = form.firstElementChild?.firstElementChild as HTMLElement;
1534
- expect(card.className).toContain("flex-1");
1540
+ expect(card.className).not.toContain("flex-1");
1535
1541
  expect(card.className).toContain("min-h-0");
1536
1542
  });
1537
1543
 
@@ -1832,6 +1838,16 @@ describe("Stack", () => {
1832
1838
  });
1833
1839
  });
1834
1840
 
1841
+ // Padding utilities of one element, sorted — every screen container must
1842
+ // resolve to the same set (fw#2640).
1843
+ const paddingClasses = (el: Element | null | undefined): string[] =>
1844
+ (el?.className ?? "")
1845
+ .split(" ")
1846
+ .filter((c) => /^p[xytblr]?-/.test(c))
1847
+ .sort();
1848
+
1849
+ const paddingOf = (testId: string): string[] => paddingClasses(screen.getByTestId(testId));
1850
+
1835
1851
  describe("PageSection", () => {
1836
1852
  test("wrappt children mit einheitlichem Padding", () => {
1837
1853
  render(
@@ -1850,12 +1866,6 @@ describe("PageSection", () => {
1850
1866
  <FormScreenShell testId="shell-pad">x</FormScreenShell>
1851
1867
  </>,
1852
1868
  );
1853
- const paddingOf = (testId: string): string[] =>
1854
- screen
1855
- .getByTestId(testId)
1856
- .className.split(" ")
1857
- .filter((c) => /^p[xytblr]?-/.test(c))
1858
- .sort();
1859
1869
  expect(paddingOf("page-pad")).toEqual(["pb-12", "pt-6", "px-6"]);
1860
1870
  expect(paddingOf("page-pad")).toEqual(paddingOf("shell-pad"));
1861
1871
  });
@@ -1881,6 +1891,35 @@ describe("PageSection", () => {
1881
1891
  });
1882
1892
  });
1883
1893
 
1894
+ // A list screen has no PageSection/FormScreenShell around it — its screen
1895
+ // chrome is DataTable's own outer wrapper, which is why that wrapper had its
1896
+ // own inset and list screens ended 24px above the viewport edge instead of
1897
+ // 48px (fw#2640).
1898
+ describe("DataTable screenPadding", () => {
1899
+ const cols = [{ field: "name", label: "Name", type: "string", sortable: false }] as const;
1900
+ const oneRow = [{ id: "r1", values: { name: "Alice" } }];
1901
+ // table → [data-slot="table-container"] → card frame → padding wrapper.
1902
+ const paddingWrapperOf = (testId: string): Element | null | undefined =>
1903
+ screen.getByTestId(testId).parentElement?.parentElement?.parentElement;
1904
+
1905
+ test("screenPadding: the table's screen chrome matches PageSection and FormScreenShell", () => {
1906
+ render(
1907
+ <>
1908
+ <PageSection testId="page-pad">x</PageSection>
1909
+ <FormScreenShell testId="shell-pad">x</FormScreenShell>
1910
+ <DataTable columns={cols} rows={oneRow} testId="list-table" screenPadding />
1911
+ </>,
1912
+ );
1913
+ expect(paddingClasses(paddingWrapperOf("list-table"))).toEqual(paddingOf("page-pad"));
1914
+ expect(paddingClasses(paddingWrapperOf("list-table"))).toEqual(paddingOf("shell-pad"));
1915
+ });
1916
+
1917
+ test("default: an embedded table keeps its symmetric inset (relatedList unchanged)", () => {
1918
+ render(<DataTable columns={cols} rows={oneRow} testId="embedded-table" />);
1919
+ expect(paddingClasses(paddingWrapperOf("embedded-table"))).toEqual(["p-6"]);
1920
+ });
1921
+ });
1922
+
1884
1923
  describe("JsonView", () => {
1885
1924
  test("tokenisiert statt einen flachen String zu rendern — Keys/Strings/Zahlen/Booleans/null bekommen je eigene Klasse", () => {
1886
1925
  render(
@@ -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));
@@ -906,6 +904,7 @@ function DefaultDataTable({
906
904
  getCellTestId,
907
905
  chromeless,
908
906
  scrollBody,
907
+ screenPadding,
909
908
  }: DataTableProps): ReactNode {
910
909
  // One locale/translate subscription per table — not per cell (fw#2345).
911
910
  // Optional hooks: a bare DataTable outside LocaleProvider must not crash.
@@ -939,13 +938,16 @@ function DefaultDataTable({
939
938
  // sitzt auf derselben Card-Fläche wie Forms; auf Themes mit farbigem
940
939
  // Page-Background (z.B. Cream) matchen Listen sonst nicht die Cards.
941
940
  // `chromeless` drops that frame for a host with its own boundary already (a tab panel, fw#2722).
942
- // `scrollBody` fills the remaining height of its flex-col ancestor
943
- // (see DefaultForm/FormScreenShell's `fillHeight` and this table's own
944
- // outer wrapper below) and scrolls rows internally, instead of
945
- // `overflow-hidden` (document-flow height, grows with row count) for
946
- // a host where a long list must not stretch the whole page (a tab
947
- // panel, fw#2722). `min-h-0` overrides flex's default min-height:auto,
948
- // which would otherwise let this grow past its flex-1 share to fit content.
941
+ // `scrollBody` is the terminal link in the DefaultForm/FormScreenShell
942
+ // `fillHeight` chain (fw#2722) every ancestor in that chain sizes to
943
+ // its own content (no flex-1) and only shrinks once the chain's
944
+ // `h-full` root runs out of room, so THIS is the one div that still
945
+ // claims the leftover height (`flex-1`) and scrolls rows internally
946
+ // instead of `overflow-hidden` (document-flow height, grows with row
947
+ // count) a short list keeps a content-sized card, a long one scrolls
948
+ // in place instead of stretching the whole page (fw#2722, fw#2778).
949
+ // `min-h-0` overrides flex's default min-height:auto, which would
950
+ // otherwise keep this at its content's height regardless of flex-shrink.
949
951
  <div
950
952
  className={cn(
951
953
  scrollBody === true ? "flex-1 min-h-0 overflow-y-auto" : "overflow-hidden",
@@ -1258,11 +1260,25 @@ function DefaultDataTable({
1258
1260
  // der Tabelle im selben Padding-Block — kein separater bg-Bar, kein Screen-
1259
1261
  // Titel (der steht im Breadcrumb der Shell).
1260
1262
  return (
1261
- <div className={cn("flex flex-col gap-4 p-6 w-full", scrollBody === true && "flex-1 min-h-0")}>
1263
+ // `screenPadding`: on a list screen this wrapper IS the screen container,
1264
+ // so it takes the same token as FormScreenShell/PageSection instead of its
1265
+ // own inset — a list ends at the same footer distance as a form (fw#2640).
1266
+ <div
1267
+ className={cn(
1268
+ "flex flex-col gap-4 w-full",
1269
+ screenPadding === true ? screenPaddingClassName : "p-6",
1270
+ // No flex-1: this wrapper sizes to its content (toolbar + table) and
1271
+ // only shrinks (min-h-0) once its ancestor chain is itself
1272
+ // height-constrained — the actual scroll surface is tableInner/
1273
+ // cardsInner below, which do keep flex-1 to claim whatever height
1274
+ // that shrink leaves them (fw#2778).
1275
+ scrollBody === true && "min-h-0",
1276
+ )}
1277
+ >
1262
1278
  {hasToolbar && (
1263
1279
  <div
1264
1280
  data-testid={testId !== undefined ? `${testId}-toolbar` : "render-list-toolbar"}
1265
- className="flex flex-wrap items-center gap-3"
1281
+ className={cn("flex flex-wrap items-center gap-3", scrollBody === true && "shrink-0")}
1266
1282
  >
1267
1283
  {toolbarStart !== undefined && <div className="flex-1 max-w-sm">{toolbarStart}</div>}
1268
1284
  {facetCluster}
@@ -1327,10 +1343,13 @@ function RowActionsCell({
1327
1343
  // Components hatten denselben State-Block dupliziert + parallel zur
1328
1344
  // Confirm-Dialog-Render-Logic — der Hook konsolidiert das.
1329
1345
  //
1330
- // "needsConfirm" Helper kapselt die Regel: explizites confirm ODER
1331
- // style=danger triggert den Dialog, alles andere fired direkt.
1346
+ // The rule: an explicit confirm OR style=danger opens the dialog,
1347
+ // everything else fires straight through.
1348
+ // `confirmRequired` overrides the danger-implies-confirm default (e.g.
1349
+ // schema-driven navigate/drawer actions where the target form is itself
1350
+ // the confirmation).
1332
1351
  function needsConfirm(action: DataTableRowAction): boolean {
1333
- return action.confirm !== undefined || action.style === "danger";
1352
+ return action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
1334
1353
  }
1335
1354
 
1336
1355
  function useRowActionTrigger(row: ListRowViewModel) {
@@ -2192,17 +2211,27 @@ function DefaultForm({
2192
2211
  {...(fillHeight === true && { fillHeight: true })}
2193
2212
  >
2194
2213
  {headerRegion !== undefined && (
2195
- <div className="flex flex-col gap-6 mb-8">{headerRegion}</div>
2214
+ <div className={cn("flex flex-col gap-6 mb-8", fillHeight === true && "shrink-0")}>
2215
+ {headerRegion}
2216
+ </div>
2196
2217
  )}
2197
2218
  <div
2198
2219
  className={cn(
2199
2220
  cardSurface(),
2200
2221
  "overflow-hidden",
2201
- fillHeight === true && "flex-1 min-h-0 flex flex-col",
2222
+ // No flex-1: a fillHeight card sizes to its content (flex's
2223
+ // initial 0 1 auto) and only claims more than that once its
2224
+ // FormScreenShell ancestor is itself height-constrained and
2225
+ // shrinks it back down via min-h-0 — flex-1 would instead force
2226
+ // it to always fill the remaining height, stretching a short
2227
+ // relatedList tab to the bottom of the panel (fw#2778).
2228
+ fillHeight === true && "min-h-0 flex flex-col",
2202
2229
  )}
2203
2230
  >
2204
2231
  {(title !== undefined || subtitle !== undefined) && (
2205
- <div className={cn(cardHeaderBorder, "px-6 pb-4 pt-5")}>
2232
+ <div
2233
+ className={cn(cardHeaderBorder, "px-6 pb-4 pt-5", fillHeight === true && "shrink-0")}
2234
+ >
2206
2235
  {title !== undefined && (
2207
2236
  <h2
2208
2237
  data-testid={testId !== undefined ? `${testId}-title` : undefined}
@@ -2234,7 +2263,10 @@ function DefaultForm({
2234
2263
  // safe-area, fw#2528) — widen further if a wizard step's last field
2235
2264
  // ever renders visibly clipped under three or more wrapped rows.
2236
2265
  stickyActions === true && "max-sm:pb-32",
2237
- fillHeight === true && "flex-1 min-h-0",
2266
+ // Same "no flex-1" reasoning as the card above: this is the
2267
+ // one section allowed to shrink (min-h-0) inside the card, not
2268
+ // one forced to grow past its content.
2269
+ fillHeight === true && "min-h-0",
2238
2270
  )}
2239
2271
  >
2240
2272
  <InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
@@ -2244,6 +2276,7 @@ function DefaultForm({
2244
2276
  className={cn(
2245
2277
  "flex flex-col-reverse gap-3 px-[var(--card-padding)] py-3 sm:flex-row sm:items-center sm:justify-between sm:py-4",
2246
2278
  cardFooterBorder,
2279
+ fillHeight === true && "shrink-0",
2247
2280
  // Below sm (640px): pin to the viewport bottom instead of normal
2248
2281
  // flow, so a virtual keyboard shrinking the viewport can't push
2249
2282
  // this out of reach (fw#1918). `fixed` escapes the card's
@@ -2421,8 +2454,12 @@ function DefaultSection({
2421
2454
  }
2422
2455
 
2423
2456
  function DefaultFillContainer({ children, testId }: FillContainerProps): ReactNode {
2457
+ // No flex-1: sizes to its content (the relatedList table) and only
2458
+ // shrinks (min-h-0) once the ancestor FormScreenShell chain is itself
2459
+ // height-constrained — flex-1 would instead always stretch to fill the
2460
+ // remaining tab-panel height, even for a two-row table (fw#2778).
2424
2461
  return (
2425
- <div data-testid={testId} className="flex flex-1 min-h-0 flex-col">
2462
+ <div data-testid={testId} className="flex min-h-0 flex-col">
2426
2463
  {children}
2427
2464
  </div>
2428
2465
  );