@cosmicdrift/kumiko-renderer-web 0.220.0 → 0.221.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__/form-action-bar.test.tsx +1 -1
- package/src/__tests__/kumiko-screen.test.tsx +60 -5
- package/src/__tests__/primitives.test.tsx +54 -2
- package/src/__tests__/render-edit.test.tsx +8 -14
- package/src/__tests__/render-list-unit-locale.test.tsx +125 -0
- package/src/app/__tests__/document-lang-sync.test.tsx +43 -0
- package/src/app/client-plugin.tsx +2 -1
- package/src/app/document-lang-sync.tsx +14 -8
- package/src/lib/__tests__/download.test.ts +3 -3
- package/src/lib/__tests__/download.test.tsx +30 -0
- package/src/primitives/__tests__/data-table-logic.test.ts +27 -0
- package/src/primitives/__tests__/embedded-list-input.test.tsx +21 -0
- package/src/primitives/index.tsx +52 -31
- package/src/ui/sheet.tsx +2 -15
- package/src/widgets/__tests__/feed-list.test.tsx +3 -0
- package/src/widgets/__tests__/widgets.test.tsx +27 -0
- package/src/widgets/charts.tsx +46 -27
- package/src/widgets/drawer.tsx +15 -11
- package/src/widgets/infinity-list.tsx +3 -21
- package/src/widgets/sheet-parts.tsx +62 -0
- package/src/widgets/upload-zone.tsx +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.221.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.221.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.221.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.221.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.221.0"
|
|
68
68
|
},
|
|
69
69
|
"repository": {
|
|
70
70
|
"type": "git",
|
|
@@ -26,7 +26,7 @@ describe("DefaultForm Action-Footer", () => {
|
|
|
26
26
|
expect(actions.className).toContain("border-t");
|
|
27
27
|
// Footer sits in the form shell (buttons align with the field edge).
|
|
28
28
|
expect(actions.closest(".max-w-full")).toBeTruthy();
|
|
29
|
-
//
|
|
29
|
+
// Title is its own heading above — not inside the action bar.
|
|
30
30
|
expect(actions.textContent).not.toContain("Titel");
|
|
31
31
|
expect(screen.getByTestId("f-title").textContent).toBe("Titel");
|
|
32
32
|
});
|
|
@@ -1570,7 +1570,7 @@ describe("KumikoScreen", () => {
|
|
|
1570
1570
|
expect(writeCalls[0]?.payload).toEqual({ title: "New Task", priority: 1 });
|
|
1571
1571
|
});
|
|
1572
1572
|
|
|
1573
|
-
test("actionForm mit redirect: nach success →
|
|
1573
|
+
test("actionForm mit redirect auf entityList: nach success → navigate ohne entityId (#2419)", async () => {
|
|
1574
1574
|
const navigateCalls: NavTarget[] = [];
|
|
1575
1575
|
const dispatcher = makeDispatcher({
|
|
1576
1576
|
write: (async () => ({
|
|
@@ -1613,9 +1613,9 @@ describe("KumikoScreen", () => {
|
|
|
1613
1613
|
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
1614
1614
|
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1615
1615
|
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1616
|
-
// #
|
|
1617
|
-
|
|
1618
|
-
expect(navigateCalls[0]
|
|
1616
|
+
// #2419: entityList targets ignore entityId — do not attach a handler id.
|
|
1617
|
+
expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
|
|
1618
|
+
expect(Object.hasOwn(navigateCalls[0] ?? {}, "entityId")).toBe(false);
|
|
1619
1619
|
});
|
|
1620
1620
|
|
|
1621
1621
|
test("actionForm mit Cross-Feature-QN als redirect: navigiert per Short-Id (#1946)", async () => {
|
|
@@ -1661,7 +1661,62 @@ describe("KumikoScreen", () => {
|
|
|
1661
1661
|
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
1662
1662
|
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1663
1663
|
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1664
|
-
|
|
1664
|
+
// Cross-feature QN is not in this feature's schema.screens — keep prior
|
|
1665
|
+
// behavior without entityId (#2419).
|
|
1666
|
+
expect(navigateCalls[0]).toEqual({ screenId: "statement-upload-list" });
|
|
1667
|
+
expect(Object.hasOwn(navigateCalls[0] ?? {}, "entityId")).toBe(false);
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
test("actionForm mit redirect auf entityEdit: hängt entityId an (#2419)", async () => {
|
|
1671
|
+
const navigateCalls: NavTarget[] = [];
|
|
1672
|
+
const dispatcher = makeDispatcher({
|
|
1673
|
+
write: (async () => ({
|
|
1674
|
+
isSuccess: true,
|
|
1675
|
+
data: { id: "x" },
|
|
1676
|
+
})) as unknown as Dispatcher["write"],
|
|
1677
|
+
});
|
|
1678
|
+
const memoryNav = {
|
|
1679
|
+
route: { screenId: "quick-add" },
|
|
1680
|
+
navigate: (target: NavTarget) => {
|
|
1681
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1682
|
+
},
|
|
1683
|
+
replace: () => undefined,
|
|
1684
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1685
|
+
searchParams: {},
|
|
1686
|
+
setSearchParams: () => undefined,
|
|
1687
|
+
};
|
|
1688
|
+
const editScreen: EntityEditScreenDefinition = {
|
|
1689
|
+
id: "task-edit",
|
|
1690
|
+
type: "entityEdit",
|
|
1691
|
+
entity: "task",
|
|
1692
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
1693
|
+
};
|
|
1694
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
1695
|
+
id: "quick-add",
|
|
1696
|
+
type: "actionForm",
|
|
1697
|
+
handler: "tasks:write:task:quick-add",
|
|
1698
|
+
fields: { title: { type: "text", required: true } },
|
|
1699
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
1700
|
+
redirect: "task-edit",
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
1704
|
+
render(
|
|
1705
|
+
<NavProvider value={memoryNav}>
|
|
1706
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1707
|
+
<KumikoScreen
|
|
1708
|
+
schema={{ ...schema, screens: [actionScreen, editScreen, listScreen] }}
|
|
1709
|
+
qn="tasks:screen:quick-add"
|
|
1710
|
+
/>
|
|
1711
|
+
</DispatcherProvider>
|
|
1712
|
+
</NavProvider>,
|
|
1713
|
+
);
|
|
1714
|
+
|
|
1715
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1716
|
+
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
1717
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1718
|
+
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1719
|
+
expect(navigateCalls[0]).toEqual({ screenId: "task-edit", entityId: "x" });
|
|
1665
1720
|
});
|
|
1666
1721
|
|
|
1667
1722
|
// #2416: entityEdit-create passes the newly created record's id through to
|
|
@@ -193,7 +193,16 @@ describe("Input kind mapping", () => {
|
|
|
193
193
|
|
|
194
194
|
test('kind="text" icon="not-a-real-key": unknown key → no icon, no padding', () => {
|
|
195
195
|
render(
|
|
196
|
-
<Input
|
|
196
|
+
<Input
|
|
197
|
+
id="i"
|
|
198
|
+
name="i"
|
|
199
|
+
kind="text"
|
|
200
|
+
value=""
|
|
201
|
+
icon={
|
|
202
|
+
"not-a-real-key" as unknown as import("@cosmicdrift/kumiko-framework/ui-types").FieldIconKey
|
|
203
|
+
}
|
|
204
|
+
onChange={() => {}}
|
|
205
|
+
/>,
|
|
197
206
|
);
|
|
198
207
|
const input = screen.getByRole("textbox");
|
|
199
208
|
expect(input.className).not.toContain("pl-8");
|
|
@@ -201,7 +210,18 @@ describe("Input kind mapping", () => {
|
|
|
201
210
|
});
|
|
202
211
|
|
|
203
212
|
test('kind="text" icon="constructor": prototype-chain key → no icon, no padding', () => {
|
|
204
|
-
render(
|
|
213
|
+
render(
|
|
214
|
+
<Input
|
|
215
|
+
id="i"
|
|
216
|
+
name="i"
|
|
217
|
+
kind="text"
|
|
218
|
+
value=""
|
|
219
|
+
icon={
|
|
220
|
+
"constructor" as unknown as import("@cosmicdrift/kumiko-framework/ui-types").FieldIconKey
|
|
221
|
+
}
|
|
222
|
+
onChange={() => {}}
|
|
223
|
+
/>,
|
|
224
|
+
);
|
|
205
225
|
const input = screen.getByRole("textbox");
|
|
206
226
|
expect(input.className).not.toContain("pl-8");
|
|
207
227
|
expect(document.querySelector("svg[aria-hidden='true']")).toBeNull();
|
|
@@ -1044,6 +1064,38 @@ describe("DataTable", () => {
|
|
|
1044
1064
|
expect((screen.getByTestId("edit-name") as HTMLInputElement).value).toBe("Alice");
|
|
1045
1065
|
});
|
|
1046
1066
|
|
|
1067
|
+
test("plain cell still fires onRowClick when onCellChange is set (stopPropagation only on editable)", () => {
|
|
1068
|
+
const onRowClick = mock();
|
|
1069
|
+
const onCellChange = mock();
|
|
1070
|
+
const cols = [
|
|
1071
|
+
{
|
|
1072
|
+
field: "name",
|
|
1073
|
+
label: "Name",
|
|
1074
|
+
type: "string",
|
|
1075
|
+
sortable: false,
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
field: "edit",
|
|
1079
|
+
label: "Edit",
|
|
1080
|
+
type: "string",
|
|
1081
|
+
sortable: false,
|
|
1082
|
+
renderer: { react: { __component: "EditableSwatch" } },
|
|
1083
|
+
},
|
|
1084
|
+
] as const;
|
|
1085
|
+
render(
|
|
1086
|
+
<ColumnRenderersProvider value={{ EditableSwatch }}>
|
|
1087
|
+
<DataTable
|
|
1088
|
+
columns={cols}
|
|
1089
|
+
rows={[{ id: "r1", values: { name: "Alice", edit: "x" } }]}
|
|
1090
|
+
onRowClick={onRowClick}
|
|
1091
|
+
onCellChange={onCellChange}
|
|
1092
|
+
/>
|
|
1093
|
+
</ColumnRenderersProvider>,
|
|
1094
|
+
);
|
|
1095
|
+
fireEvent.click(screen.getByTestId("cell-r1-name"));
|
|
1096
|
+
expect(onRowClick).toHaveBeenCalled();
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1047
1099
|
test("format-spec renderer ignores onCellChange — no crash, plain formatted text", () => {
|
|
1048
1100
|
const onCellChange = mock();
|
|
1049
1101
|
const cols = [
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "@cosmicdrift/kumiko-renderer";
|
|
17
17
|
import { type ReactNode, useCallback, useState } from "react";
|
|
18
18
|
import { z } from "zod";
|
|
19
|
+
import { PATCH_DRAFT_SAVE_DEBOUNCE_MS } from "../../../renderer/src/components/render-edit";
|
|
19
20
|
import {
|
|
20
21
|
act,
|
|
21
22
|
createFakeDraftStorage,
|
|
@@ -1307,14 +1308,11 @@ describe("RenderEdit wizard mode", () => {
|
|
|
1307
1308
|
expect(screen.queryByTestId("field-title-errors")).toBeNull();
|
|
1308
1309
|
});
|
|
1309
1310
|
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
// issues from every validate() call via its own `hiddenFields` set
|
|
1316
|
-
// (form-controller.ts:200), independent of scope; this test protects
|
|
1317
|
-
// that existing guard, not the fieldNames narrowing added here.
|
|
1311
|
+
// Regression for form-controller.ts's `hiddenFields` guard: a required
|
|
1312
|
+
// field currently hidden by its own condition (e.g. vatId when not a
|
|
1313
|
+
// company) must not block wizard "Next". This is independent of any
|
|
1314
|
+
// handleWizardNext fieldNames narrowing — those paths are covered by
|
|
1315
|
+
// the fully-hidden-section test below.
|
|
1318
1316
|
test("Weiter is not blocked by a required field that's currently hidden by its own condition", async () => {
|
|
1319
1317
|
const companyEntity = {
|
|
1320
1318
|
fields: {
|
|
@@ -1590,10 +1588,6 @@ describe("RenderEdit wizard mode", () => {
|
|
|
1590
1588
|
});
|
|
1591
1589
|
|
|
1592
1590
|
describe("RenderEdit wizard draft", () => {
|
|
1593
|
-
// Mirrors render-edit.tsx's own PATCH_DRAFT_SAVE_DEBOUNCE_MS (not
|
|
1594
|
-
// exported) — tests below wait out a full window to catch stragglers.
|
|
1595
|
-
const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
|
|
1596
|
-
|
|
1597
1591
|
function makeDraftWizardScreen(draft: boolean): EntityEditScreenDefinition {
|
|
1598
1592
|
return {
|
|
1599
1593
|
id: "orders:screen:order-wizard-draft",
|
|
@@ -2109,7 +2103,7 @@ describe("RenderEdit wizard draft", () => {
|
|
|
2109
2103
|
// second burst) would slip past an assertion taken right after the
|
|
2110
2104
|
// first save. Wait out a full extra debounce window to prove there is
|
|
2111
2105
|
// no straggler.
|
|
2112
|
-
await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS +
|
|
2106
|
+
await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 1000));
|
|
2113
2107
|
expect(savesSoFar()).toBe(before + 1);
|
|
2114
2108
|
});
|
|
2115
2109
|
|
|
@@ -2191,7 +2185,7 @@ describe("RenderEdit wizard draft", () => {
|
|
|
2191
2185
|
// Wait past the debounce window the patch() call armed. If discard
|
|
2192
2186
|
// hadn't cleared the timer, a straggling save would land here and
|
|
2193
2187
|
// resurrect the just-discarded draft.
|
|
2194
|
-
await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS +
|
|
2188
|
+
await new Promise((resolve) => setTimeout(resolve, PATCH_DRAFT_SAVE_DEBOUNCE_MS + 1000));
|
|
2195
2189
|
|
|
2196
2190
|
expect(calls.filter((c) => c === "form-draft:write:save").length).toBe(savesAtSubmit);
|
|
2197
2191
|
expect(store.current).toBeNull();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// fw#2437: DataTableCell must thread the app locale (LocaleProvider) into
|
|
2
|
+
// `format: "unit"` / Intl.NumberFormat cells instead of falling back to the
|
|
3
|
+
// runtime's default locale — list vs. detail view could otherwise disagree
|
|
4
|
+
// ("72.5 m²" vs "72,5 m²") for the same value.
|
|
5
|
+
//
|
|
6
|
+
// DoD-Test: rendering the SAME `{ format: "unit", unit: "m2" }` column under
|
|
7
|
+
// two locales must produce two DIFFERENT cell labels — not just prove the
|
|
8
|
+
// unit-format key exists.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from "bun:test";
|
|
11
|
+
import type {
|
|
12
|
+
EntityDefinition,
|
|
13
|
+
EntityListScreenDefinition,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
15
|
+
import {
|
|
16
|
+
createStaticLocaleResolver,
|
|
17
|
+
type LiveEventSubscriber,
|
|
18
|
+
LiveEventsProvider,
|
|
19
|
+
LocaleProvider,
|
|
20
|
+
type NavApi,
|
|
21
|
+
NavProvider,
|
|
22
|
+
PrimitivesProvider,
|
|
23
|
+
RenderList,
|
|
24
|
+
TokensProvider,
|
|
25
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
26
|
+
import { cleanup, render } from "@testing-library/react";
|
|
27
|
+
import type { ReactElement, ReactNode } from "react";
|
|
28
|
+
import { defaultPrimitives } from "../primitives";
|
|
29
|
+
import { defaultTokens } from "../tokens";
|
|
30
|
+
|
|
31
|
+
const stubNav: NavApi = {
|
|
32
|
+
route: undefined,
|
|
33
|
+
navigate: () => {},
|
|
34
|
+
replace: () => {},
|
|
35
|
+
hrefFor: () => "",
|
|
36
|
+
searchParams: {},
|
|
37
|
+
setSearchParams: () => {},
|
|
38
|
+
};
|
|
39
|
+
const stubLiveEvents: LiveEventSubscriber = () => () => {};
|
|
40
|
+
const stubTokens = {
|
|
41
|
+
tokens: defaultTokens,
|
|
42
|
+
mode: "dark" as const,
|
|
43
|
+
setMode: () => {},
|
|
44
|
+
toggleMode: () => {},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Same all-text synthesized entity shape as projection-list-shim.ts /
|
|
48
|
+
// related-list-section.tsx — a projectionList/relatedList column has no real
|
|
49
|
+
// EntityDefinition, only a number-typed pseudo-field per column.
|
|
50
|
+
const areaColumnEntity = {
|
|
51
|
+
fields: { area: { type: "number" } },
|
|
52
|
+
} as unknown as EntityDefinition;
|
|
53
|
+
|
|
54
|
+
const unitScreen: EntityListScreenDefinition = {
|
|
55
|
+
id: "",
|
|
56
|
+
type: "entityList",
|
|
57
|
+
entity: "__projection__",
|
|
58
|
+
columns: [{ field: "area", renderer: { format: "unit", unit: "m2" } }],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const numberScreen: EntityListScreenDefinition = {
|
|
62
|
+
id: "",
|
|
63
|
+
type: "entityList",
|
|
64
|
+
entity: "__projection__",
|
|
65
|
+
columns: [{ field: "area" }],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function renderAreaListUnderLocale(
|
|
69
|
+
locale: string,
|
|
70
|
+
areaValue: number,
|
|
71
|
+
screen: EntityListScreenDefinition,
|
|
72
|
+
): string | null {
|
|
73
|
+
cleanup();
|
|
74
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactElement {
|
|
75
|
+
return (
|
|
76
|
+
<TokensProvider value={stubTokens}>
|
|
77
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale })}>
|
|
78
|
+
<PrimitivesProvider value={defaultPrimitives}>
|
|
79
|
+
<NavProvider value={stubNav}>
|
|
80
|
+
<LiveEventsProvider value={stubLiveEvents}>{children}</LiveEventsProvider>
|
|
81
|
+
</NavProvider>
|
|
82
|
+
</PrimitivesProvider>
|
|
83
|
+
</LocaleProvider>
|
|
84
|
+
</TokensProvider>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const result = render(
|
|
88
|
+
<RenderList
|
|
89
|
+
screen={screen}
|
|
90
|
+
entity={areaColumnEntity}
|
|
91
|
+
rows={[{ id: "r1", area: areaValue }]}
|
|
92
|
+
featureName="listing"
|
|
93
|
+
/>,
|
|
94
|
+
{ wrapper: Wrapper },
|
|
95
|
+
);
|
|
96
|
+
return result.getByTestId("cell-r1-area").textContent;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("RenderList — format:unit-FormatSpec threads the app locale (fw#2437)", () => {
|
|
100
|
+
test("dieselbe m2-Spalte unter zwei Locales rendert zwei verschiedene Beschriftungen", () => {
|
|
101
|
+
expect(renderAreaListUnderLocale("de-DE", 72.5, unitScreen)).toBe("72,5 m²");
|
|
102
|
+
expect(renderAreaListUnderLocale("en-US", 72.5, unitScreen)).toBe("72.5 m²");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("ein renderer.locale gewinnt weiterhin vor dem App-Locale", () => {
|
|
106
|
+
const screen: EntityListScreenDefinition = {
|
|
107
|
+
id: "",
|
|
108
|
+
type: "entityList",
|
|
109
|
+
entity: "__projection__",
|
|
110
|
+
columns: [{ field: "area", renderer: { format: "unit", unit: "m2", locale: "en-US" } }],
|
|
111
|
+
};
|
|
112
|
+
expect(renderAreaListUnderLocale("de-DE", 72.5, screen)).toBe("72.5 m²");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("RenderList — default number-column threads the app locale (fw#2437)", () => {
|
|
117
|
+
test("kein expliziter renderer: number-Zelle folgt dem App-Locale, nicht der Runtime-Locale", () => {
|
|
118
|
+
expect(renderAreaListUnderLocale("de-DE", 1234.5, numberScreen)).toBe(
|
|
119
|
+
new Intl.NumberFormat("de-DE").format(1234.5),
|
|
120
|
+
);
|
|
121
|
+
expect(renderAreaListUnderLocale("en-US", 1234.5, numberScreen)).toBe(
|
|
122
|
+
new Intl.NumberFormat("en-US").format(1234.5),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { render, waitFor } from "@testing-library/react";
|
|
4
|
+
import { DocumentLangSync } from "../document-lang-sync";
|
|
5
|
+
|
|
6
|
+
function resolverWithLocale(initial: string): {
|
|
7
|
+
readonly resolver: LocaleResolver;
|
|
8
|
+
setLocale(next: string): void;
|
|
9
|
+
} {
|
|
10
|
+
let locale = initial;
|
|
11
|
+
const listeners = new Set<() => void>();
|
|
12
|
+
return {
|
|
13
|
+
resolver: {
|
|
14
|
+
translate: (key: string) => key,
|
|
15
|
+
locale: () => locale,
|
|
16
|
+
timeZone: () => "UTC",
|
|
17
|
+
subscribe: (listener: () => void) => {
|
|
18
|
+
listeners.add(listener);
|
|
19
|
+
return () => listeners.delete(listener);
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
setLocale(next: string) {
|
|
23
|
+
locale = next;
|
|
24
|
+
for (const listener of listeners) listener();
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("DocumentLangSync", () => {
|
|
30
|
+
test("sets document.documentElement.lang from the resolver and updates on locale change", async () => {
|
|
31
|
+
const { resolver, setLocale } = resolverWithLocale("de-DE");
|
|
32
|
+
render(<DocumentLangSync resolver={resolver} />);
|
|
33
|
+
|
|
34
|
+
await waitFor(() => {
|
|
35
|
+
expect(document.documentElement.lang).toBe("de-DE");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
setLocale("en-US");
|
|
39
|
+
await waitFor(() => {
|
|
40
|
+
expect(document.documentElement.lang).toBe("en-US");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
QualifiedContentCollection,
|
|
18
18
|
TranslationsByLocale,
|
|
19
19
|
} from "@cosmicdrift/kumiko-renderer";
|
|
20
|
+
import type { ContentEditorFormat } from "@cosmicdrift/kumiko-types/nav";
|
|
20
21
|
import type { ComponentType, ReactNode } from "react";
|
|
21
22
|
|
|
22
23
|
/** What a `navProvidersFromCollections` factory returns: the providers plus
|
|
@@ -97,7 +98,7 @@ export type ClientFeatureDefinition = {
|
|
|
97
98
|
* a plain textarea when no clientFeature registered one for that
|
|
98
99
|
* format. Same last-wins semantics as columnRenderers. Public component
|
|
99
100
|
* contract: `{ value, onChange, variables, readOnly }`. */
|
|
100
|
-
readonly contentEditors?:
|
|
101
|
+
readonly contentEditors?: Partial<Record<ContentEditorFormat, ContentEditorComponent>>;
|
|
101
102
|
|
|
102
103
|
/** Editor-Resolver-Komponenten pro featureId:action-Key. Wenn ein
|
|
103
104
|
* TreeNode mit target angeklickt wird, schlägt der EditorPanel das
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// Keeps document.documentElement.lang in sync with
|
|
2
|
-
// Screen readers and the browser's built-in translate
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// access to the resolver — learns the active language.
|
|
1
|
+
// Keeps document.documentElement.lang (+ dataset.kumikoLocale) in sync with
|
|
2
|
+
// the active locale. Screen readers and the browser's built-in translate
|
|
3
|
+
// offer read lang; dispatcher-live reads dataset.kumikoLocale for X-Locale
|
|
4
|
+
// (see dispatcher-live/src/locale.ts) so a static <html lang> cannot spoof
|
|
5
|
+
// the active UI language.
|
|
7
6
|
//
|
|
8
7
|
// Lives here, not in @cosmicdrift/kumiko-renderer: that package is DOM-free
|
|
9
8
|
// by design (Renderer-Boundaries Guard enforces it — web/native both
|
|
@@ -14,11 +13,18 @@
|
|
|
14
13
|
import type { LocaleResolver } from "@cosmicdrift/kumiko-headless";
|
|
15
14
|
import { useEffect } from "react";
|
|
16
15
|
|
|
16
|
+
function syncDocumentLocale(locale: string): void {
|
|
17
|
+
document.documentElement.lang = locale;
|
|
18
|
+
// Dedicated marker for dispatcher-live — static <html lang> must not
|
|
19
|
+
// look like an active UI locale (kumiko-framework#2334).
|
|
20
|
+
document.documentElement.dataset["kumikoLocale"] = locale;
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
export function DocumentLangSync({ resolver }: { readonly resolver: LocaleResolver }): null {
|
|
18
24
|
useEffect(() => {
|
|
19
|
-
|
|
25
|
+
syncDocumentLocale(resolver.locale());
|
|
20
26
|
return resolver.subscribe(() => {
|
|
21
|
-
|
|
27
|
+
syncDocumentLocale(resolver.locale());
|
|
22
28
|
});
|
|
23
29
|
}, [resolver]);
|
|
24
30
|
return null;
|
|
@@ -6,9 +6,7 @@ function stubDispatcher(result: QueryResult<{ url?: string }>): Dispatcher {
|
|
|
6
6
|
return { query: async () => result } as unknown as Dispatcher;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
// src/__tests__/download.test.tsx duplicate was removed — this helper
|
|
11
|
-
// already covers both bunfig (node) and bunfig.dom.toml (happy-dom):
|
|
9
|
+
// Covers both bunfig (node) and bunfig.dom.toml (happy-dom):
|
|
12
10
|
// - node env: install a minimal fake window for the duration of the test,
|
|
13
11
|
// then remove it again so later suites are unaffected.
|
|
14
12
|
// - dom env: patch only location.assign on the real window — swapping the
|
|
@@ -56,6 +54,7 @@ describe("postWithDownload", () => {
|
|
|
56
54
|
});
|
|
57
55
|
|
|
58
56
|
test("passes the dispatcher error through when not successful", async () => {
|
|
57
|
+
tracked = trackAssignedUrl();
|
|
59
58
|
const dispatcherError: DispatcherError = {
|
|
60
59
|
code: "boom",
|
|
61
60
|
httpStatus: 500,
|
|
@@ -65,6 +64,7 @@ describe("postWithDownload", () => {
|
|
|
65
64
|
const dispatcher = stubDispatcher({ isSuccess: false, error: dispatcherError });
|
|
66
65
|
const err = await postWithDownload(dispatcher, "files.signedUrl", {});
|
|
67
66
|
expect(err).toEqual(dispatcherError);
|
|
67
|
+
expect(tracked.url()).toBeUndefined();
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
test("returns download_url_missing error when url is absent", async () => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// happy-dom coverage for postWithDownload's window.location.assign path.
|
|
2
|
+
// bunfig.toml (unit `test`) ignores *.test.tsx; bunfig.dom.toml picks this up.
|
|
3
|
+
|
|
4
|
+
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
|
5
|
+
import type { Dispatcher, QueryResult } from "@cosmicdrift/kumiko-headless";
|
|
6
|
+
import { postWithDownload } from "../download";
|
|
7
|
+
|
|
8
|
+
function stubDispatcher(result: QueryResult<{ url?: string }>): Dispatcher {
|
|
9
|
+
return { query: async () => result } as unknown as Dispatcher;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("postWithDownload (happy-dom)", () => {
|
|
13
|
+
let assignSpy: ReturnType<typeof spyOn> | undefined;
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
assignSpy?.mockRestore();
|
|
17
|
+
assignSpy = undefined;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("assigns the url on the real window", async () => {
|
|
21
|
+
assignSpy = spyOn(window.location, "assign").mockImplementation(() => {});
|
|
22
|
+
const err = await postWithDownload(
|
|
23
|
+
stubDispatcher({ isSuccess: true, data: { url: "https://cdn.example.com/f.pdf" } }),
|
|
24
|
+
"files.signedUrl",
|
|
25
|
+
{},
|
|
26
|
+
);
|
|
27
|
+
expect(err).toBeNull();
|
|
28
|
+
expect(assignSpy).toHaveBeenCalledWith("https://cdn.example.com/f.pdf");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -209,6 +209,19 @@ describe("defaultCellRender", () => {
|
|
|
209
209
|
expect(new Intl.NumberFormat("de-DE").format(245.5)).toBe("245,5");
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
test("number/decimal → explicit locale param wins over the runtime default (fw#2437)", () => {
|
|
213
|
+
expect(defaultCellRender(245.5, "number", undefined, "de-DE")).toBe(
|
|
214
|
+
new Intl.NumberFormat("de-DE").format(245.5),
|
|
215
|
+
);
|
|
216
|
+
expect(defaultCellRender(245.5, "decimal", undefined, "en-US")).toBe(
|
|
217
|
+
new Intl.NumberFormat("en-US").format(245.5),
|
|
218
|
+
);
|
|
219
|
+
// Same value, two locales → two different strings, not just "locale is accepted".
|
|
220
|
+
expect(defaultCellRender(245.5, "number", undefined, "de-DE")).not.toBe(
|
|
221
|
+
defaultCellRender(245.5, "number", undefined, "en-US"),
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
212
225
|
test("money → { amount, currency } formatiert, kein [object Object]", () => {
|
|
213
226
|
const result = defaultCellRender({ amount: 450, currency: "EUR" }, "money");
|
|
214
227
|
expect(result).not.toBe("[object Object]");
|
|
@@ -254,4 +267,18 @@ describe("defaultCellRender", () => {
|
|
|
254
267
|
expect(() => defaultCellRender({ amount: 1, currency: "" }, "money")).not.toThrow();
|
|
255
268
|
expect(defaultCellRender({ amount: 1, currency: "" }, "money")).toBe("[object Object]");
|
|
256
269
|
});
|
|
270
|
+
|
|
271
|
+
test("money → explicit locale param wins over guessLocale's navigator fallback (fw#2437)", () => {
|
|
272
|
+
const value = { amount: 1234.5, currency: "EUR" };
|
|
273
|
+
const de = defaultCellRender(value, "money", undefined, "de-DE");
|
|
274
|
+
const en = defaultCellRender(value, "money", undefined, "en-US");
|
|
275
|
+
// Same amount, two locales → two different strings, not just "locale is accepted".
|
|
276
|
+
expect(de).not.toBe(en);
|
|
277
|
+
expect(de).toBe(
|
|
278
|
+
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.5),
|
|
279
|
+
);
|
|
280
|
+
expect(en).toBe(
|
|
281
|
+
new Intl.NumberFormat("en-US", { style: "currency", currency: "EUR" }).format(1234.5),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
257
284
|
});
|
|
@@ -498,6 +498,27 @@ describe("EmbeddedListInput — paste", () => {
|
|
|
498
498
|
});
|
|
499
499
|
|
|
500
500
|
describe("EmbeddedListInput — Enter-to-add-row (#1839)", () => {
|
|
501
|
+
test("Enter on a select trigger in the last cell does not append a row", () => {
|
|
502
|
+
const columns: readonly EmbeddedListColumn[] = [
|
|
503
|
+
{
|
|
504
|
+
field: "status",
|
|
505
|
+
label: "Status",
|
|
506
|
+
type: "select",
|
|
507
|
+
required: false,
|
|
508
|
+
derived: false,
|
|
509
|
+
options: ["open"],
|
|
510
|
+
},
|
|
511
|
+
];
|
|
512
|
+
const rows = [{ status: "open" }];
|
|
513
|
+
const onAddRow = mock(() => {});
|
|
514
|
+
renderWithLocale(<EmbeddedListInput {...baseProps({ columns, rows, onAddRow })} />);
|
|
515
|
+
const desktop = within(screen.getByTestId("lines-desktop"));
|
|
516
|
+
const trigger = desktop.getByTestId("lines-cell-0-status").querySelector("button");
|
|
517
|
+
if (trigger === null) throw new Error("expected a select trigger button");
|
|
518
|
+
fireEvent.keyDown(trigger, { key: "Enter", code: "Enter" });
|
|
519
|
+
expect(onAddRow).not.toHaveBeenCalled();
|
|
520
|
+
});
|
|
521
|
+
|
|
501
522
|
test("Enter on the last editable cell of the last row fires onAddRow and prevents default, same as Tab", () => {
|
|
502
523
|
const rows = [{ description: "A", quantity: 1, amount: 100 }];
|
|
503
524
|
const onAddRow = mock(() => {});
|
package/src/primitives/index.tsx
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// basierte Stile. Radix-UI-Unterbau für interaktive Elemente (Modal,
|
|
9
9
|
// Dropdown etc. kommen später).
|
|
10
10
|
|
|
11
|
+
import type { FieldIconKey } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
11
12
|
import type { ListRowViewModel } from "@cosmicdrift/kumiko-headless";
|
|
12
13
|
import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
|
|
13
14
|
import type {
|
|
@@ -36,6 +37,8 @@ import {
|
|
|
36
37
|
type StepBarProps,
|
|
37
38
|
type TextProps,
|
|
38
39
|
useColumnRenderer,
|
|
40
|
+
useOptionalLocale,
|
|
41
|
+
useOptionalTranslation,
|
|
39
42
|
useTranslation,
|
|
40
43
|
type WizardStepGroupProps,
|
|
41
44
|
WriteFailedError,
|
|
@@ -311,7 +314,7 @@ function DefaultField({
|
|
|
311
314
|
// (nav-tree.tsx) — a separate, smaller registry instead of a shared
|
|
312
315
|
// import, because field icons cover a different use case (email, phone,
|
|
313
316
|
// location, …) than nav icons (dashboard, tables, …).
|
|
314
|
-
const FIELD_ICONS
|
|
317
|
+
const FIELD_ICONS = {
|
|
315
318
|
mail: Mail,
|
|
316
319
|
lock: Lock,
|
|
317
320
|
hash: Hash,
|
|
@@ -325,10 +328,12 @@ const FIELD_ICONS: Readonly<Record<string, typeof Mail>> = {
|
|
|
325
328
|
globe: Globe,
|
|
326
329
|
key: KeyRound,
|
|
327
330
|
"map-pin": MapPin,
|
|
328
|
-
}
|
|
331
|
+
} as const satisfies Readonly<Record<FieldIconKey, typeof Mail>>;
|
|
329
332
|
|
|
330
|
-
function fieldIconFor(icon: string | undefined): (typeof FIELD_ICONS)[
|
|
331
|
-
return icon !== undefined && Object.hasOwn(FIELD_ICONS, icon)
|
|
333
|
+
function fieldIconFor(icon: string | undefined): (typeof FIELD_ICONS)[FieldIconKey] | undefined {
|
|
334
|
+
return icon !== undefined && Object.hasOwn(FIELD_ICONS, icon)
|
|
335
|
+
? FIELD_ICONS[icon as FieldIconKey]
|
|
336
|
+
: undefined;
|
|
332
337
|
}
|
|
333
338
|
|
|
334
339
|
// Wraps a text/number input with a left-positioned prefix icon when
|
|
@@ -668,13 +673,17 @@ function DefaultDataTable({
|
|
|
668
673
|
getRowTestId,
|
|
669
674
|
getCellTestId,
|
|
670
675
|
}: DataTableProps): ReactNode {
|
|
676
|
+
// One locale/translate subscription per table — not per cell (fw#2345).
|
|
677
|
+
// Optional hooks: a bare DataTable outside LocaleProvider must not crash.
|
|
678
|
+
const tableTranslate = useOptionalTranslation();
|
|
679
|
+
const tableLocale = useOptionalLocale();
|
|
671
680
|
// Toolbar-Wrapper: gemeinsamer Container für Toolbar+Tabelle damit
|
|
672
681
|
// beide visuell zusammengehören. Toolbar ist NICHT sticky — Lists
|
|
673
682
|
// scrollen typischerweise mit dem Page-Container, nicht intern.
|
|
674
683
|
// Sticky würde mit der Topbar konkurrieren.
|
|
675
684
|
const hasTableActions = rowActions !== undefined && rowActions.length > 0;
|
|
676
|
-
|
|
677
|
-
rows.length === 0 ? (
|
|
685
|
+
function tableInner(): ReactNode {
|
|
686
|
+
return rows.length === 0 ? (
|
|
678
687
|
<div
|
|
679
688
|
data-testid={testId !== undefined ? `${testId}-empty` : "render-list-empty"}
|
|
680
689
|
className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-sm text-muted-foreground gap-3"
|
|
@@ -734,12 +743,6 @@ function DefaultDataTable({
|
|
|
734
743
|
// if the sum of the columns gets too wide.
|
|
735
744
|
className={cn("max-w-xs truncate", col.highlighted === true && "bg-accent/40")}
|
|
736
745
|
title={cellTitle(row.values[col.field])}
|
|
737
|
-
// A click into the editable cell's widget must not also
|
|
738
|
-
// trigger the row's onClick (typically "Open Detail") —
|
|
739
|
-
// same reasoning as the actions cell below.
|
|
740
|
-
{...(onCellChange !== undefined && {
|
|
741
|
-
onClick: (e: MouseEvent) => e.stopPropagation(),
|
|
742
|
-
})}
|
|
743
746
|
>
|
|
744
747
|
<DataTableCell
|
|
745
748
|
value={row.values[col.field]}
|
|
@@ -747,6 +750,8 @@ function DefaultDataTable({
|
|
|
747
750
|
field={col.field}
|
|
748
751
|
type={col.type}
|
|
749
752
|
renderer={col.renderer}
|
|
753
|
+
translate={tableTranslate}
|
|
754
|
+
locale={tableLocale}
|
|
750
755
|
{...(col.optionLabels !== undefined && { optionLabels: col.optionLabels })}
|
|
751
756
|
{...(onCellChange !== undefined && {
|
|
752
757
|
onChange: (value: unknown) => onCellChange(row.id, col.field, value),
|
|
@@ -777,6 +782,8 @@ function DefaultDataTable({
|
|
|
777
782
|
</Table>
|
|
778
783
|
</div>
|
|
779
784
|
);
|
|
785
|
+
}
|
|
786
|
+
const tableContent = tableInner();
|
|
780
787
|
|
|
781
788
|
// Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
|
|
782
789
|
// damit der User bei einem Filter-Hit-of-Zero zurückblättern kann
|
|
@@ -1440,25 +1447,18 @@ export function defaultCellRender(
|
|
|
1440
1447
|
value: unknown,
|
|
1441
1448
|
type: string,
|
|
1442
1449
|
optionLabels?: Readonly<Record<string, string>>,
|
|
1450
|
+
locale?: string,
|
|
1443
1451
|
): string {
|
|
1444
1452
|
if (value === null || value === undefined || value === "") return "";
|
|
1445
1453
|
if (type === "boolean") return value === true ? "✓" : "";
|
|
1446
|
-
if (type === "timestamp" || type === "date")
|
|
1454
|
+
if (type === "timestamp" || type === "date") {
|
|
1455
|
+
return applyFormatSpec({ format: type, locale }, value);
|
|
1456
|
+
}
|
|
1447
1457
|
if (type === "number" || type === "decimal" || type === "bigInt") {
|
|
1448
|
-
|
|
1449
|
-
// DataTableCell has no app-locale context to thread through yet.
|
|
1450
|
-
// Intl.NumberFormat(undefined) resolves the runtime's default locale.
|
|
1451
|
-
return applyFormatSpec({ format: type }, value);
|
|
1458
|
+
return applyFormatSpec({ format: type, locale }, value);
|
|
1452
1459
|
}
|
|
1453
1460
|
if (type === "money") {
|
|
1454
1461
|
if (!isMoneyValue(value)) return String(value);
|
|
1455
|
-
// No `locale` param here: DataTableCell has no app-locale context to
|
|
1456
|
-
// thread through (unlike the form path — see MoneyInput/RenderField in
|
|
1457
|
-
// packages/renderer, which pins the LocaleProvider locale). formatMoney
|
|
1458
|
-
// falls back to guessLocale() (navigator.language), so the same amount
|
|
1459
|
-
// can render differently in a table vs. a form on the same screen.
|
|
1460
|
-
// Mid-term: pass the app locale down here too, analogous to render-field.
|
|
1461
|
-
//
|
|
1462
1462
|
// formatMoney expects minor units scaled by currencyDecimals(currency).
|
|
1463
1463
|
// rehydrateMoney's `amountMinor` is scaled by a flat MINOR_UNIT_SCALE=100
|
|
1464
1464
|
// instead, which disagrees with currencyDecimals for non-2-decimal
|
|
@@ -1466,7 +1466,7 @@ export function defaultCellRender(
|
|
|
1466
1466
|
// low). Deriving minor units from `amount` keeps this consistent with
|
|
1467
1467
|
// render-field.tsx's moneyMinorValue.
|
|
1468
1468
|
const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
|
|
1469
|
-
return formatMoney(minor, value.currency);
|
|
1469
|
+
return formatMoney(minor, value.currency, locale);
|
|
1470
1470
|
}
|
|
1471
1471
|
if (type === "select") {
|
|
1472
1472
|
const raw = String(value);
|
|
@@ -1506,6 +1506,8 @@ type DataTableCellProps = {
|
|
|
1506
1506
|
readonly renderer?: unknown;
|
|
1507
1507
|
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
1508
1508
|
readonly onChange?: (value: unknown) => void;
|
|
1509
|
+
readonly translate?: (key: string, params?: Readonly<Record<string, unknown>>) => string;
|
|
1510
|
+
readonly locale?: string;
|
|
1509
1511
|
};
|
|
1510
1512
|
|
|
1511
1513
|
// Cell-Renderer als Component (statt reiner Funktion) damit der
|
|
@@ -1525,12 +1527,17 @@ function DataTableCell({
|
|
|
1525
1527
|
renderer,
|
|
1526
1528
|
optionLabels,
|
|
1527
1529
|
onChange,
|
|
1530
|
+
translate,
|
|
1531
|
+
locale,
|
|
1528
1532
|
}: DataTableCellProps): ReactNode {
|
|
1529
1533
|
const componentRef = isComponentRendererRef(renderer);
|
|
1530
1534
|
const ResolvedComponent = useColumnRenderer(componentRef?.name);
|
|
1531
|
-
const t = useTranslation();
|
|
1532
1535
|
if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
|
|
1533
|
-
return applyFormatSpec(
|
|
1536
|
+
return applyFormatSpec(
|
|
1537
|
+
{ locale, ...(renderer as { format: string } & Record<string, unknown>) },
|
|
1538
|
+
value,
|
|
1539
|
+
translate,
|
|
1540
|
+
);
|
|
1534
1541
|
}
|
|
1535
1542
|
if (typeof renderer === "function") {
|
|
1536
1543
|
const fn = renderer as (v: unknown, r?: Readonly<Record<string, unknown>>) => string;
|
|
@@ -1538,7 +1545,7 @@ function DataTableCell({
|
|
|
1538
1545
|
}
|
|
1539
1546
|
if (componentRef !== undefined) {
|
|
1540
1547
|
if (ResolvedComponent !== undefined) {
|
|
1541
|
-
|
|
1548
|
+
const node = (
|
|
1542
1549
|
<ResolvedComponent
|
|
1543
1550
|
value={value}
|
|
1544
1551
|
row={row}
|
|
@@ -1546,6 +1553,21 @@ function DataTableCell({
|
|
|
1546
1553
|
{...(onChange !== undefined && { onChange })}
|
|
1547
1554
|
/>
|
|
1548
1555
|
);
|
|
1556
|
+
// stopPropagation only on the editable branch — plain cells must still
|
|
1557
|
+
// fire row onClick when onCellChange is set for sibling editable cells.
|
|
1558
|
+
if (onChange !== undefined) {
|
|
1559
|
+
return (
|
|
1560
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation only — not a control
|
|
1561
|
+
<span
|
|
1562
|
+
className="contents"
|
|
1563
|
+
onClick={(e: MouseEvent) => e.stopPropagation()}
|
|
1564
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
1565
|
+
>
|
|
1566
|
+
{node}
|
|
1567
|
+
</span>
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
return node;
|
|
1549
1571
|
}
|
|
1550
1572
|
// Renderer im Schema referenziert, aber client-side kein Map-Eintrag —
|
|
1551
1573
|
// typischer Fall: clientFeatures.columnRenderers vergessen oder
|
|
@@ -1560,11 +1582,11 @@ function DataTableCell({
|
|
|
1560
1582
|
// dashboard-01-Muster: outline-Badge + muted statt gefülltem secondary.
|
|
1561
1583
|
return (
|
|
1562
1584
|
<Badge variant="outline" className="px-1.5 text-muted-foreground">
|
|
1563
|
-
{defaultCellRender(value, type, optionLabels)}
|
|
1585
|
+
{defaultCellRender(value, type, optionLabels, locale)}
|
|
1564
1586
|
</Badge>
|
|
1565
1587
|
);
|
|
1566
1588
|
}
|
|
1567
|
-
return defaultCellRender(value, type, optionLabels);
|
|
1589
|
+
return defaultCellRender(value, type, optionLabels, locale);
|
|
1568
1590
|
}
|
|
1569
1591
|
|
|
1570
1592
|
// ---- Form + Section + Grid + Text ----
|
|
@@ -1701,7 +1723,6 @@ function DefaultForm({
|
|
|
1701
1723
|
export type FormScreenShellWidth = FormWidth;
|
|
1702
1724
|
|
|
1703
1725
|
const formScreenShellWidth: Record<FormScreenShellWidth, string> = {
|
|
1704
|
-
sm: "max-w-sm mx-auto",
|
|
1705
1726
|
"3xl": "max-w-3xl mx-auto",
|
|
1706
1727
|
"4xl": "max-w-4xl mx-auto",
|
|
1707
1728
|
full: "max-w-full",
|
package/src/ui/sheet.tsx
CHANGED
|
@@ -47,8 +47,6 @@ function SheetOverlay({
|
|
|
47
47
|
|
|
48
48
|
function SheetContent({
|
|
49
49
|
className,
|
|
50
|
-
overlayClassName,
|
|
51
|
-
overlayStyle,
|
|
52
50
|
children,
|
|
53
51
|
side = "right",
|
|
54
52
|
showCloseButton = true,
|
|
@@ -56,12 +54,10 @@ function SheetContent({
|
|
|
56
54
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
|
57
55
|
side?: "top" | "right" | "bottom" | "left"
|
|
58
56
|
showCloseButton?: boolean
|
|
59
|
-
overlayClassName?: string
|
|
60
|
-
overlayStyle?: React.CSSProperties
|
|
61
57
|
}) {
|
|
62
58
|
return (
|
|
63
59
|
<SheetPortal>
|
|
64
|
-
<SheetOverlay
|
|
60
|
+
<SheetOverlay />
|
|
65
61
|
<SheetPrimitive.Content
|
|
66
62
|
data-slot="sheet-content"
|
|
67
63
|
className={cn(
|
|
@@ -104,16 +100,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
104
100
|
return (
|
|
105
101
|
<div
|
|
106
102
|
data-slot="sheet-footer"
|
|
107
|
-
|
|
108
|
-
// + cardFooterBorder) for padding/border/button-row shape, but keeps
|
|
109
|
-
// the panel's own `bg-background` instead of the card footer's
|
|
110
|
-
// `bg-muted/30` — the sheet panel isn't a card, so its footer reads
|
|
111
|
-
// as the same surface as the body above it. The border-t alone marks
|
|
112
|
-
// the footer boundary.
|
|
113
|
-
className={cn(
|
|
114
|
-
"mt-auto flex items-center justify-end gap-2 border-t bg-background px-[var(--card-padding)] py-4",
|
|
115
|
-
className,
|
|
116
|
-
)}
|
|
103
|
+
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
|
117
104
|
{...props}
|
|
118
105
|
/>
|
|
119
106
|
)
|
|
@@ -18,6 +18,9 @@ describe("FeedList", () => {
|
|
|
18
18
|
test("renders trailing content only when defined", () => {
|
|
19
19
|
render(<FeedList rows={rows} testId="feed" />);
|
|
20
20
|
expect(screen.getByText("3 min")).toBeTruthy();
|
|
21
|
+
const items = screen.getAllByRole("listitem");
|
|
22
|
+
expect(items[0]?.textContent).toContain("3 min");
|
|
23
|
+
expect(items[1]?.textContent).not.toContain("min");
|
|
21
24
|
});
|
|
22
25
|
|
|
23
26
|
test("renders the empty state when there are no rows", () => {
|
|
@@ -318,6 +318,33 @@ describe("StatusBarChart", () => {
|
|
|
318
318
|
expect(container.querySelectorAll("rect").length).toBe(5);
|
|
319
319
|
expect(screen.getByText("heute")).toBeTruthy();
|
|
320
320
|
});
|
|
321
|
+
|
|
322
|
+
test("dense: fixed-size flat bars ohne Tick/Gradient, aria-Label + Tooltips bleiben", () => {
|
|
323
|
+
const { container } = render(
|
|
324
|
+
<StatusBarChart
|
|
325
|
+
dense
|
|
326
|
+
ariaLabel="Zahlungsmonate"
|
|
327
|
+
entries={[
|
|
328
|
+
{ key: "m1", level: 1, tone: "ok", label: "Januar: bezahlt" },
|
|
329
|
+
{ key: "m2", level: 0.5, tone: "bad", label: "Februar: offen" },
|
|
330
|
+
]}
|
|
331
|
+
/>,
|
|
332
|
+
);
|
|
333
|
+
const svg = container.querySelector("svg");
|
|
334
|
+
expect(screen.getByRole("img", { name: "Zahlungsmonate" })).toBeTruthy();
|
|
335
|
+
expect(svg?.getAttribute("class")).not.toContain("w-full");
|
|
336
|
+
expect(svg?.getAttribute("width")).not.toBeNull();
|
|
337
|
+
expect(container.querySelectorAll("linearGradient").length).toBe(0);
|
|
338
|
+
// 1 bar (non-last entry) + 1 bar + 1 last-highlight stripe (no tick) = 3 rects
|
|
339
|
+
expect(container.querySelectorAll("rect").length).toBe(3);
|
|
340
|
+
expect(container.querySelectorAll("title").length).toBe(3); // aria title + 2 entry tooltips
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("dense: leere Entries reservieren keine 36px-Höhe", () => {
|
|
344
|
+
const { container } = render(<StatusBarChart dense ariaLabel="Leer" entries={[]} />);
|
|
345
|
+
const placeholder = container.querySelector("div[aria-hidden]");
|
|
346
|
+
expect(placeholder?.className).toBe("h-3");
|
|
347
|
+
});
|
|
321
348
|
});
|
|
322
349
|
|
|
323
350
|
describe("TimeseriesChart", () => {
|
package/src/widgets/charts.tsx
CHANGED
|
@@ -23,15 +23,17 @@ export type StatusBarEntry = {
|
|
|
23
23
|
readonly label?: string;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
-
/** Status
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
/** Status bar strip (e.g. 90-day uptime): variable-height bars with
|
|
27
|
+
* gradient fade + tick line on top; the last entry gets a "now"
|
|
28
|
+
* accent stripe. `dense` swaps this for a flat-fill, fixed-size
|
|
29
|
+
* variant sized to fit a table cell instead of stretching to `w-full`. */
|
|
29
30
|
export function StatusBarChart({
|
|
30
31
|
entries,
|
|
31
32
|
ariaLabel,
|
|
32
33
|
startLabel,
|
|
33
34
|
endLabel,
|
|
34
35
|
highlightLast = true,
|
|
36
|
+
dense = false,
|
|
35
37
|
testId,
|
|
36
38
|
}: {
|
|
37
39
|
readonly entries: readonly StatusBarEntry[];
|
|
@@ -40,28 +42,34 @@ export function StatusBarChart({
|
|
|
40
42
|
readonly startLabel?: string;
|
|
41
43
|
readonly endLabel?: string;
|
|
42
44
|
readonly highlightLast?: boolean;
|
|
45
|
+
/** Compact variant for table cells: fixed intrinsic size (no `w-full` stretch), flat fills instead of gradients, no tick marks. */
|
|
46
|
+
readonly dense?: boolean;
|
|
43
47
|
readonly testId?: string;
|
|
44
48
|
}): ReactNode {
|
|
45
49
|
const gradPrefix = useId();
|
|
46
|
-
if (entries.length === 0) return <div className="h-9" aria-hidden />;
|
|
50
|
+
if (entries.length === 0) return <div className={dense ? "h-3" : "h-9"} aria-hidden />;
|
|
47
51
|
|
|
48
|
-
const chartHeight = 36;
|
|
49
|
-
const tickHeight = 1;
|
|
52
|
+
const chartHeight = dense ? 12 : 36;
|
|
53
|
+
const tickHeight = dense ? 0 : 1;
|
|
54
|
+
const barWidth = dense ? 3 : 1;
|
|
50
55
|
const barGap = 1;
|
|
51
56
|
const lastIdx = entries.length - 1;
|
|
57
|
+
const totalWidth = entries.length * (barWidth + barGap);
|
|
52
58
|
|
|
53
59
|
return (
|
|
54
60
|
<div data-testid={testId}>
|
|
55
61
|
<svg
|
|
56
|
-
viewBox={`0 0 ${
|
|
57
|
-
preserveAspectRatio="none"
|
|
58
|
-
|
|
62
|
+
viewBox={`0 0 ${totalWidth} ${chartHeight}`}
|
|
63
|
+
preserveAspectRatio={dense ? undefined : "none"}
|
|
64
|
+
width={dense ? totalWidth : undefined}
|
|
65
|
+
height={dense ? chartHeight : undefined}
|
|
66
|
+
className={dense ? "block" : "block h-9 w-full"}
|
|
59
67
|
role="img"
|
|
60
68
|
aria-label={ariaLabel}
|
|
61
69
|
>
|
|
62
70
|
<title>{ariaLabel}</title>
|
|
63
71
|
{entries.map((entry, idx) => {
|
|
64
|
-
const x = idx * (
|
|
72
|
+
const x = idx * (barWidth + barGap);
|
|
65
73
|
const level = Math.max(0, Math.min(1, entry.level));
|
|
66
74
|
const barHeight = (chartHeight - tickHeight) * level;
|
|
67
75
|
const barY = chartHeight - barHeight;
|
|
@@ -70,33 +78,44 @@ export function StatusBarChart({
|
|
|
70
78
|
const gradId = `${gradPrefix}-${idx}`;
|
|
71
79
|
return (
|
|
72
80
|
<g key={entry.key}>
|
|
73
|
-
|
|
74
|
-
<
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
{!dense && (
|
|
82
|
+
<defs>
|
|
83
|
+
<linearGradient id={gradId} x1="0" x2="0" y1="0" y2="1">
|
|
84
|
+
<stop offset="0%" stopColor={color} stopOpacity={isLast ? 0.85 : 0.5} />
|
|
85
|
+
<stop offset="100%" stopColor={color} stopOpacity={0.05} />
|
|
86
|
+
</linearGradient>
|
|
87
|
+
</defs>
|
|
88
|
+
)}
|
|
79
89
|
{isLast && (
|
|
80
90
|
<rect
|
|
81
|
-
x={x -
|
|
91
|
+
x={x - barGap / 2}
|
|
82
92
|
y={0}
|
|
83
|
-
width={
|
|
93
|
+
width={barWidth + barGap}
|
|
84
94
|
height={chartHeight}
|
|
85
95
|
fill="var(--color-foreground)"
|
|
86
96
|
fillOpacity={0.06}
|
|
87
97
|
/>
|
|
88
98
|
)}
|
|
89
|
-
<rect x={x} y={barY} width={1} height={barHeight} fill={`url(#${gradId})`}>
|
|
90
|
-
{entry.label !== undefined && <title>{entry.label}</title>}
|
|
91
|
-
</rect>
|
|
92
99
|
<rect
|
|
93
100
|
x={x}
|
|
94
|
-
y={barY
|
|
95
|
-
width={
|
|
96
|
-
height={
|
|
97
|
-
fill=
|
|
98
|
-
fillOpacity={isLast ? 1
|
|
99
|
-
|
|
101
|
+
y={barY}
|
|
102
|
+
width={barWidth}
|
|
103
|
+
height={barHeight}
|
|
104
|
+
fill={dense ? color : `url(#${gradId})`}
|
|
105
|
+
fillOpacity={dense ? (isLast ? 1 : 0.75) : undefined}
|
|
106
|
+
>
|
|
107
|
+
{entry.label !== undefined && <title>{entry.label}</title>}
|
|
108
|
+
</rect>
|
|
109
|
+
{!dense && (
|
|
110
|
+
<rect
|
|
111
|
+
x={x}
|
|
112
|
+
y={barY - tickHeight}
|
|
113
|
+
width={barWidth}
|
|
114
|
+
height={tickHeight}
|
|
115
|
+
fill="var(--color-foreground)"
|
|
116
|
+
fillOpacity={isLast ? 1.0 : 0.7}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
100
119
|
</g>
|
|
101
120
|
);
|
|
102
121
|
})}
|
package/src/widgets/drawer.tsx
CHANGED
|
@@ -4,14 +4,16 @@ import { type ReactNode, useRef, useState } from "react";
|
|
|
4
4
|
import { clamp } from "../lib/clamp";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
6
|
import { useIsNarrowViewport } from "../primitives/use-narrow-viewport";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
7
|
+
import { Sheet, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "../ui/sheet";
|
|
8
|
+
import { DrawerSheetContent } from "./sheet-parts";
|
|
9
|
+
|
|
10
|
+
// Mirrors primitives/index.tsx's cardFooter + cardFooterBorder (row layout,
|
|
11
|
+
// end-justified actions, top border, panel surface instead of the card
|
|
12
|
+
// footer's bg-muted/30) — those two consts aren't exported, so the
|
|
13
|
+
// equivalent classes are inlined here rather than growing the vendored
|
|
14
|
+
// SheetFooter's own default className with panel-specific styling.
|
|
15
|
+
const DRAWER_FOOTER_CLASS =
|
|
16
|
+
"flex-row items-center justify-end border-t bg-background px-[var(--card-padding)] py-4";
|
|
15
17
|
|
|
16
18
|
export type DrawerProps = {
|
|
17
19
|
readonly open: boolean;
|
|
@@ -161,7 +163,7 @@ export function Drawer({
|
|
|
161
163
|
|
|
162
164
|
return (
|
|
163
165
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
164
|
-
<
|
|
166
|
+
<DrawerSheetContent
|
|
165
167
|
side={side}
|
|
166
168
|
data-testid={testId}
|
|
167
169
|
overlayStyle={overlayStyle}
|
|
@@ -193,7 +195,9 @@ export function Drawer({
|
|
|
193
195
|
</SheetHeader>
|
|
194
196
|
)}
|
|
195
197
|
<div className="flex-1 overflow-y-auto px-4">{children}</div>
|
|
196
|
-
{footer !== undefined &&
|
|
198
|
+
{footer !== undefined && (
|
|
199
|
+
<SheetFooter className={DRAWER_FOOTER_CLASS}>{footer}</SheetFooter>
|
|
200
|
+
)}
|
|
197
201
|
{canResize && !narrow && (
|
|
198
202
|
// biome-ignore lint/a11y/useSemanticElements: <hr> can't carry pointer/keyboard drag interaction or a live width value — a draggable separator needs a div with the ARIA role.
|
|
199
203
|
<div
|
|
@@ -214,7 +218,7 @@ export function Drawer({
|
|
|
214
218
|
)}
|
|
215
219
|
/>
|
|
216
220
|
)}
|
|
217
|
-
</
|
|
221
|
+
</DrawerSheetContent>
|
|
218
222
|
</Sheet>
|
|
219
223
|
);
|
|
220
224
|
}
|
|
@@ -20,12 +20,6 @@ export type InfinityListProps<TData = unknown, TRow = Readonly<Record<string, un
|
|
|
20
20
|
/** Must derive from row content (e.g. `row.id`), not from `index` — a
|
|
21
21
|
* live refresh reorders rows (new/changed rows move to the front). */
|
|
22
22
|
readonly rowId: (row: TRow, index: number) => string;
|
|
23
|
-
/** Pass this when `rowId` depends on list position — the live-merge path
|
|
24
|
-
* needs a position-independent identity. Without it, an index-based
|
|
25
|
-
* `rowId` (legitimate when live-merge isn't used) can collide between
|
|
26
|
-
* the freshly-fetched first page's local indices and the accumulated
|
|
27
|
-
* list's indices, silently dropping an unrelated mid-list row on merge. */
|
|
28
|
-
readonly rowKey?: (row: TRow) => string;
|
|
29
23
|
readonly renderRow: (row: TRow) => ReactNode;
|
|
30
24
|
readonly emptyState?: ReactNode;
|
|
31
25
|
readonly className?: string;
|
|
@@ -55,7 +49,6 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
55
49
|
rows,
|
|
56
50
|
nextCursor,
|
|
57
51
|
rowId,
|
|
58
|
-
rowKey,
|
|
59
52
|
renderRow,
|
|
60
53
|
emptyState,
|
|
61
54
|
className,
|
|
@@ -80,16 +73,6 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
80
73
|
nextCursorRef.current = nextCursor;
|
|
81
74
|
const rowIdRef = useRef(rowId);
|
|
82
75
|
rowIdRef.current = rowId;
|
|
83
|
-
const rowKeyRef = useRef(rowKey);
|
|
84
|
-
rowKeyRef.current = rowKey;
|
|
85
|
-
// Position-independent when rowKey is supplied, otherwise falls back to
|
|
86
|
-
// the (possibly position-dependent) rowId — same identity function used
|
|
87
|
-
// for both sides of the live-merge diff below so a position-based rowId
|
|
88
|
-
// can no longer collide across the fresh page's local indices and the
|
|
89
|
-
// accumulated list's indices (fw#1829).
|
|
90
|
-
const identifyRef = useRef((row: TRow, index: number): string =>
|
|
91
|
-
rowKeyRef.current !== undefined ? rowKeyRef.current(row) : rowIdRef.current(row, index),
|
|
92
|
-
);
|
|
93
76
|
|
|
94
77
|
// Discards a response whose request was superseded by a newer one before
|
|
95
78
|
// it resolved (e.g. two searches fired in quick succession) — without
|
|
@@ -121,9 +104,8 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
121
104
|
if (cursor === null) {
|
|
122
105
|
// Same identity as refreshFirstPage's freshIds/staleRows diff below
|
|
123
106
|
// — this ref is what that diff's previousFirstPageIds reads, so it
|
|
124
|
-
// must agree on rowKey vs rowId or the two id spaces never overlap.
|
|
125
107
|
firstPageIdsRef.current = new Set(
|
|
126
|
-
nextRows.map((row, index) =>
|
|
108
|
+
nextRows.map((row, index) => rowIdRef.current(row, index)),
|
|
127
109
|
);
|
|
128
110
|
}
|
|
129
111
|
setState((prev) => {
|
|
@@ -174,7 +156,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
174
156
|
// skip: background live refresh failed, keep showing the current rows
|
|
175
157
|
if (!res.isSuccess) return;
|
|
176
158
|
const freshRows = rowsRef.current(res.data);
|
|
177
|
-
const freshIds = new Set(freshRows.map((row, index) =>
|
|
159
|
+
const freshIds = new Set(freshRows.map((row, index) => rowIdRef.current(row, index)));
|
|
178
160
|
// A row absent from freshIds is stale for one of two reasons: it was
|
|
179
161
|
// dropped from page 1 (deleted, or filtered out elsewhere) and must be
|
|
180
162
|
// pruned, or it belongs to an already-accumulated later page and must
|
|
@@ -194,7 +176,7 @@ export function InfinityList<TData = unknown, TRow = Readonly<Record<string, unk
|
|
|
194
176
|
return { kind: "ready", rows: freshRows, cursor: nextCursorRef.current(res.data) };
|
|
195
177
|
}
|
|
196
178
|
const staleRows = prev.rows.filter((row, index) => {
|
|
197
|
-
const id =
|
|
179
|
+
const id = rowIdRef.current(row, index);
|
|
198
180
|
return !freshIds.has(id) && !previousFirstPageIds.has(id);
|
|
199
181
|
});
|
|
200
182
|
return { kind: "ready", rows: [...freshRows, ...staleRows], cursor: prev.cursor };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { XIcon } from "lucide-react";
|
|
2
|
+
import { Dialog as SheetPrimitive } from "radix-ui";
|
|
3
|
+
import type { ComponentProps, CSSProperties, ReactNode } from "react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
|
|
6
|
+
// Drawer needs a per-instance overlay dim/blur (its `backdrop` prop), but
|
|
7
|
+
// ../ui/sheet.tsx is vendored shadcn (regenerated via scripts/sync-shadcn.ts,
|
|
8
|
+
// never hand-edited — #1965) and its SheetContent hard-codes a plain
|
|
9
|
+
// `<SheetOverlay />` with no way to reach the overlay's inline style. Rather
|
|
10
|
+
// than growing the vendored primitive an ad-hoc prop that a resync would
|
|
11
|
+
// silently drop, this composes the same Portal + Overlay + Content tree
|
|
12
|
+
// directly from the underlying radix-ui Dialog primitive (mirrors
|
|
13
|
+
// SheetContent's className logic 1:1 so Drawer's other side-variant
|
|
14
|
+
// behavior — slide animation, borders — stays identical).
|
|
15
|
+
export type DrawerSheetContentProps = ComponentProps<typeof SheetPrimitive.Content> & {
|
|
16
|
+
readonly side?: "top" | "right" | "bottom" | "left";
|
|
17
|
+
readonly showCloseButton?: boolean;
|
|
18
|
+
readonly overlayStyle?: CSSProperties;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function DrawerSheetContent({
|
|
22
|
+
className,
|
|
23
|
+
overlayStyle,
|
|
24
|
+
children,
|
|
25
|
+
side = "right",
|
|
26
|
+
showCloseButton = true,
|
|
27
|
+
...props
|
|
28
|
+
}: DrawerSheetContentProps): ReactNode {
|
|
29
|
+
return (
|
|
30
|
+
<SheetPrimitive.Portal data-slot="sheet-portal">
|
|
31
|
+
<SheetPrimitive.Overlay
|
|
32
|
+
data-slot="sheet-overlay"
|
|
33
|
+
style={overlayStyle}
|
|
34
|
+
className="fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
|
35
|
+
/>
|
|
36
|
+
<SheetPrimitive.Content
|
|
37
|
+
data-slot="sheet-content"
|
|
38
|
+
className={cn(
|
|
39
|
+
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
|
40
|
+
side === "right" &&
|
|
41
|
+
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
|
42
|
+
side === "left" &&
|
|
43
|
+
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
|
44
|
+
side === "top" &&
|
|
45
|
+
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
|
46
|
+
side === "bottom" &&
|
|
47
|
+
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
{showCloseButton && (
|
|
54
|
+
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
|
55
|
+
<XIcon className="size-4" />
|
|
56
|
+
<span className="sr-only">Close</span>
|
|
57
|
+
</SheetPrimitive.Close>
|
|
58
|
+
)}
|
|
59
|
+
</SheetPrimitive.Content>
|
|
60
|
+
</SheetPrimitive.Portal>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -92,9 +92,10 @@ export function UploadZone({
|
|
|
92
92
|
|
|
93
93
|
async function uploadOne(file: File): Promise<void> {
|
|
94
94
|
const rowId = String(nextRowId.current++);
|
|
95
|
-
|
|
95
|
+
const uploadFile = await resizeImageBeforeUpload(file);
|
|
96
|
+
setRows((prev) => [...prev, { id: rowId, fileName: uploadFile.name, status: "uploading" }]);
|
|
96
97
|
try {
|
|
97
|
-
await onUpload(
|
|
98
|
+
await onUpload(uploadFile);
|
|
98
99
|
setRows((prev) => prev.map((row) => (row.id === rowId ? { ...row, status: "done" } : row)));
|
|
99
100
|
} catch (cause) {
|
|
100
101
|
const message = cause instanceof Error ? cause.message : t("kumiko.widget.upload.error");
|
|
@@ -107,6 +108,8 @@ export function UploadZone({
|
|
|
107
108
|
async function uploadFiles(files: FileList | null): Promise<void> {
|
|
108
109
|
if (files === null || files.length === 0) return;
|
|
109
110
|
const picked = multiple ? Array.from(files) : files[0] !== undefined ? [files[0]] : [];
|
|
111
|
+
// Reset before awaits so re-picking the same file during a slow upload still fires change.
|
|
112
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
110
113
|
const [accepted, rejected] = partitionByAccept(picked, accept);
|
|
111
114
|
for (const file of rejected) {
|
|
112
115
|
setRows((prev) => [
|
|
@@ -120,9 +123,6 @@ export function UploadZone({
|
|
|
120
123
|
]);
|
|
121
124
|
}
|
|
122
125
|
await Promise.all(accepted.map((file) => uploadOne(file)));
|
|
123
|
-
// Reset so re-picking the SAME file still fires change — the browser
|
|
124
|
-
// suppresses the event otherwise.
|
|
125
|
-
if (inputRef.current) inputRef.current.value = "";
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
function handleDrop(e: DragEvent<HTMLLabelElement>): void {
|