@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 +5 -5
- package/src/__tests__/kumiko-screen.test.tsx +183 -0
- package/src/__tests__/primitives.test.tsx +52 -13
- package/src/__tests__/render-edit.test.tsx +33 -0
- package/src/primitives/__tests__/select-display-request.test.tsx +3 -3
- package/src/primitives/__tests__/select-segmented.test.tsx +57 -5
- package/src/primitives/index.tsx +63 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "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.
|
|
20
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
21
|
-
"@cosmicdrift/kumiko-renderer": "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.
|
|
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
|
|
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
|
-
|
|
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
|
|
1532
|
-
// flex-1 min-h-0
|
|
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,
|
|
14
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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("
|
|
56
|
+
test("a long label no longer pushes the field into the dropdown", () => {
|
|
56
57
|
renderSelect(["Draft", "Review", "Published Status"]);
|
|
57
|
-
expect(screen.
|
|
58
|
-
expect(screen.
|
|
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
|
+
});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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`
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
// `
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
1331
|
-
//
|
|
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"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2462
|
+
<div data-testid={testId} className="flex min-h-0 flex-col">
|
|
2426
2463
|
{children}
|
|
2427
2464
|
</div>
|
|
2428
2465
|
);
|