@cosmicdrift/kumiko-renderer-web 0.188.0 → 0.190.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 +4 -4
- package/src/__tests__/entity-edit-redirect.test.tsx +166 -0
- package/src/__tests__/kumiko-screen.test.tsx +144 -0
- package/src/__tests__/primitives.test.tsx +23 -0
- package/src/__tests__/render-edit.test.tsx +137 -0
- package/src/primitives/index.tsx +28 -1
- package/src/primitives/located-timestamp-input.tsx +1 -21
- package/src/primitives/tz-input.tsx +43 -0
- package/src/primitives/tz-options.ts +20 -0
- package/src/ui/sheet.tsx +10 -2
- package/src/widgets/__tests__/widgets.test.tsx +11 -0
- package/src/widgets/drawer.tsx +118 -2
- package/src/widgets/progress-bar.tsx +5 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.190.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.190.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.190.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.190.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",
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Issue #1942: entityEdit had no post-save redirect (only actionForm did) —
|
|
2
|
+
// RenderEdit only exposed onSubmit/onCancel, unreachable from a declarative
|
|
3
|
+
// screen. This exercises the real create/update submit path through
|
|
4
|
+
// KumikoScreen → EntityEditCreateBody/EntityEditUpdateForm, not just the
|
|
5
|
+
// type/boot-validator layer, proving a successful save actually calls
|
|
6
|
+
// nav.navigate(redirect) instead of the default "back to list", and that
|
|
7
|
+
// omitting redirect keeps the existing list-navigation behavior.
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type {
|
|
10
|
+
EntityDefinition,
|
|
11
|
+
FeatureSchema,
|
|
12
|
+
ScreenDefinition,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
14
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
15
|
+
import type { NavTarget } from "@cosmicdrift/kumiko-renderer";
|
|
16
|
+
import { DispatcherProvider, KumikoScreen, NavProvider } from "@cosmicdrift/kumiko-renderer";
|
|
17
|
+
import { createMockDispatcher, fireEvent, render, screen, waitFor } from "./test-utils";
|
|
18
|
+
|
|
19
|
+
const productEntity: EntityDefinition = {
|
|
20
|
+
fields: { name: { type: "text", required: false, searchable: false, sortable: false } },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function buildSchema(redirect?: string): FeatureSchema {
|
|
24
|
+
const editScreen: ScreenDefinition = {
|
|
25
|
+
id: "product-edit",
|
|
26
|
+
type: "entityEdit",
|
|
27
|
+
entity: "product",
|
|
28
|
+
layout: { sections: [{ fields: ["name"] }] },
|
|
29
|
+
...(redirect !== undefined && { redirect }),
|
|
30
|
+
};
|
|
31
|
+
const listScreen: ScreenDefinition = {
|
|
32
|
+
id: "product-list",
|
|
33
|
+
type: "entityList",
|
|
34
|
+
entity: "product",
|
|
35
|
+
columns: ["name"],
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
featureName: "shop",
|
|
39
|
+
entities: { product: productEntity },
|
|
40
|
+
screens: [editScreen, listScreen],
|
|
41
|
+
} as FeatureSchema;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fillNameAndSubmit(): void {
|
|
45
|
+
const nameInput = screen.getByTestId("field-name").querySelector("input") as HTMLInputElement;
|
|
46
|
+
fireEvent.change(nameInput, { target: { value: "Widget" } });
|
|
47
|
+
const form = screen.getByTestId("render-edit-form");
|
|
48
|
+
fireEvent.submit(form);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("entityEdit redirect (#1942)", () => {
|
|
52
|
+
test("create: successful save with redirect set navigates there, not to the list", async () => {
|
|
53
|
+
const navigated: NavTarget[] = [];
|
|
54
|
+
render(
|
|
55
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
56
|
+
<NavProvider
|
|
57
|
+
value={{
|
|
58
|
+
route: { screenId: "shop:screen:product-edit" },
|
|
59
|
+
navigate: (target) => navigated.push(target),
|
|
60
|
+
replace: () => {},
|
|
61
|
+
hrefFor: () => "",
|
|
62
|
+
searchParams: {},
|
|
63
|
+
setSearchParams: () => {},
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<KumikoScreen schema={buildSchema("product-detail")} qn="shop:screen:product-edit" />
|
|
67
|
+
</NavProvider>
|
|
68
|
+
</DispatcherProvider>,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
fillNameAndSubmit();
|
|
72
|
+
|
|
73
|
+
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("create: no redirect set falls back to the entity's list screen", async () => {
|
|
77
|
+
const navigated: NavTarget[] = [];
|
|
78
|
+
render(
|
|
79
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
80
|
+
<NavProvider
|
|
81
|
+
value={{
|
|
82
|
+
route: { screenId: "shop:screen:product-edit" },
|
|
83
|
+
navigate: (target) => navigated.push(target),
|
|
84
|
+
replace: () => {},
|
|
85
|
+
hrefFor: () => "",
|
|
86
|
+
searchParams: {},
|
|
87
|
+
setSearchParams: () => {},
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<KumikoScreen schema={buildSchema()} qn="shop:screen:product-edit" />
|
|
91
|
+
</NavProvider>
|
|
92
|
+
</DispatcherProvider>,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
fillNameAndSubmit();
|
|
96
|
+
|
|
97
|
+
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-list" }]));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("create: redirect as fully-qualified cross-feature QN navigates via its short id (#1946)", async () => {
|
|
101
|
+
// redirect may name a screen in ANOTHER feature via `<feature>:screen:<id>`
|
|
102
|
+
// (boot-validator accepts the QN directly) — the renderer must strip it
|
|
103
|
+
// down to the short id before calling nav.navigate, since the runtime
|
|
104
|
+
// router resolves bare short ids app-wide, not raw QNs (see nav.tsx).
|
|
105
|
+
const navigated: NavTarget[] = [];
|
|
106
|
+
render(
|
|
107
|
+
<DispatcherProvider dispatcher={createMockDispatcher()}>
|
|
108
|
+
<NavProvider
|
|
109
|
+
value={{
|
|
110
|
+
route: { screenId: "shop:screen:product-edit" },
|
|
111
|
+
navigate: (target) => navigated.push(target),
|
|
112
|
+
replace: () => {},
|
|
113
|
+
hrefFor: () => "",
|
|
114
|
+
searchParams: {},
|
|
115
|
+
setSearchParams: () => {},
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
<KumikoScreen
|
|
119
|
+
schema={buildSchema("statements:screen:statement-upload-list")}
|
|
120
|
+
qn="shop:screen:product-edit"
|
|
121
|
+
/>
|
|
122
|
+
</NavProvider>
|
|
123
|
+
</DispatcherProvider>,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
fillNameAndSubmit();
|
|
127
|
+
|
|
128
|
+
await waitFor(() => expect(navigated).toEqual([{ screenId: "statement-upload-list" }]));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("update: successful save with redirect set navigates there, not to the list", async () => {
|
|
132
|
+
const navigated: NavTarget[] = [];
|
|
133
|
+
const dispatcher = createMockDispatcher({
|
|
134
|
+
query: (async () => ({
|
|
135
|
+
isSuccess: true,
|
|
136
|
+
data: { id: "42", version: 1, name: "Existing" },
|
|
137
|
+
})) as unknown as Dispatcher["query"],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
render(
|
|
141
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
142
|
+
<NavProvider
|
|
143
|
+
value={{
|
|
144
|
+
route: { screenId: "shop:screen:product-edit", entityId: "42" },
|
|
145
|
+
navigate: (target) => navigated.push(target),
|
|
146
|
+
replace: () => {},
|
|
147
|
+
hrefFor: () => "",
|
|
148
|
+
searchParams: {},
|
|
149
|
+
setSearchParams: () => {},
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
<KumikoScreen
|
|
153
|
+
schema={buildSchema("product-detail")}
|
|
154
|
+
qn="shop:screen:product-edit"
|
|
155
|
+
entityId="42"
|
|
156
|
+
/>
|
|
157
|
+
</NavProvider>
|
|
158
|
+
</DispatcherProvider>,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
await waitFor(() => expect(screen.getByTestId("field-name")).toBeTruthy());
|
|
162
|
+
fillNameAndSubmit();
|
|
163
|
+
|
|
164
|
+
await waitFor(() => expect(navigated).toEqual([{ screenId: "product-detail" }]));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -1404,6 +1404,50 @@ describe("KumikoScreen", () => {
|
|
|
1404
1404
|
expect(navigateCalls[0]).toEqual({ screenId: "task-list" });
|
|
1405
1405
|
});
|
|
1406
1406
|
|
|
1407
|
+
test("actionForm mit Cross-Feature-QN als redirect: navigiert per Short-Id (#1946)", async () => {
|
|
1408
|
+
const navigateCalls: { screenId: string }[] = [];
|
|
1409
|
+
const dispatcher = makeDispatcher({
|
|
1410
|
+
write: (async () => ({
|
|
1411
|
+
isSuccess: true,
|
|
1412
|
+
data: { id: "x" },
|
|
1413
|
+
})) as unknown as Dispatcher["write"],
|
|
1414
|
+
});
|
|
1415
|
+
const memoryNav = {
|
|
1416
|
+
route: { screenId: "quick-add" },
|
|
1417
|
+
navigate: (target: { screenId: string }) => navigateCalls.push(target),
|
|
1418
|
+
replace: () => undefined,
|
|
1419
|
+
hrefFor: (t: { screenId: string }) => `/${t.screenId}`,
|
|
1420
|
+
searchParams: {},
|
|
1421
|
+
setSearchParams: () => undefined,
|
|
1422
|
+
};
|
|
1423
|
+
const actionScreen: ActionFormScreenDefinition = {
|
|
1424
|
+
id: "quick-add",
|
|
1425
|
+
type: "actionForm",
|
|
1426
|
+
handler: "tasks:write:task:quick-add",
|
|
1427
|
+
fields: { title: { type: "text", required: true } },
|
|
1428
|
+
layout: { sections: [{ title: "x", fields: ["title"] }] },
|
|
1429
|
+
redirect: "statements:screen:statement-upload-list",
|
|
1430
|
+
};
|
|
1431
|
+
|
|
1432
|
+
const { NavProvider } = await import("@cosmicdrift/kumiko-renderer");
|
|
1433
|
+
render(
|
|
1434
|
+
<NavProvider value={memoryNav}>
|
|
1435
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1436
|
+
<KumikoScreen
|
|
1437
|
+
schema={{ ...schema, screens: [actionScreen, listScreen] }}
|
|
1438
|
+
qn="tasks:screen:quick-add"
|
|
1439
|
+
/>
|
|
1440
|
+
</DispatcherProvider>
|
|
1441
|
+
</NavProvider>,
|
|
1442
|
+
);
|
|
1443
|
+
|
|
1444
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1445
|
+
fireEvent.change(titleInput, { target: { value: "go" } });
|
|
1446
|
+
fireEvent.click(screen.getByTestId("render-edit-submit"));
|
|
1447
|
+
await waitFor(() => expect(navigateCalls.length).toBe(1));
|
|
1448
|
+
expect(navigateCalls[0]).toEqual({ screenId: "statement-upload-list" });
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1407
1451
|
// cancelTarget (Bug-Bash 2026-06-07, Bug 9): redirect erzeugte
|
|
1408
1452
|
// automatisch einen Abbrechen-Button mit demselben Ziel wie der
|
|
1409
1453
|
// Submit-Redirect — auf Single-Action-Screens ("Test-Mail senden")
|
|
@@ -1814,6 +1858,106 @@ describe("KumikoScreen: update-only entityEdit (allowCreate/allowDelete)", () =>
|
|
|
1814
1858
|
});
|
|
1815
1859
|
});
|
|
1816
1860
|
|
|
1861
|
+
// --- singleton entityEdit (fw#1941) ---
|
|
1862
|
+
// Singleton entities (exactly one record per tenant) have no nav path
|
|
1863
|
+
// that supplies an entityId. `singleton: true` resolves the existing
|
|
1864
|
+
// record via list(limit:1) before deciding create vs update.
|
|
1865
|
+
describe("KumikoScreen: singleton entityEdit", () => {
|
|
1866
|
+
const singletonEdit: EntityEditScreenDefinition = {
|
|
1867
|
+
id: "task-edit",
|
|
1868
|
+
type: "entityEdit",
|
|
1869
|
+
entity: "task",
|
|
1870
|
+
singleton: true,
|
|
1871
|
+
layout: { sections: [{ title: "Basics", fields: ["title"] }] },
|
|
1872
|
+
};
|
|
1873
|
+
const singletonSchema: FeatureSchema = {
|
|
1874
|
+
featureName: "tasks",
|
|
1875
|
+
entities: { task: taskEntity },
|
|
1876
|
+
screens: [singletonEdit],
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
test("kein vorhandener Record → rendert leeres Create-Form", async () => {
|
|
1880
|
+
const queryCalls: { type: string; payload: unknown }[] = [];
|
|
1881
|
+
const dispatcher = makeDispatcher({
|
|
1882
|
+
query: (async (type: string, payload: unknown) => {
|
|
1883
|
+
queryCalls.push({ type, payload });
|
|
1884
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
1885
|
+
}) as unknown as Dispatcher["query"],
|
|
1886
|
+
});
|
|
1887
|
+
render(
|
|
1888
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1889
|
+
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
1890
|
+
</DispatcherProvider>,
|
|
1891
|
+
);
|
|
1892
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
1893
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
1894
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1895
|
+
expect(titleInput.value).toBe("");
|
|
1896
|
+
expect(queryCalls).toEqual([{ type: "tasks:query:task:list", payload: { limit: 1 } }]);
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
test("vorhandener Record → lädt ihn (Update-Form, prefilled) statt Create", async () => {
|
|
1900
|
+
const queryCalls: { type: string; payload: unknown }[] = [];
|
|
1901
|
+
const dispatcher = makeDispatcher({
|
|
1902
|
+
query: (async (type: string, payload: unknown) => {
|
|
1903
|
+
queryCalls.push({ type, payload });
|
|
1904
|
+
if (type.endsWith(":list")) {
|
|
1905
|
+
return {
|
|
1906
|
+
isSuccess: true,
|
|
1907
|
+
data: { rows: [{ id: "task-1", title: "loaded-title" }], nextCursor: null },
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
return {
|
|
1911
|
+
isSuccess: true,
|
|
1912
|
+
data: { id: "task-1", version: 1, title: "loaded-title", count: 0, done: false },
|
|
1913
|
+
};
|
|
1914
|
+
}) as unknown as Dispatcher["query"],
|
|
1915
|
+
});
|
|
1916
|
+
render(
|
|
1917
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1918
|
+
<KumikoScreen schema={singletonSchema} qn="tasks:screen:task-edit" />
|
|
1919
|
+
</DispatcherProvider>,
|
|
1920
|
+
);
|
|
1921
|
+
// Two sequential loading cycles (list, then detail once the singleton
|
|
1922
|
+
// wrapper hands off to EntityEditUpdateBody) — default waitFor timeout
|
|
1923
|
+
// can be too tight under CI's shared-process test load.
|
|
1924
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull(), {
|
|
1925
|
+
timeout: 5000,
|
|
1926
|
+
});
|
|
1927
|
+
expect(screen.getByTestId("render-edit-form")).toBeTruthy();
|
|
1928
|
+
const titleInput = screen.getByTestId("field-title").querySelector("input") as HTMLInputElement;
|
|
1929
|
+
expect(titleInput.value).toBe("loaded-title");
|
|
1930
|
+
// Anchor: the id resolved from list(limit:1) reaches the detail query —
|
|
1931
|
+
// otherwise the form would render empty/create-mode instead of loading it.
|
|
1932
|
+
expect(queryCalls).toContainEqual({
|
|
1933
|
+
type: "tasks:query:task:detail",
|
|
1934
|
+
payload: { id: "task-1" },
|
|
1935
|
+
});
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
test("allowCreate:false + leere Tabelle → Fehler-Banner statt Create-Form", async () => {
|
|
1939
|
+
const disabledSchema: FeatureSchema = {
|
|
1940
|
+
featureName: "tasks",
|
|
1941
|
+
entities: { task: taskEntity },
|
|
1942
|
+
screens: [{ ...singletonEdit, allowCreate: false }],
|
|
1943
|
+
};
|
|
1944
|
+
const dispatcher = makeDispatcher({
|
|
1945
|
+
query: (async () => ({
|
|
1946
|
+
isSuccess: true,
|
|
1947
|
+
data: { rows: [], nextCursor: null },
|
|
1948
|
+
})) as unknown as Dispatcher["query"],
|
|
1949
|
+
});
|
|
1950
|
+
render(
|
|
1951
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1952
|
+
<KumikoScreen schema={disabledSchema} qn="tasks:screen:task-edit" />
|
|
1953
|
+
</DispatcherProvider>,
|
|
1954
|
+
);
|
|
1955
|
+
await waitFor(() => expect(screen.queryByTestId("kumiko-screen-loading")).toBeNull());
|
|
1956
|
+
expect(screen.getByTestId("kumiko-screen-create-disabled")).toBeTruthy();
|
|
1957
|
+
expect(screen.queryByTestId("render-edit-form")).toBeNull();
|
|
1958
|
+
});
|
|
1959
|
+
});
|
|
1960
|
+
|
|
1817
1961
|
// --- actionForm extension-section (Wave J: Incident-Update-Timeline) ---
|
|
1818
1962
|
// actionForm hat keinen record — Extension-Sections bekommen stattdessen
|
|
1819
1963
|
// die initialen Form-Values (inkl. searchParams-Prefill) als initialValues.
|
|
@@ -1040,6 +1040,29 @@ describe("Form", () => {
|
|
|
1040
1040
|
);
|
|
1041
1041
|
expect(screen.queryByTestId("form-actions")).toBeNull();
|
|
1042
1042
|
});
|
|
1043
|
+
|
|
1044
|
+
test("stickyActions: Footer bekommt mobile-fixed-Klassen (fw#1918)", () => {
|
|
1045
|
+
render(
|
|
1046
|
+
<Form
|
|
1047
|
+
onSubmit={() => undefined}
|
|
1048
|
+
actions={<button type="submit">Next</button>}
|
|
1049
|
+
testId="form"
|
|
1050
|
+
stickyActions
|
|
1051
|
+
>
|
|
1052
|
+
<div>content</div>
|
|
1053
|
+
</Form>,
|
|
1054
|
+
);
|
|
1055
|
+
expect(screen.getByTestId("form-actions").className).toContain("max-sm:fixed");
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test("ohne stickyActions: Footer bleibt im normalen Dokumentfluss", () => {
|
|
1059
|
+
render(
|
|
1060
|
+
<Form onSubmit={() => undefined} actions={<button type="submit">Save</button>} testId="form">
|
|
1061
|
+
<div>content</div>
|
|
1062
|
+
</Form>,
|
|
1063
|
+
);
|
|
1064
|
+
expect(screen.getByTestId("form-actions").className).not.toContain("max-sm:fixed");
|
|
1065
|
+
});
|
|
1043
1066
|
});
|
|
1044
1067
|
|
|
1045
1068
|
describe("Banner padded", () => {
|
|
@@ -1303,6 +1303,143 @@ describe("RenderEdit wizard draft", () => {
|
|
|
1303
1303
|
expect(screen.getByTestId("field-count")).toBeTruthy();
|
|
1304
1304
|
expect(calls.some((c) => c.startsWith("form-draft:"))).toBe(false);
|
|
1305
1305
|
});
|
|
1306
|
+
|
|
1307
|
+
// Issue #1914: patch() (controlled mode / extension sections) previously
|
|
1308
|
+
// never triggered a draft save — only handleWizardNext/Back did. A patch()
|
|
1309
|
+
// on the last step (no further Next click coming) or on a step abandoned
|
|
1310
|
+
// without Next/Back was silently lost, forcing e.g. a repeat of a paid
|
|
1311
|
+
// VIN-decode round-trip on resume (#1908).
|
|
1312
|
+
test("controls.patch() on the last step persists into the draft blob (debounced)", async () => {
|
|
1313
|
+
const { dispatcher, store } = makeDraftDispatcher();
|
|
1314
|
+
let controls: RenderEditControls<TestValues> | undefined;
|
|
1315
|
+
|
|
1316
|
+
render(
|
|
1317
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1318
|
+
<RenderEdit<TestValues>
|
|
1319
|
+
screen={makeDraftWizardScreen(true)}
|
|
1320
|
+
entity={orderEntity}
|
|
1321
|
+
featureName="orders"
|
|
1322
|
+
initial={{ title: "", count: 0 }}
|
|
1323
|
+
writeCommand="order:create"
|
|
1324
|
+
onControlsReady={(c) => {
|
|
1325
|
+
controls = c;
|
|
1326
|
+
}}
|
|
1327
|
+
/>
|
|
1328
|
+
</DispatcherProvider>,
|
|
1329
|
+
);
|
|
1330
|
+
|
|
1331
|
+
// Reach the last step first (handleWizardNext's own saveDraft mints the
|
|
1332
|
+
// draftId) — the bug is specifically about a patch() AFTER that, with no
|
|
1333
|
+
// further Next/Back to piggyback a save on.
|
|
1334
|
+
fireEvent.click(screen.getByTestId("render-edit-wizard-next"));
|
|
1335
|
+
expect(screen.getByTestId("field-count")).toBeTruthy();
|
|
1336
|
+
|
|
1337
|
+
act(() => {
|
|
1338
|
+
controls?.patch({ count: 7 });
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
await waitFor(
|
|
1342
|
+
() => {
|
|
1343
|
+
const values = store.current?.values as { count?: number } | undefined;
|
|
1344
|
+
expect(values?.count).toBe(7);
|
|
1345
|
+
},
|
|
1346
|
+
{ timeout: 3000 },
|
|
1347
|
+
);
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test("remount after patch() without a Next/Back click shows the patched value", async () => {
|
|
1351
|
+
const { dispatcher, store } = makeDraftDispatcher();
|
|
1352
|
+
const draftStorage = createFakeDraftStorage();
|
|
1353
|
+
let controls: RenderEditControls<TestValues> | undefined;
|
|
1354
|
+
|
|
1355
|
+
const first = render(
|
|
1356
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1357
|
+
<DraftStorageProvider value={draftStorage}>
|
|
1358
|
+
<RenderEdit<TestValues>
|
|
1359
|
+
screen={makeDraftWizardScreen(true)}
|
|
1360
|
+
entity={orderEntity}
|
|
1361
|
+
featureName="orders"
|
|
1362
|
+
initial={{ title: "", count: 0 }}
|
|
1363
|
+
writeCommand="order:create"
|
|
1364
|
+
onControlsReady={(c) => {
|
|
1365
|
+
controls = c;
|
|
1366
|
+
}}
|
|
1367
|
+
/>
|
|
1368
|
+
</DraftStorageProvider>
|
|
1369
|
+
</DispatcherProvider>,
|
|
1370
|
+
);
|
|
1371
|
+
|
|
1372
|
+
// No Next/Back click anywhere in this test — patch() alone, on the
|
|
1373
|
+
// first step, must still mint a draftId and persist (proves there is
|
|
1374
|
+
// no second, blob-bypassing write path for patch()'d data either: this
|
|
1375
|
+
// writes exclusively via patch(), the remount below reads exclusively
|
|
1376
|
+
// via form-draft:query:get).
|
|
1377
|
+
act(() => {
|
|
1378
|
+
controls?.patch({ title: "Patched" });
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
await waitFor(() => expect(store.current).not.toBeNull(), { timeout: 3000 });
|
|
1382
|
+
|
|
1383
|
+
first.unmount();
|
|
1384
|
+
|
|
1385
|
+
render(
|
|
1386
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1387
|
+
<DraftStorageProvider value={draftStorage}>
|
|
1388
|
+
<RenderEdit<TestValues>
|
|
1389
|
+
screen={makeDraftWizardScreen(true)}
|
|
1390
|
+
entity={orderEntity}
|
|
1391
|
+
featureName="orders"
|
|
1392
|
+
initial={{ title: "", count: 0 }}
|
|
1393
|
+
writeCommand="order:create"
|
|
1394
|
+
/>
|
|
1395
|
+
</DraftStorageProvider>
|
|
1396
|
+
</DispatcherProvider>,
|
|
1397
|
+
);
|
|
1398
|
+
|
|
1399
|
+
await waitFor(
|
|
1400
|
+
() => {
|
|
1401
|
+
const titleInput = screen
|
|
1402
|
+
.getByTestId("field-title")
|
|
1403
|
+
.querySelector("input") as HTMLInputElement;
|
|
1404
|
+
expect(titleInput.value).toBe("Patched");
|
|
1405
|
+
},
|
|
1406
|
+
{ timeout: 3000 },
|
|
1407
|
+
);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
test("a burst of patch() calls collapses into a single debounced draft save", async () => {
|
|
1411
|
+
const { dispatcher, calls } = makeDraftDispatcher();
|
|
1412
|
+
let controls: RenderEditControls<TestValues> | undefined;
|
|
1413
|
+
|
|
1414
|
+
render(
|
|
1415
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
1416
|
+
<RenderEdit<TestValues>
|
|
1417
|
+
screen={makeDraftWizardScreen(true)}
|
|
1418
|
+
entity={orderEntity}
|
|
1419
|
+
featureName="orders"
|
|
1420
|
+
initial={{ title: "", count: 0 }}
|
|
1421
|
+
writeCommand="order:create"
|
|
1422
|
+
onControlsReady={(c) => {
|
|
1423
|
+
controls = c;
|
|
1424
|
+
}}
|
|
1425
|
+
/>
|
|
1426
|
+
</DispatcherProvider>,
|
|
1427
|
+
);
|
|
1428
|
+
|
|
1429
|
+
const savesSoFar = () => calls.filter((c) => c === "form-draft:write:save").length;
|
|
1430
|
+
const before = savesSoFar();
|
|
1431
|
+
|
|
1432
|
+
// Each patch() resets the same debounce timer — three patches in one
|
|
1433
|
+
// burst must still land as exactly one form-draft:write:save, not three.
|
|
1434
|
+
act(() => {
|
|
1435
|
+
controls?.patch({ count: 1 });
|
|
1436
|
+
controls?.patch({ count: 2 });
|
|
1437
|
+
controls?.patch({ count: 3 });
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
await waitFor(() => expect(savesSoFar()).toBe(before + 1), { timeout: 3000 });
|
|
1441
|
+
expect(savesSoFar()).toBe(before + 1);
|
|
1442
|
+
});
|
|
1306
1443
|
});
|
|
1307
1444
|
|
|
1308
1445
|
describe("RenderEdit create-mode draftId (issue #1913)", () => {
|
package/src/primitives/index.tsx
CHANGED
|
@@ -100,6 +100,7 @@ import { DefaultModal } from "./modal";
|
|
|
100
100
|
import { formatMoney, MoneyInput } from "./money-input";
|
|
101
101
|
import { TimestampInput } from "./timestamp-input";
|
|
102
102
|
import { useToast } from "./toast";
|
|
103
|
+
import { TzInput } from "./tz-input";
|
|
103
104
|
|
|
104
105
|
// ---- Card-Chrome (eine Definition für Form/Section/Card) ----
|
|
105
106
|
|
|
@@ -555,6 +556,18 @@ function DefaultInput(props: InputProps): ReactNode {
|
|
|
555
556
|
className="resize-y"
|
|
556
557
|
/>
|
|
557
558
|
);
|
|
559
|
+
case "tz":
|
|
560
|
+
return (
|
|
561
|
+
<TzInput
|
|
562
|
+
id={props.id}
|
|
563
|
+
name={props.name}
|
|
564
|
+
value={props.value}
|
|
565
|
+
onChange={props.onChange}
|
|
566
|
+
{...(props.disabled !== undefined && { disabled: props.disabled })}
|
|
567
|
+
{...(props.required !== undefined && { required: props.required })}
|
|
568
|
+
{...(props.hasError !== undefined && { hasError: props.hasError })}
|
|
569
|
+
/>
|
|
570
|
+
);
|
|
558
571
|
}
|
|
559
572
|
}
|
|
560
573
|
|
|
@@ -1558,6 +1571,7 @@ function DefaultForm({
|
|
|
1558
1571
|
actions,
|
|
1559
1572
|
testId,
|
|
1560
1573
|
width,
|
|
1574
|
+
stickyActions,
|
|
1561
1575
|
}: FormProps): ReactNode {
|
|
1562
1576
|
// Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
|
|
1563
1577
|
// der Container trägt Card/Titel selbst, sonst Card-in-Card.
|
|
@@ -1626,6 +1640,9 @@ function DefaultForm({
|
|
|
1626
1640
|
"[&>section:not(:first-child)]:border-t",
|
|
1627
1641
|
"[&>:not(section)]:px-6 [&>:not(section)]:py-3",
|
|
1628
1642
|
"[&>:not(section):first-child]:pt-6 [&>:not(section):last-child]:pb-6",
|
|
1643
|
+
// ponytail: fixed footer height is a guess (button row + safe-area) —
|
|
1644
|
+
// widen if a wizard step's last field ever renders visibly clipped.
|
|
1645
|
+
stickyActions === true && "max-sm:pb-24",
|
|
1629
1646
|
)}
|
|
1630
1647
|
>
|
|
1631
1648
|
<InsideFormContext.Provider value={true}>{children}</InsideFormContext.Provider>
|
|
@@ -1633,7 +1650,17 @@ function DefaultForm({
|
|
|
1633
1650
|
{actions !== undefined && (
|
|
1634
1651
|
<div
|
|
1635
1652
|
data-testid={testId !== undefined ? `${testId}-actions` : undefined}
|
|
1636
|
-
className={cn(
|
|
1653
|
+
className={cn(
|
|
1654
|
+
cardFooter,
|
|
1655
|
+
cardFooterBorder,
|
|
1656
|
+
// Below sm (640px): pin to the viewport bottom instead of normal
|
|
1657
|
+
// flow, so a virtual keyboard shrinking the viewport can't push
|
|
1658
|
+
// this out of reach (fw#1918). `fixed` escapes the card's
|
|
1659
|
+
// `overflow-hidden` (only transform/filter/contain ancestors trap
|
|
1660
|
+
// it, confirmed against AppLayout/SidebarInset — neither sets those).
|
|
1661
|
+
stickyActions === true &&
|
|
1662
|
+
"max-sm:fixed max-sm:inset-x-0 max-sm:bottom-0 max-sm:z-20 max-sm:bg-background max-sm:shadow-[0_-4px_12px_-4px_rgb(0_0_0_/_0.15)] max-sm:pb-[max(1rem,env(safe-area-inset-bottom))]",
|
|
1663
|
+
)}
|
|
1637
1664
|
>
|
|
1638
1665
|
{actions}
|
|
1639
1666
|
</div>
|
|
@@ -9,27 +9,7 @@ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
10
|
import { ComboboxInput } from "./combobox";
|
|
11
11
|
import { TimestampInput } from "./timestamp-input";
|
|
12
|
-
|
|
13
|
-
// Kuratierte Notliste falls die Runtime Intl.supportedValuesOf nicht kennt
|
|
14
|
-
// (vor ES2022). Reicht für die häufigsten Zonen; moderne Browser + Bun liefern
|
|
15
|
-
// die volle Liste.
|
|
16
|
-
const FALLBACK_ZONES: readonly string[] = [
|
|
17
|
-
"UTC",
|
|
18
|
-
"Europe/Berlin",
|
|
19
|
-
"Europe/London",
|
|
20
|
-
"Europe/Paris",
|
|
21
|
-
"Europe/Madrid",
|
|
22
|
-
"America/New_York",
|
|
23
|
-
"America/Los_Angeles",
|
|
24
|
-
"America/Sao_Paulo",
|
|
25
|
-
"Asia/Tokyo",
|
|
26
|
-
"Asia/Singapore",
|
|
27
|
-
"Australia/Sydney",
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
const TZ_OPTIONS: readonly { readonly value: string; readonly label: string }[] = (
|
|
31
|
-
typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : FALLBACK_ZONES
|
|
32
|
-
).map((zone) => ({ value: zone, label: zone }));
|
|
12
|
+
import { TZ_OPTIONS } from "./tz-options";
|
|
33
13
|
|
|
34
14
|
export type LocatedTimestampValue = {
|
|
35
15
|
readonly at: string;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// TzInput (kind:"tz") — standalone IANA-zone picker for `tz`-typed fields
|
|
2
|
+
// (#1925). Same searchable-combobox UX as the zone half of
|
|
3
|
+
// LocatedTimestampInput, shared TZ_OPTIONS source.
|
|
4
|
+
|
|
5
|
+
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { ComboboxInput } from "./combobox";
|
|
8
|
+
import { TZ_OPTIONS } from "./tz-options";
|
|
9
|
+
|
|
10
|
+
export type TzInputProps = {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly value: string;
|
|
14
|
+
readonly onChange: (v: string | undefined) => void;
|
|
15
|
+
readonly disabled?: boolean;
|
|
16
|
+
readonly required?: boolean;
|
|
17
|
+
readonly hasError?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function TzInput({
|
|
21
|
+
id,
|
|
22
|
+
name,
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
disabled,
|
|
26
|
+
required,
|
|
27
|
+
hasError,
|
|
28
|
+
}: TzInputProps): ReactNode {
|
|
29
|
+
const t = useTranslation();
|
|
30
|
+
return (
|
|
31
|
+
<ComboboxInput
|
|
32
|
+
id={id}
|
|
33
|
+
name={name}
|
|
34
|
+
options={TZ_OPTIONS}
|
|
35
|
+
value={value}
|
|
36
|
+
onChange={(v) => onChange(v === "" ? undefined : v)}
|
|
37
|
+
placeholder={t("kumiko.field.timezone")}
|
|
38
|
+
{...(disabled !== undefined && { disabled })}
|
|
39
|
+
{...(required !== undefined && { required })}
|
|
40
|
+
{...(hasError !== undefined && { hasError })}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Curated fallback list for runtimes without Intl.supportedValuesOf (pre
|
|
2
|
+
// ES2022). Covers the most common zones; modern browsers + Bun return the
|
|
3
|
+
// full IANA list.
|
|
4
|
+
const FALLBACK_ZONES: readonly string[] = [
|
|
5
|
+
"UTC",
|
|
6
|
+
"Europe/Berlin",
|
|
7
|
+
"Europe/London",
|
|
8
|
+
"Europe/Paris",
|
|
9
|
+
"Europe/Madrid",
|
|
10
|
+
"America/New_York",
|
|
11
|
+
"America/Los_Angeles",
|
|
12
|
+
"America/Sao_Paulo",
|
|
13
|
+
"Asia/Tokyo",
|
|
14
|
+
"Asia/Singapore",
|
|
15
|
+
"Australia/Sydney",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export const TZ_OPTIONS: readonly { readonly value: string; readonly label: string }[] = (
|
|
19
|
+
typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : FALLBACK_ZONES
|
|
20
|
+
).map((zone) => ({ value: zone, label: zone }));
|
package/src/ui/sheet.tsx
CHANGED
|
@@ -47,6 +47,7 @@ function SheetOverlay({
|
|
|
47
47
|
|
|
48
48
|
function SheetContent({
|
|
49
49
|
className,
|
|
50
|
+
overlayClassName,
|
|
50
51
|
children,
|
|
51
52
|
side = "right",
|
|
52
53
|
showCloseButton = true,
|
|
@@ -54,10 +55,11 @@ function SheetContent({
|
|
|
54
55
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
|
55
56
|
side?: "top" | "right" | "bottom" | "left"
|
|
56
57
|
showCloseButton?: boolean
|
|
58
|
+
overlayClassName?: string
|
|
57
59
|
}) {
|
|
58
60
|
return (
|
|
59
61
|
<SheetPortal>
|
|
60
|
-
<SheetOverlay />
|
|
62
|
+
<SheetOverlay className={overlayClassName} />
|
|
61
63
|
<SheetPrimitive.Content
|
|
62
64
|
data-slot="sheet-content"
|
|
63
65
|
className={cn(
|
|
@@ -100,7 +102,13 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
|
100
102
|
return (
|
|
101
103
|
<div
|
|
102
104
|
data-slot="sheet-footer"
|
|
103
|
-
|
|
105
|
+
// Matches the card footer convention (primitives/index.tsx cardFooter
|
|
106
|
+
// + cardFooterBorder) — same padding/border/button-row shape as
|
|
107
|
+
// SectionCard/DefaultCard so a drawer footer reads like a card footer.
|
|
108
|
+
className={cn(
|
|
109
|
+
"mt-auto flex items-center justify-end gap-2 border-t bg-muted/30 px-[var(--card-padding)] py-4",
|
|
110
|
+
className,
|
|
111
|
+
)}
|
|
104
112
|
{...props}
|
|
105
113
|
/>
|
|
106
114
|
)
|
|
@@ -42,6 +42,17 @@ describe("ProgressBar", () => {
|
|
|
42
42
|
render(<ProgressBar value={-3} testId="bar" />);
|
|
43
43
|
expect(screen.getByTestId("bar").getAttribute("aria-valuenow")).toBe("0");
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
test("Füll-Element bildet den Wert über Breite ab und erbt die Höhe nicht vom Elternteil", () => {
|
|
47
|
+
render(<ProgressBar value={0.5} testId="bar" />);
|
|
48
|
+
const bar = screen.getByTestId("bar");
|
|
49
|
+
const fill = bar.firstElementChild as HTMLElement;
|
|
50
|
+
expect(fill.style.width).toBe("50%");
|
|
51
|
+
expect(fill.className).not.toContain("h-full");
|
|
52
|
+
expect(fill.className).toContain("absolute");
|
|
53
|
+
expect(fill.className).toContain("inset-y-0");
|
|
54
|
+
expect(bar.className).toContain("relative");
|
|
55
|
+
});
|
|
45
56
|
});
|
|
46
57
|
|
|
47
58
|
describe("ModeSwitch", () => {
|
package/src/widgets/drawer.tsx
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useRef, useState } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
2
4
|
import {
|
|
3
5
|
Sheet,
|
|
4
6
|
SheetContent,
|
|
@@ -17,8 +19,40 @@ export type DrawerProps = {
|
|
|
17
19
|
readonly footer?: ReactNode;
|
|
18
20
|
readonly children: ReactNode;
|
|
19
21
|
readonly testId?: string;
|
|
22
|
+
/** Opt-in drag-to-resize + maximize toggle (left/right sides only). */
|
|
23
|
+
readonly resizable?: boolean;
|
|
24
|
+
readonly defaultWidthPx?: number;
|
|
25
|
+
readonly minWidthPx?: number;
|
|
26
|
+
readonly maxWidthPx?: number;
|
|
20
27
|
};
|
|
21
28
|
|
|
29
|
+
const DEFAULT_WIDTH_PX = 420;
|
|
30
|
+
const MIN_WIDTH_PX = 320;
|
|
31
|
+
const MAX_WIDTH_PX = 800;
|
|
32
|
+
|
|
33
|
+
// Floating panel with a clearly visible margin on every edge, rounded on
|
|
34
|
+
// all four corners — replaces the sheet primitive's flush-to-viewport-edge
|
|
35
|
+
// per-side classes. twMerge resolves each utility group against the base
|
|
36
|
+
// (inset/width/height/border/rounding), so this fully overrides rather than
|
|
37
|
+
// stacking with it. 32px margin + 32px radius so the detachment from the
|
|
38
|
+
// viewport edge reads clearly at a glance, not just on close 1:1 inspection.
|
|
39
|
+
function floatingSideClass(side: "left" | "right" | "top" | "bottom"): string {
|
|
40
|
+
switch (side) {
|
|
41
|
+
case "left":
|
|
42
|
+
return "inset-y-8 left-8 h-auto w-[420px] max-w-[85vw] sm:max-w-[420px] rounded-[2rem] border shadow-2xl";
|
|
43
|
+
case "top":
|
|
44
|
+
return "inset-x-8 top-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
45
|
+
case "bottom":
|
|
46
|
+
return "inset-x-8 bottom-8 h-auto max-h-[80vh] rounded-[2rem] border shadow-2xl";
|
|
47
|
+
default:
|
|
48
|
+
return "inset-y-8 right-8 h-auto w-[420px] max-w-[85vw] sm:max-w-[420px] rounded-[2rem] border shadow-2xl";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clamp(value: number, min: number, max: number): number {
|
|
53
|
+
return Math.min(Math.max(value, min), max);
|
|
54
|
+
}
|
|
55
|
+
|
|
22
56
|
/** Slide-in panel beside a list (e.g. mail reader next to the inbox) —
|
|
23
57
|
* thin wrapper over the Sheet primitive with header/body/footer slots
|
|
24
58
|
* so screens skip per-route Radix boilerplate. */
|
|
@@ -31,10 +65,72 @@ export function Drawer({
|
|
|
31
65
|
footer,
|
|
32
66
|
children,
|
|
33
67
|
testId,
|
|
68
|
+
resizable = false,
|
|
69
|
+
defaultWidthPx = DEFAULT_WIDTH_PX,
|
|
70
|
+
minWidthPx = MIN_WIDTH_PX,
|
|
71
|
+
maxWidthPx = MAX_WIDTH_PX,
|
|
34
72
|
}: DrawerProps): ReactNode {
|
|
73
|
+
const canResize = resizable && (side === "left" || side === "right");
|
|
74
|
+
const [width, setWidth] = useState(defaultWidthPx);
|
|
75
|
+
const [maximized, setMaximized] = useState(false);
|
|
76
|
+
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
77
|
+
|
|
78
|
+
const effectiveMaxWidthPx = () =>
|
|
79
|
+
typeof window === "undefined"
|
|
80
|
+
? maxWidthPx
|
|
81
|
+
: Math.min(maxWidthPx, Math.round(window.innerWidth * 0.9));
|
|
82
|
+
const effectiveWidthPx = maximized ? effectiveMaxWidthPx() : width;
|
|
83
|
+
|
|
84
|
+
const onHandlePointerDown = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
85
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
86
|
+
dragRef.current = { startX: event.clientX, startWidth: effectiveWidthPx };
|
|
87
|
+
setMaximized(false);
|
|
88
|
+
};
|
|
89
|
+
const onHandlePointerMove = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
90
|
+
if (dragRef.current === null) return;
|
|
91
|
+
const deltaX = event.clientX - dragRef.current.startX;
|
|
92
|
+
const signedDelta = side === "right" ? -deltaX : deltaX;
|
|
93
|
+
setWidth(clamp(dragRef.current.startWidth + signedDelta, minWidthPx, effectiveMaxWidthPx()));
|
|
94
|
+
};
|
|
95
|
+
const onHandlePointerUp = (event: React.PointerEvent<HTMLDivElement>): void => {
|
|
96
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
97
|
+
dragRef.current = null;
|
|
98
|
+
};
|
|
99
|
+
const onHandleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
100
|
+
const step = event.shiftKey ? 40 : 16;
|
|
101
|
+
const grow = side === "right" ? "ArrowLeft" : "ArrowRight";
|
|
102
|
+
const shrink = side === "right" ? "ArrowRight" : "ArrowLeft";
|
|
103
|
+
if (event.key !== grow && event.key !== shrink) return;
|
|
104
|
+
event.preventDefault();
|
|
105
|
+
setMaximized(false);
|
|
106
|
+
const delta = event.key === grow ? step : -step;
|
|
107
|
+
setWidth((current) => clamp(current + delta, minWidthPx, effectiveMaxWidthPx()));
|
|
108
|
+
};
|
|
109
|
+
|
|
35
110
|
return (
|
|
36
111
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
37
|
-
<SheetContent
|
|
112
|
+
<SheetContent
|
|
113
|
+
side={side}
|
|
114
|
+
data-testid={testId}
|
|
115
|
+
overlayClassName="bg-black/20 backdrop-blur-[2px]"
|
|
116
|
+
className={floatingSideClass(side)}
|
|
117
|
+
style={canResize ? { width: effectiveWidthPx, maxWidth: "none" } : undefined}
|
|
118
|
+
>
|
|
119
|
+
{canResize && (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={() => setMaximized((m) => !m)}
|
|
123
|
+
aria-pressed={maximized}
|
|
124
|
+
aria-label={maximized ? "Restore drawer width" : "Maximize drawer width"}
|
|
125
|
+
className="absolute top-4 right-14 z-10 rounded-xs p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden"
|
|
126
|
+
>
|
|
127
|
+
{maximized ? (
|
|
128
|
+
<Minimize2Icon className="size-4" />
|
|
129
|
+
) : (
|
|
130
|
+
<Maximize2Icon className="size-4" />
|
|
131
|
+
)}
|
|
132
|
+
</button>
|
|
133
|
+
)}
|
|
38
134
|
{(title !== undefined || description !== undefined) && (
|
|
39
135
|
<SheetHeader>
|
|
40
136
|
{title !== undefined && <SheetTitle>{title}</SheetTitle>}
|
|
@@ -43,6 +139,26 @@ export function Drawer({
|
|
|
43
139
|
)}
|
|
44
140
|
<div className="flex-1 overflow-y-auto px-4">{children}</div>
|
|
45
141
|
{footer !== undefined && <SheetFooter>{footer}</SheetFooter>}
|
|
142
|
+
{canResize && (
|
|
143
|
+
// 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.
|
|
144
|
+
<div
|
|
145
|
+
role="separator"
|
|
146
|
+
aria-orientation="vertical"
|
|
147
|
+
aria-label="Resize drawer"
|
|
148
|
+
aria-valuenow={effectiveWidthPx}
|
|
149
|
+
aria-valuemin={minWidthPx}
|
|
150
|
+
aria-valuemax={effectiveMaxWidthPx()}
|
|
151
|
+
tabIndex={0}
|
|
152
|
+
onPointerDown={onHandlePointerDown}
|
|
153
|
+
onPointerMove={onHandlePointerMove}
|
|
154
|
+
onPointerUp={onHandlePointerUp}
|
|
155
|
+
onKeyDown={onHandleKeyDown}
|
|
156
|
+
className={cn(
|
|
157
|
+
"absolute inset-y-0 z-10 w-1 cursor-col-resize touch-none after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-border",
|
|
158
|
+
side === "right" ? "left-0 -translate-x-1/2" : "right-0 translate-x-1/2",
|
|
159
|
+
)}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
46
162
|
</SheetContent>
|
|
47
163
|
</Sheet>
|
|
48
164
|
);
|
|
@@ -19,9 +19,12 @@ export function ProgressBar({
|
|
|
19
19
|
aria-valuenow={Math.round(pct * 100)}
|
|
20
20
|
aria-valuemin={0}
|
|
21
21
|
aria-valuemax={100}
|
|
22
|
-
className={cn("h-2 w-full overflow-hidden rounded-full bg-muted", className)}
|
|
22
|
+
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-muted", className)}
|
|
23
23
|
>
|
|
24
|
-
<div
|
|
24
|
+
<div
|
|
25
|
+
className="absolute inset-y-0 left-0 rounded-full bg-primary"
|
|
26
|
+
style={{ width: `${pct * 100}%` }}
|
|
27
|
+
/>
|
|
25
28
|
</div>
|
|
26
29
|
);
|
|
27
30
|
}
|