@cosmicdrift/kumiko-renderer-web 0.200.1 → 0.202.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__/create-app.test.tsx +5 -3
- package/src/__tests__/kumiko-screen.test.tsx +54 -30
- package/src/__tests__/render-edit.test.tsx +62 -0
- package/src/__tests__/test-utils.tsx +5 -3
- package/src/app/create-app.tsx +13 -4
- package/src/app/nav.tsx +23 -7
- package/src/widgets/__tests__/drawer.test.tsx +96 -1
- package/src/widgets/drawer.tsx +12 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.202.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.202.0",
|
|
20
|
+
"@cosmicdrift/kumiko-headless": "0.202.0",
|
|
21
|
+
"@cosmicdrift/kumiko-renderer": "0.202.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",
|
|
@@ -576,10 +576,12 @@ describe("createKumikoApp", () => {
|
|
|
576
576
|
route: { screenId: "task-list" },
|
|
577
577
|
navigate: () => {},
|
|
578
578
|
replace: () => {},
|
|
579
|
-
hrefFor: (target) =>
|
|
580
|
-
target
|
|
579
|
+
hrefFor: (target) => {
|
|
580
|
+
if (!("screenId" in target)) return "";
|
|
581
|
+
return target.entityId !== undefined
|
|
581
582
|
? `/${target.screenId}/${target.entityId}`
|
|
582
|
-
: `/${target.screenId}
|
|
583
|
+
: `/${target.screenId}`;
|
|
584
|
+
},
|
|
583
585
|
searchParams: {},
|
|
584
586
|
setSearchParams: () => {},
|
|
585
587
|
};
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
EntityListScreenDefinition,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
8
8
|
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
9
|
-
import type { FeatureSchema, NavApi } from "@cosmicdrift/kumiko-renderer";
|
|
9
|
+
import type { FeatureSchema, NavApi, NavTarget } from "@cosmicdrift/kumiko-renderer";
|
|
10
10
|
import {
|
|
11
11
|
DispatcherProvider,
|
|
12
12
|
ExtensionSectionsProvider,
|
|
@@ -812,12 +812,12 @@ describe("KumikoScreen", () => {
|
|
|
812
812
|
const searchParamUpdates: Record<string, string | null>[] = [];
|
|
813
813
|
const memoryNav = {
|
|
814
814
|
route: { screenId: "task-list" },
|
|
815
|
-
navigate: (target:
|
|
815
|
+
navigate: (target: NavTarget) => {
|
|
816
816
|
calls.push({ kind: "navigate", value: target });
|
|
817
|
-
navigateCalls.push(target);
|
|
817
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
818
818
|
},
|
|
819
819
|
replace: () => undefined,
|
|
820
|
-
hrefFor: (t:
|
|
820
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
821
821
|
searchParams: {},
|
|
822
822
|
setSearchParams: (u: Record<string, string | null>) => {
|
|
823
823
|
calls.push({ kind: "setSearchParams", value: u });
|
|
@@ -941,9 +941,11 @@ describe("KumikoScreen", () => {
|
|
|
941
941
|
const searchParamUpdates: Record<string, string | null>[] = [];
|
|
942
942
|
const memoryNav = {
|
|
943
943
|
route: { screenId: "task-list" },
|
|
944
|
-
navigate: (target:
|
|
944
|
+
navigate: (target: NavTarget) => {
|
|
945
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
946
|
+
},
|
|
945
947
|
replace: () => undefined,
|
|
946
|
-
hrefFor: (t:
|
|
948
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
947
949
|
searchParams: {},
|
|
948
950
|
setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
|
|
949
951
|
};
|
|
@@ -1000,9 +1002,11 @@ describe("KumikoScreen", () => {
|
|
|
1000
1002
|
const navigateCalls: { screenId: string; entityId?: string }[] = [];
|
|
1001
1003
|
const memoryNav = {
|
|
1002
1004
|
route: { screenId: "task-list" },
|
|
1003
|
-
navigate: (target:
|
|
1005
|
+
navigate: (target: NavTarget) => {
|
|
1006
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1007
|
+
},
|
|
1004
1008
|
replace: () => undefined,
|
|
1005
|
-
hrefFor: (t:
|
|
1009
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1006
1010
|
searchParams: {},
|
|
1007
1011
|
setSearchParams: () => undefined,
|
|
1008
1012
|
};
|
|
@@ -1055,9 +1059,11 @@ describe("KumikoScreen", () => {
|
|
|
1055
1059
|
const navigateCalls: { screenId: string; entityId?: string }[] = [];
|
|
1056
1060
|
const memoryNav = {
|
|
1057
1061
|
route: { screenId: "task-list" },
|
|
1058
|
-
navigate: (target:
|
|
1062
|
+
navigate: (target: NavTarget) => {
|
|
1063
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1064
|
+
},
|
|
1059
1065
|
replace: () => undefined,
|
|
1060
|
-
hrefFor: (t:
|
|
1066
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1061
1067
|
searchParams: {},
|
|
1062
1068
|
setSearchParams: () => undefined,
|
|
1063
1069
|
};
|
|
@@ -1114,9 +1120,11 @@ describe("KumikoScreen", () => {
|
|
|
1114
1120
|
const searchParamUpdates: Record<string, string | null>[] = [];
|
|
1115
1121
|
const memoryNav = {
|
|
1116
1122
|
route: { screenId: "task-list" },
|
|
1117
|
-
navigate: (target:
|
|
1123
|
+
navigate: (target: NavTarget) => {
|
|
1124
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1125
|
+
},
|
|
1118
1126
|
replace: () => undefined,
|
|
1119
|
-
hrefFor: (t:
|
|
1127
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1120
1128
|
searchParams: {},
|
|
1121
1129
|
setSearchParams: (u: Record<string, string | null>) => searchParamUpdates.push(u),
|
|
1122
1130
|
};
|
|
@@ -1170,9 +1178,11 @@ describe("KumikoScreen", () => {
|
|
|
1170
1178
|
});
|
|
1171
1179
|
const memoryNav = {
|
|
1172
1180
|
route: { screenId: "task-list" },
|
|
1173
|
-
navigate: (target:
|
|
1181
|
+
navigate: (target: NavTarget) => {
|
|
1182
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1183
|
+
},
|
|
1174
1184
|
replace: () => undefined,
|
|
1175
|
-
hrefFor: (t:
|
|
1185
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1176
1186
|
searchParams: {},
|
|
1177
1187
|
setSearchParams: () => undefined,
|
|
1178
1188
|
};
|
|
@@ -1306,7 +1316,7 @@ describe("KumikoScreen", () => {
|
|
|
1306
1316
|
route: undefined,
|
|
1307
1317
|
navigate: () => {},
|
|
1308
1318
|
replace: () => {},
|
|
1309
|
-
hrefFor: (t) => `/${t.screenId}
|
|
1319
|
+
hrefFor: (t) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1310
1320
|
searchParams: { "task-list.f.id": "r1,r2" },
|
|
1311
1321
|
setSearchParams: () => {},
|
|
1312
1322
|
};
|
|
@@ -1448,9 +1458,11 @@ describe("KumikoScreen", () => {
|
|
|
1448
1458
|
});
|
|
1449
1459
|
const memoryNav = {
|
|
1450
1460
|
route: { screenId: "quick-add" },
|
|
1451
|
-
navigate: (target:
|
|
1461
|
+
navigate: (target: NavTarget) => {
|
|
1462
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1463
|
+
},
|
|
1452
1464
|
replace: () => undefined,
|
|
1453
|
-
hrefFor: (t:
|
|
1465
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1454
1466
|
searchParams: {},
|
|
1455
1467
|
setSearchParams: () => undefined,
|
|
1456
1468
|
};
|
|
@@ -1492,9 +1504,11 @@ describe("KumikoScreen", () => {
|
|
|
1492
1504
|
});
|
|
1493
1505
|
const memoryNav = {
|
|
1494
1506
|
route: { screenId: "quick-add" },
|
|
1495
|
-
navigate: (target:
|
|
1507
|
+
navigate: (target: NavTarget) => {
|
|
1508
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1509
|
+
},
|
|
1496
1510
|
replace: () => undefined,
|
|
1497
|
-
hrefFor: (t:
|
|
1511
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1498
1512
|
searchParams: {},
|
|
1499
1513
|
setSearchParams: () => undefined,
|
|
1500
1514
|
};
|
|
@@ -1576,9 +1590,11 @@ describe("KumikoScreen", () => {
|
|
|
1576
1590
|
const navigateCalls: { screenId: string }[] = [];
|
|
1577
1591
|
const memoryNav = {
|
|
1578
1592
|
route: { screenId: "quick-add" },
|
|
1579
|
-
navigate: (target:
|
|
1593
|
+
navigate: (target: NavTarget) => {
|
|
1594
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1595
|
+
},
|
|
1580
1596
|
replace: () => undefined,
|
|
1581
|
-
hrefFor: (t:
|
|
1597
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1582
1598
|
searchParams: {},
|
|
1583
1599
|
setSearchParams: () => undefined,
|
|
1584
1600
|
};
|
|
@@ -1609,9 +1625,11 @@ describe("KumikoScreen", () => {
|
|
|
1609
1625
|
const navigateCalls: { screenId: string }[] = [];
|
|
1610
1626
|
const memoryNav = {
|
|
1611
1627
|
route: { screenId: "quick-add" },
|
|
1612
|
-
navigate: (target:
|
|
1628
|
+
navigate: (target: NavTarget) => {
|
|
1629
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1630
|
+
},
|
|
1613
1631
|
replace: () => undefined,
|
|
1614
|
-
hrefFor: (t:
|
|
1632
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1615
1633
|
searchParams: {},
|
|
1616
1634
|
setSearchParams: () => undefined,
|
|
1617
1635
|
};
|
|
@@ -1747,9 +1765,11 @@ describe("KumikoScreen", () => {
|
|
|
1747
1765
|
});
|
|
1748
1766
|
const memoryNav = {
|
|
1749
1767
|
route: { screenId: "quick-add" },
|
|
1750
|
-
navigate: (target:
|
|
1768
|
+
navigate: (target: NavTarget) => {
|
|
1769
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1770
|
+
},
|
|
1751
1771
|
replace: () => undefined,
|
|
1752
|
-
hrefFor: (t:
|
|
1772
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1753
1773
|
searchParams: {},
|
|
1754
1774
|
setSearchParams: () => undefined,
|
|
1755
1775
|
};
|
|
@@ -1825,9 +1845,11 @@ describe("KumikoScreen", () => {
|
|
|
1825
1845
|
const navigateCalls: { screenId: string }[] = [];
|
|
1826
1846
|
const memoryNav = {
|
|
1827
1847
|
route: { screenId: "task-list" },
|
|
1828
|
-
navigate: (target:
|
|
1848
|
+
navigate: (target: NavTarget) => {
|
|
1849
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1850
|
+
},
|
|
1829
1851
|
replace: () => undefined,
|
|
1830
|
-
hrefFor: (t:
|
|
1852
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1831
1853
|
searchParams: {},
|
|
1832
1854
|
setSearchParams: () => undefined,
|
|
1833
1855
|
};
|
|
@@ -1864,9 +1886,11 @@ describe("KumikoScreen", () => {
|
|
|
1864
1886
|
const navigateCalls: { screenId: string }[] = [];
|
|
1865
1887
|
const memoryNav = {
|
|
1866
1888
|
route: { screenId: "task-list" },
|
|
1867
|
-
navigate: (target:
|
|
1889
|
+
navigate: (target: NavTarget) => {
|
|
1890
|
+
if ("screenId" in target) navigateCalls.push(target);
|
|
1891
|
+
},
|
|
1868
1892
|
replace: () => undefined,
|
|
1869
|
-
hrefFor: (t:
|
|
1893
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
1870
1894
|
searchParams: {},
|
|
1871
1895
|
setSearchParams: () => undefined,
|
|
1872
1896
|
};
|
|
@@ -2209,7 +2233,7 @@ describe("KumikoScreen: actionForm extension-section", () => {
|
|
|
2209
2233
|
route: { screenId: "post-update" },
|
|
2210
2234
|
navigate: () => undefined,
|
|
2211
2235
|
replace: () => undefined,
|
|
2212
|
-
hrefFor: (t:
|
|
2236
|
+
hrefFor: (t: NavTarget) => ("screenId" in t ? `/${t.screenId}` : ""),
|
|
2213
2237
|
searchParams: { incidentId: "inc-7" },
|
|
2214
2238
|
setSearchParams: () => undefined,
|
|
2215
2239
|
};
|
|
@@ -3076,6 +3076,68 @@ describe("RenderEdit hideActions (host-driven action bar)", () => {
|
|
|
3076
3076
|
data: { id: "42" },
|
|
3077
3077
|
});
|
|
3078
3078
|
});
|
|
3079
|
+
|
|
3080
|
+
test("onChange's submitting is false on mount", () => {
|
|
3081
|
+
const seen: RenderEditChangeState<TestValues>[] = [];
|
|
3082
|
+
render(
|
|
3083
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3084
|
+
<RenderEdit<TestValues>
|
|
3085
|
+
screen={makeScreen()}
|
|
3086
|
+
entity={orderEntity}
|
|
3087
|
+
featureName="orders"
|
|
3088
|
+
initial={{ title: "Acme", count: 1, isUrgent: false }}
|
|
3089
|
+
writeCommand="order:create"
|
|
3090
|
+
onChange={(state) => seen.push(state)}
|
|
3091
|
+
/>
|
|
3092
|
+
</DispatcherProvider>,
|
|
3093
|
+
);
|
|
3094
|
+
|
|
3095
|
+
expect(seen.at(-1)?.submitting).toBe(false);
|
|
3096
|
+
});
|
|
3097
|
+
|
|
3098
|
+
test("onChange's submitting is true while a customSubmit is in flight and false again after", async () => {
|
|
3099
|
+
let resolveSubmit: (() => void) | undefined;
|
|
3100
|
+
const customSubmit = mock(
|
|
3101
|
+
(_snapshot: FormSnapshot<TestValues>): Promise<SubmitResult<unknown>> =>
|
|
3102
|
+
new Promise((resolve) => {
|
|
3103
|
+
resolveSubmit = () =>
|
|
3104
|
+
resolve({ validationBlocked: false, isSuccess: true, data: { id: "42" } });
|
|
3105
|
+
}),
|
|
3106
|
+
);
|
|
3107
|
+
const seen: RenderEditChangeState<TestValues>[] = [];
|
|
3108
|
+
let controls: RenderEditControls<TestValues> | undefined;
|
|
3109
|
+
render(
|
|
3110
|
+
<DispatcherProvider dispatcher={makeDispatcher()}>
|
|
3111
|
+
<RenderEdit<TestValues>
|
|
3112
|
+
screen={makeScreen()}
|
|
3113
|
+
entity={orderEntity}
|
|
3114
|
+
featureName="orders"
|
|
3115
|
+
initial={{ title: "Acme", count: 1, isUrgent: false }}
|
|
3116
|
+
customSubmit={customSubmit}
|
|
3117
|
+
onChange={(state) => seen.push(state)}
|
|
3118
|
+
onControlsReady={(c) => {
|
|
3119
|
+
controls = c;
|
|
3120
|
+
}}
|
|
3121
|
+
/>
|
|
3122
|
+
</DispatcherProvider>,
|
|
3123
|
+
);
|
|
3124
|
+
|
|
3125
|
+
expect(seen.at(-1)?.submitting).toBe(false);
|
|
3126
|
+
|
|
3127
|
+
let submitPromise: Promise<void> | undefined;
|
|
3128
|
+
act(() => {
|
|
3129
|
+
submitPromise = controls?.submit();
|
|
3130
|
+
});
|
|
3131
|
+
|
|
3132
|
+
expect(seen.at(-1)?.submitting).toBe(true);
|
|
3133
|
+
|
|
3134
|
+
await act(async () => {
|
|
3135
|
+
resolveSubmit?.();
|
|
3136
|
+
await submitPromise;
|
|
3137
|
+
});
|
|
3138
|
+
|
|
3139
|
+
expect(seen.at(-1)?.submitting).toBe(false);
|
|
3140
|
+
});
|
|
3079
3141
|
});
|
|
3080
3142
|
|
|
3081
3143
|
describe("RenderEdit fields filter", () => {
|
|
@@ -53,10 +53,12 @@ const stubNav: NavApi = {
|
|
|
53
53
|
route: undefined,
|
|
54
54
|
navigate: () => {},
|
|
55
55
|
replace: () => {},
|
|
56
|
-
hrefFor: (target) =>
|
|
57
|
-
target
|
|
56
|
+
hrefFor: (target) => {
|
|
57
|
+
if (!("screenId" in target)) return "";
|
|
58
|
+
return target.entityId !== undefined
|
|
58
59
|
? `/${target.screenId}/${target.entityId}`
|
|
59
|
-
: `/${target.screenId}
|
|
60
|
+
: `/${target.screenId}`;
|
|
61
|
+
},
|
|
60
62
|
searchParams: {},
|
|
61
63
|
setSearchParams: () => {},
|
|
62
64
|
};
|
package/src/app/create-app.tsx
CHANGED
|
@@ -177,8 +177,14 @@ export type CreateKumikoAppOptions = {
|
|
|
177
177
|
* `{ hasWorkspaces: true }` aufgerufen — der Default-Hook nutzt das
|
|
178
178
|
* um das URL-Pattern auf `/<workspace>/<screen>[/<entityId>]`
|
|
179
179
|
* umzustellen. Eigene Adapter dürfen die Option ignorieren wenn ihr
|
|
180
|
-
* Router das anders löst.
|
|
181
|
-
|
|
180
|
+
* Router das anders löst. `features` ist `app.features` — der
|
|
181
|
+
* Default-Hook braucht sie für resolveTarget() (ObjectTarget →
|
|
182
|
+
* ScreenTarget); eigene Adapter die nie ein ObjectTarget bekommen,
|
|
183
|
+
* dürfen sie ignorieren. */
|
|
184
|
+
readonly navAdapter?: (options?: {
|
|
185
|
+
readonly hasWorkspaces?: boolean;
|
|
186
|
+
readonly features?: readonly FeatureSchema[];
|
|
187
|
+
}) => NavApi;
|
|
182
188
|
};
|
|
183
189
|
|
|
184
190
|
// Reads the dev-server-injected schema from the global. Guarded for
|
|
@@ -474,7 +480,10 @@ function BrowserNavBoot({
|
|
|
474
480
|
}: {
|
|
475
481
|
readonly app: AppSchema;
|
|
476
482
|
readonly fallbackQn: string;
|
|
477
|
-
readonly useNavApi: (options?: {
|
|
483
|
+
readonly useNavApi: (options?: {
|
|
484
|
+
readonly hasWorkspaces?: boolean;
|
|
485
|
+
readonly features?: readonly FeatureSchema[];
|
|
486
|
+
}) => NavApi;
|
|
478
487
|
readonly hasWorkspaces: boolean;
|
|
479
488
|
readonly translate?: Translate;
|
|
480
489
|
readonly onRowClick?: (row: ListRowViewModel, entityName: string) => void;
|
|
@@ -483,7 +492,7 @@ function BrowserNavBoot({
|
|
|
483
492
|
readonly schema: AppSchema;
|
|
484
493
|
}) => ReactNode;
|
|
485
494
|
}): ReactNode {
|
|
486
|
-
const navApi = useNavApi({ hasWorkspaces });
|
|
495
|
+
const navApi = useNavApi({ hasWorkspaces, features: app.features });
|
|
487
496
|
const Shell = shell;
|
|
488
497
|
const screen = (
|
|
489
498
|
<RoutedScreen
|
package/src/app/nav.tsx
CHANGED
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
// Notify-Trigger (eigenes pushPath, popstate-Event), nicht generalisierbar.
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
+
type FeatureSchema,
|
|
14
15
|
formatPath,
|
|
15
16
|
type NavApi,
|
|
16
17
|
type NavTarget,
|
|
17
18
|
parsePath,
|
|
19
|
+
resolveTarget,
|
|
20
|
+
type ScreenTarget,
|
|
18
21
|
useNav,
|
|
19
22
|
} from "@cosmicdrift/kumiko-renderer";
|
|
20
23
|
import {
|
|
@@ -66,6 +69,11 @@ function prependBasePath(path: string, basePath: string): string {
|
|
|
66
69
|
const listeners = new Set<() => void>();
|
|
67
70
|
let popstateWired = false;
|
|
68
71
|
|
|
72
|
+
// Stable identity for the omitted-features case — `?? []` would create a
|
|
73
|
+
// fresh array every render, busting the useMemo below on every render for
|
|
74
|
+
// every existing useBrowserNavApi() call site (none of which pass features).
|
|
75
|
+
const NO_FEATURES: readonly FeatureSchema[] = [];
|
|
76
|
+
|
|
69
77
|
function ensurePopstateWired(): void {
|
|
70
78
|
if (popstateWired) return;
|
|
71
79
|
if (typeof window === "undefined") return;
|
|
@@ -174,6 +182,10 @@ function replacePath(path: string): void {
|
|
|
174
182
|
export function useBrowserNavApi(options?: {
|
|
175
183
|
readonly hasWorkspaces?: boolean;
|
|
176
184
|
readonly basePath?: string;
|
|
185
|
+
/** Feature list ObjectTarget resolution needs (resolveTarget). Omit when
|
|
186
|
+
* the caller never passes an ObjectTarget — a ScreenTarget resolves
|
|
187
|
+
* without looking at the feature list at all. */
|
|
188
|
+
readonly features?: readonly FeatureSchema[];
|
|
177
189
|
}): NavApi {
|
|
178
190
|
const path = useSyncExternalStore(subscribe, readPath, () => "/");
|
|
179
191
|
// Search wird über denselben Listener-Set notifiziert (popstate +
|
|
@@ -185,6 +197,7 @@ export function useBrowserNavApi(options?: {
|
|
|
185
197
|
const basePath = useMemo(() => normalizeBasePath(options?.basePath), [options?.basePath]);
|
|
186
198
|
const searchParams = useMemo(() => parseSearchParams(search), [search]);
|
|
187
199
|
const inAppPath = useMemo(() => stripBasePath(path, basePath), [path, basePath]);
|
|
200
|
+
const features = options?.features ?? NO_FEATURES;
|
|
188
201
|
return useMemo<NavApi>(() => {
|
|
189
202
|
const route = inAppPath === undefined ? undefined : parsePath(inAppPath, hasWorkspaces);
|
|
190
203
|
// NavTarget-Contract: workspaceId weglassen = aktueller Workspace
|
|
@@ -192,21 +205,24 @@ export function useBrowserNavApi(options?: {
|
|
|
192
205
|
// landet `/screen-id` in parsePath(hasWorkspaces) als workspaceId,
|
|
193
206
|
// WorkspaceShell sieht einen unbekannten Workspace und revertet
|
|
194
207
|
// sofort auf den Default-Screen ("Klick tut nichts"-Prod-Bug).
|
|
195
|
-
const inCurrentWorkspace = (target:
|
|
208
|
+
const inCurrentWorkspace = (target: ScreenTarget): ScreenTarget =>
|
|
196
209
|
hasWorkspaces && target.workspaceId === undefined && route?.workspaceId !== undefined
|
|
197
210
|
? { ...target, workspaceId: route.workspaceId }
|
|
198
211
|
: target;
|
|
212
|
+
// resolveTarget runs first so an ObjectTarget never reaches formatPath —
|
|
213
|
+
// everything downstream (workspace-injection, path-building) only ever
|
|
214
|
+
// sees the ScreenTarget shape it already knew about.
|
|
215
|
+
const toPath = (target: NavTarget): string =>
|
|
216
|
+
prependBasePath(formatPath(inCurrentWorkspace(resolveTarget(features, target))), basePath);
|
|
199
217
|
return {
|
|
200
218
|
route,
|
|
201
|
-
navigate: (target) =>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
replacePath(prependBasePath(formatPath(inCurrentWorkspace(target)), basePath)),
|
|
205
|
-
hrefFor: (target) => prependBasePath(formatPath(inCurrentWorkspace(target)), basePath),
|
|
219
|
+
navigate: (target) => pushPath(toPath(target)),
|
|
220
|
+
replace: (target) => replacePath(toPath(target)),
|
|
221
|
+
hrefFor: (target) => toPath(target),
|
|
206
222
|
searchParams,
|
|
207
223
|
setSearchParams: applySearchParamUpdates,
|
|
208
224
|
};
|
|
209
|
-
}, [inAppPath, hasWorkspaces, basePath, searchParams]);
|
|
225
|
+
}, [inAppPath, hasWorkspaces, basePath, searchParams, features]);
|
|
210
226
|
}
|
|
211
227
|
|
|
212
228
|
// ---- KumikoLink (Anchor-basiert, nur Web) ----
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, mock, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import { fireEvent, render, screen } from "../../__tests__/test-utils";
|
|
3
3
|
import { Drawer } from "../drawer";
|
|
4
4
|
|
|
@@ -376,4 +376,99 @@ describe("Drawer", () => {
|
|
|
376
376
|
expect(screen.queryByRole("button", { name: /maximize drawer width/i })).toBeNull();
|
|
377
377
|
});
|
|
378
378
|
});
|
|
379
|
+
|
|
380
|
+
describe("narrow viewport", () => {
|
|
381
|
+
// matchMedia is set via Object.defineProperty on window, same leak trap
|
|
382
|
+
// as innerWidth above (resize describe): without restoring the original
|
|
383
|
+
// descriptor in afterEach, the mock survives into the next test file's
|
|
384
|
+
// happy-dom instance and corrupts unrelated tests there.
|
|
385
|
+
let originalDescriptor: PropertyDescriptor | undefined;
|
|
386
|
+
|
|
387
|
+
function mockMatchMedia(matches: boolean): void {
|
|
388
|
+
Object.defineProperty(window, "matchMedia", {
|
|
389
|
+
configurable: true,
|
|
390
|
+
writable: true,
|
|
391
|
+
value: (query: string) => ({
|
|
392
|
+
matches,
|
|
393
|
+
media: query,
|
|
394
|
+
addEventListener: () => {},
|
|
395
|
+
removeEventListener: () => {},
|
|
396
|
+
}),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
beforeEach(() => {
|
|
401
|
+
originalDescriptor = Object.getOwnPropertyDescriptor(window, "matchMedia");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
afterEach(() => {
|
|
405
|
+
if (originalDescriptor) {
|
|
406
|
+
Object.defineProperty(window, "matchMedia", originalDescriptor);
|
|
407
|
+
} else {
|
|
408
|
+
delete (window as unknown as { matchMedia?: unknown }).matchMedia;
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("narrow: full-screen classes replace the floating panel treatment", () => {
|
|
413
|
+
mockMatchMedia(true);
|
|
414
|
+
render(
|
|
415
|
+
<Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer">
|
|
416
|
+
<div>Body</div>
|
|
417
|
+
</Drawer>,
|
|
418
|
+
);
|
|
419
|
+
const content = screen.getByTestId("drawer");
|
|
420
|
+
expect(content.className).toContain("inset-0");
|
|
421
|
+
expect(content.className).toContain("w-full");
|
|
422
|
+
expect(content.className).not.toContain("rounded-[2rem]");
|
|
423
|
+
expect(content.className).not.toContain("inset-y-8");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("narrow with resize set: no inline width style", () => {
|
|
427
|
+
mockMatchMedia(true);
|
|
428
|
+
render(
|
|
429
|
+
<Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer" resize={{}}>
|
|
430
|
+
<div>Body</div>
|
|
431
|
+
</Drawer>,
|
|
432
|
+
);
|
|
433
|
+
expect(screen.getByTestId("drawer").style.width).toBe("");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("narrow: resize handle is not rendered", () => {
|
|
437
|
+
mockMatchMedia(true);
|
|
438
|
+
render(
|
|
439
|
+
<Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer" resize={{}}>
|
|
440
|
+
<div>Body</div>
|
|
441
|
+
</Drawer>,
|
|
442
|
+
);
|
|
443
|
+
expect(screen.queryByRole("separator")).toBeNull();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("narrow: maximize button is not rendered", () => {
|
|
447
|
+
mockMatchMedia(true);
|
|
448
|
+
render(
|
|
449
|
+
<Drawer open={true} onOpenChange={() => {}} side="right" testId="drawer" resize={{}}>
|
|
450
|
+
<div>Body</div>
|
|
451
|
+
</Drawer>,
|
|
452
|
+
);
|
|
453
|
+
expect(screen.queryByRole("button", { name: /maximize drawer width/i })).toBeNull();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("wide (regression clamp): inline width and handle still present with resize set", () => {
|
|
457
|
+
mockMatchMedia(false);
|
|
458
|
+
render(
|
|
459
|
+
<Drawer
|
|
460
|
+
open={true}
|
|
461
|
+
onOpenChange={() => {}}
|
|
462
|
+
side="right"
|
|
463
|
+
testId="drawer"
|
|
464
|
+
resize={{ defaultWidthPx: 400, minWidthPx: 300, maxWidthPx: 500 }}
|
|
465
|
+
>
|
|
466
|
+
<div>Body</div>
|
|
467
|
+
</Drawer>,
|
|
468
|
+
);
|
|
469
|
+
const content = screen.getByTestId("drawer");
|
|
470
|
+
expect(content.style.width).toBe("400px");
|
|
471
|
+
expect(screen.getByRole("separator")).toBeTruthy();
|
|
472
|
+
});
|
|
473
|
+
});
|
|
379
474
|
});
|
package/src/widgets/drawer.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Maximize2Icon, Minimize2Icon } from "lucide-react";
|
|
|
3
3
|
import { type ReactNode, useRef, useState } from "react";
|
|
4
4
|
import { clamp } from "../lib/clamp";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
|
+
import { useIsNarrowViewport } from "../primitives/use-narrow-viewport";
|
|
6
7
|
import {
|
|
7
8
|
Sheet,
|
|
8
9
|
SheetContent,
|
|
@@ -63,8 +64,12 @@ function defaultWidthFromViewport(): number {
|
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
// 32px margin + 32px radius so the panel reads as detached from the
|
|
66
|
-
// viewport edge, unlike the sheet primitive's flush-edge default.
|
|
67
|
-
|
|
67
|
+
// viewport edge, unlike the sheet primitive's flush-edge default. Below the
|
|
68
|
+
// narrow-viewport breakpoint the floating treatment doesn't fit — the JS
|
|
69
|
+
// hook decides, not a `sm:` Tailwind prefix, since the resize width below
|
|
70
|
+
// already comes from JS and two decision sources is the bug this avoids.
|
|
71
|
+
function floatingSideClass(side: "left" | "right" | "top" | "bottom", narrow: boolean): string {
|
|
72
|
+
if (narrow) return "inset-0 h-full w-full max-w-none rounded-none border-0 overflow-hidden";
|
|
68
73
|
switch (side) {
|
|
69
74
|
case "left":
|
|
70
75
|
return "inset-y-8 left-8 h-auto w-[max(600px,37.5vw)] max-w-[85vw] sm:max-w-[max(600px,37.5vw)] rounded-[2rem] border shadow-2xl overflow-hidden";
|
|
@@ -94,6 +99,7 @@ export function Drawer({
|
|
|
94
99
|
backdrop,
|
|
95
100
|
}: DrawerProps): ReactNode {
|
|
96
101
|
const t = useTranslation();
|
|
102
|
+
const narrow = useIsNarrowViewport();
|
|
97
103
|
const canResize = resize !== undefined && (side === "left" || side === "right");
|
|
98
104
|
const minWidthPx = resize?.minWidthPx ?? MIN_WIDTH_PX;
|
|
99
105
|
const maxWidthPx = resize?.maxWidthPx ?? MAX_WIDTH_PX;
|
|
@@ -160,10 +166,10 @@ export function Drawer({
|
|
|
160
166
|
data-testid={testId}
|
|
161
167
|
overlayStyle={overlayStyle}
|
|
162
168
|
showCloseButton={showCloseButton}
|
|
163
|
-
className={floatingSideClass(side)}
|
|
164
|
-
style={canResize ? { width: effectiveWidthPx, maxWidth: "none" } : undefined}
|
|
169
|
+
className={floatingSideClass(side, narrow)}
|
|
170
|
+
style={canResize && !narrow ? { width: effectiveWidthPx, maxWidth: "none" } : undefined}
|
|
165
171
|
>
|
|
166
|
-
{canResize && (
|
|
172
|
+
{canResize && !narrow && (
|
|
167
173
|
<button
|
|
168
174
|
type="button"
|
|
169
175
|
onClick={() => setMaximized((m) => !m)}
|
|
@@ -188,7 +194,7 @@ export function Drawer({
|
|
|
188
194
|
)}
|
|
189
195
|
<div className="flex-1 overflow-y-auto px-4">{children}</div>
|
|
190
196
|
{footer !== undefined && <SheetFooter>{footer}</SheetFooter>}
|
|
191
|
-
{canResize && (
|
|
197
|
+
{canResize && !narrow && (
|
|
192
198
|
// 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.
|
|
193
199
|
<div
|
|
194
200
|
role="separator"
|