@jskit-ai/shell-web 0.1.149 → 0.1.151
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/CHANGELOG.md +14 -0
- package/fixtures/adaptive-shell/index.html +12 -0
- package/fixtures/adaptive-shell/src/App.vue +34 -0
- package/fixtures/adaptive-shell/src/ScreenPage.vue +14 -0
- package/fixtures/adaptive-shell/src/main.js +197 -0
- package/fixtures/adaptive-shell/vite.config.mjs +17 -0
- package/package.descriptor.mjs +2 -2
- package/package.json +2 -2
- package/src/client/components/ShellLayout.vue +258 -16
- package/src/client/components/ShellMenuLinkItem.vue +18 -12
- package/src/client/components/ShellNavigationTooltip.vue +37 -0
- package/src/client/components/ShellSurfaceAwareMenuLinkItem.vue +18 -12
- package/src/client/support/drawerWidth.js +91 -0
- package/src/client/support/navigationLinkKeyboard.js +10 -0
- package/src/test/adaptiveShellSmoke.js +192 -16
- package/templates/src/components/ShellLayout.vue +1 -0
- package/test/adaptiveShell.browser.test.js +324 -0
- package/test/drawerWidth.test.js +59 -0
- package/test/linkItemScaffoldContract.test.js +18 -2
- package/test/navigationLinkKeyboard.test.js +36 -0
- package/test/playwrightContract.test.js +17 -0
- package/test/settingsPlacementContract.test.js +16 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const DEFAULT_SMOKE_PATH = String(process.env.JSKIT_PLAYWRIGHT_SMOKE_PATH || "/home");
|
|
2
2
|
const DEFAULT_VIEWPORTS = Object.freeze([
|
|
3
3
|
Object.freeze({ name: "compact", width: 390, height: 844 }),
|
|
4
|
-
Object.freeze({ name: "medium", width:
|
|
4
|
+
Object.freeze({ name: "medium", width: 1024, height: 1024 }),
|
|
5
5
|
Object.freeze({ name: "expanded", width: 1280, height: 900 })
|
|
6
6
|
]);
|
|
7
7
|
|
|
@@ -68,6 +68,165 @@ async function isElementVisibleInViewport(page, testId) {
|
|
|
68
68
|
});
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
async function expectElementVisibility(page, expect, testId, visible) {
|
|
72
|
+
await expect.poll(
|
|
73
|
+
() => isElementVisibleInViewport(page, testId),
|
|
74
|
+
{ message: `${testId} did not reach its expected visibility.` }
|
|
75
|
+
).toBe(visible);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function readShellLayoutClass(drawer) {
|
|
79
|
+
const layoutClass = String(await drawer.getAttribute("data-layout") || "");
|
|
80
|
+
if (!["compact", "medium", "expanded"].includes(layoutClass)) {
|
|
81
|
+
throw new Error(`Shell drawer exposed an invalid data-layout value: ${layoutClass || "(empty)"}.`);
|
|
82
|
+
}
|
|
83
|
+
return layoutClass;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readConfiguredWidth(drawer, attribute) {
|
|
87
|
+
const width = Number(await drawer.getAttribute(attribute));
|
|
88
|
+
if (!Number.isFinite(width) || width <= 0) {
|
|
89
|
+
throw new Error(`Shell drawer exposed an invalid ${attribute} value.`);
|
|
90
|
+
}
|
|
91
|
+
return width;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function expectDrawerWidth(drawer, expect, expectedWidth) {
|
|
95
|
+
await expect.poll(async () => {
|
|
96
|
+
const box = await drawer.boundingBox();
|
|
97
|
+
return box ? Math.abs(box.width - expectedWidth) : Number.POSITIVE_INFINITY;
|
|
98
|
+
}, {
|
|
99
|
+
message: `Shell drawer did not settle at ${expectedWidth}px.`
|
|
100
|
+
}).toBeLessThanOrEqual(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function expectContentAwareDrawerFit(drawer, expect) {
|
|
104
|
+
if (await drawer.getAttribute("data-drawer-width-mode") !== "content") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const configuredSpacing = Number(await drawer.getAttribute("data-navigation-item-spacing"));
|
|
108
|
+
expect(Number.isFinite(configuredSpacing)).toBe(true);
|
|
109
|
+
|
|
110
|
+
const fit = await drawer.evaluate((element) => {
|
|
111
|
+
const drawerRect = element.getBoundingClientRect();
|
|
112
|
+
const drawerStyle = window.getComputedStyle(element);
|
|
113
|
+
const rightToLeft = drawerStyle.direction === "rtl";
|
|
114
|
+
const endBorderWidth = Number.parseFloat(
|
|
115
|
+
rightToLeft ? drawerStyle.borderLeftWidth : drawerStyle.borderRightWidth
|
|
116
|
+
) || 0;
|
|
117
|
+
const innerEnd = rightToLeft
|
|
118
|
+
? drawerRect.left + endBorderWidth
|
|
119
|
+
: drawerRect.right - endBorderWidth;
|
|
120
|
+
const labels = Array.from(element.querySelectorAll(".v-list-item-title"))
|
|
121
|
+
.filter((label) => {
|
|
122
|
+
const style = window.getComputedStyle(label);
|
|
123
|
+
return style.display !== "none" && style.visibility !== "hidden";
|
|
124
|
+
})
|
|
125
|
+
.map((label) => {
|
|
126
|
+
const range = document.createRange();
|
|
127
|
+
range.selectNodeContents(label);
|
|
128
|
+
const textRect = range.getBoundingClientRect();
|
|
129
|
+
range.detach?.();
|
|
130
|
+
const icon = label.closest(".v-list-item")?.querySelector(".v-icon");
|
|
131
|
+
const iconRect = icon?.getBoundingClientRect();
|
|
132
|
+
return {
|
|
133
|
+
clipped: (
|
|
134
|
+
label.scrollWidth > label.clientWidth ||
|
|
135
|
+
label.scrollHeight > label.clientHeight ||
|
|
136
|
+
(rightToLeft
|
|
137
|
+
? textRect.left < innerEnd - 1
|
|
138
|
+
: textRect.right > innerEnd + 1)
|
|
139
|
+
),
|
|
140
|
+
iconLabelGap: iconRect
|
|
141
|
+
? rightToLeft ? iconRect.left - textRect.right : textRect.left - iconRect.right
|
|
142
|
+
: null,
|
|
143
|
+
logicalEnd: rightToLeft ? textRect.left : textRect.right,
|
|
144
|
+
width: textRect.width
|
|
145
|
+
};
|
|
146
|
+
})
|
|
147
|
+
.filter((label) => label.width > 0);
|
|
148
|
+
|
|
149
|
+
if (labels.length === 0) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const furthestLabel = labels.reduce((furthest, label) => {
|
|
153
|
+
if (!furthest) {
|
|
154
|
+
return label;
|
|
155
|
+
}
|
|
156
|
+
return rightToLeft
|
|
157
|
+
? label.logicalEnd < furthest.logicalEnd ? label : furthest
|
|
158
|
+
: label.logicalEnd > furthest.logicalEnd ? label : furthest;
|
|
159
|
+
}, null);
|
|
160
|
+
return {
|
|
161
|
+
clipped: labels.some((label) => label.clipped),
|
|
162
|
+
endGap: rightToLeft
|
|
163
|
+
? furthestLabel.logicalEnd - innerEnd
|
|
164
|
+
: innerEnd - furthestLabel.logicalEnd,
|
|
165
|
+
iconLabelGaps: labels
|
|
166
|
+
.map((label) => label.iconLabelGap)
|
|
167
|
+
.filter((gap) => Number.isFinite(gap))
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(fit).not.toBeNull();
|
|
172
|
+
expect(fit.clipped).toBe(false);
|
|
173
|
+
expect(Math.abs(fit.endGap - configuredSpacing)).toBeLessThanOrEqual(1.1);
|
|
174
|
+
expect(fit.iconLabelGaps.length).toBeGreaterThan(0);
|
|
175
|
+
for (const gap of fit.iconLabelGaps) {
|
|
176
|
+
expect(Math.abs(gap - configuredSpacing)).toBeLessThanOrEqual(1);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function expectCentredRailNavigation(page, drawer, expect) {
|
|
181
|
+
const links = drawer.locator("a.shell-menu-link-item[href]");
|
|
182
|
+
await expect(links.first()).toBeVisible();
|
|
183
|
+
const firstLink = links.first();
|
|
184
|
+
const firstIcon = firstLink.locator(".v-icon").first();
|
|
185
|
+
const geometry = await Promise.all([drawer.boundingBox(), firstLink.boundingBox(), firstIcon.boundingBox()]);
|
|
186
|
+
const [drawerBox, linkBox, iconBox] = geometry;
|
|
187
|
+
|
|
188
|
+
expect(drawerBox).not.toBeNull();
|
|
189
|
+
expect(linkBox).not.toBeNull();
|
|
190
|
+
expect(iconBox).not.toBeNull();
|
|
191
|
+
expect(linkBox.width).toBeGreaterThanOrEqual(48);
|
|
192
|
+
expect(linkBox.height).toBeGreaterThanOrEqual(48);
|
|
193
|
+
expect(Math.abs(
|
|
194
|
+
iconBox.x + iconBox.width / 2 - (drawerBox.x + drawerBox.width / 2)
|
|
195
|
+
)).toBeLessThanOrEqual(1);
|
|
196
|
+
|
|
197
|
+
const currentUrl = new URL(page.url());
|
|
198
|
+
const linkCount = await links.count();
|
|
199
|
+
let navigationLink = null;
|
|
200
|
+
let targetUrl = null;
|
|
201
|
+
for (let index = 0; index < linkCount; index += 1) {
|
|
202
|
+
const candidate = links.nth(index);
|
|
203
|
+
const href = await candidate.getAttribute("href");
|
|
204
|
+
if (!href) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const resolved = new URL(href, currentUrl);
|
|
208
|
+
if (resolved.pathname !== currentUrl.pathname || resolved.search !== currentUrl.search) {
|
|
209
|
+
navigationLink = candidate;
|
|
210
|
+
targetUrl = resolved;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
expect(navigationLink).not.toBeNull();
|
|
216
|
+
await navigationLink.locator(".v-icon").first().click();
|
|
217
|
+
await expect.poll(() => {
|
|
218
|
+
const actual = new URL(page.url());
|
|
219
|
+
return `${actual.pathname}${actual.search}`;
|
|
220
|
+
}).toBe(`${targetUrl.pathname}${targetUrl.search}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function openCompactDrawer(page, expect) {
|
|
224
|
+
const drawer = page.getByTestId("jskit-shell-drawer");
|
|
225
|
+
await page.getByTestId("jskit-shell-nav-toggle").click();
|
|
226
|
+
await expect(drawer).toHaveAttribute("data-presentation", "modal");
|
|
227
|
+
await expectElementVisibility(page, expect, "jskit-shell-drawer", true);
|
|
228
|
+
}
|
|
229
|
+
|
|
71
230
|
async function runAdaptiveShellSmokeCase({
|
|
72
231
|
page,
|
|
73
232
|
expect,
|
|
@@ -83,8 +242,11 @@ async function runAdaptiveShellSmokeCase({
|
|
|
83
242
|
await expect(page.locator("body")).toBeVisible();
|
|
84
243
|
await expectGeneratedScreenContract(page, expect);
|
|
85
244
|
await expectNoHorizontalOverflow(page, expect);
|
|
245
|
+
const drawer = page.getByTestId("jskit-shell-drawer");
|
|
246
|
+
const toggle = page.getByTestId("jskit-shell-nav-toggle");
|
|
247
|
+
const layoutClass = await readShellLayoutClass(drawer);
|
|
86
248
|
|
|
87
|
-
if (
|
|
249
|
+
if (layoutClass === "compact") {
|
|
88
250
|
let bootstrapRequests = 0;
|
|
89
251
|
await page.route("**/api/bootstrap**", async (route) => {
|
|
90
252
|
bootstrapRequests += 1;
|
|
@@ -93,12 +255,20 @@ async function runAdaptiveShellSmokeCase({
|
|
|
93
255
|
const bootstrapRequestsBeforePull = bootstrapRequests;
|
|
94
256
|
|
|
95
257
|
await expect(page.getByTestId("jskit-shell-bottom-nav")).toBeVisible();
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
await
|
|
99
|
-
await
|
|
258
|
+
await expectElementVisibility(page, expect, "jskit-shell-drawer", false);
|
|
259
|
+
|
|
260
|
+
await openCompactDrawer(page, expect);
|
|
261
|
+
await toggle.click();
|
|
262
|
+
await expectElementVisibility(page, expect, "jskit-shell-drawer", false);
|
|
263
|
+
|
|
264
|
+
await openCompactDrawer(page, expect);
|
|
265
|
+
await page.locator(".v-navigation-drawer__scrim").click();
|
|
266
|
+
await expectElementVisibility(page, expect, "jskit-shell-drawer", false);
|
|
267
|
+
|
|
268
|
+
await openCompactDrawer(page, expect);
|
|
100
269
|
await page.keyboard.press("Escape");
|
|
101
|
-
|
|
270
|
+
await expectElementVisibility(page, expect, "jskit-shell-drawer", false);
|
|
271
|
+
await expect(toggle).toBeFocused();
|
|
102
272
|
|
|
103
273
|
const navButtonHeights = await page.getByTestId("jskit-shell-bottom-nav").locator(".v-btn").evaluateAll((buttons) =>
|
|
104
274
|
buttons.map((button) => button.getBoundingClientRect().height)
|
|
@@ -111,15 +281,21 @@ async function runAdaptiveShellSmokeCase({
|
|
|
111
281
|
await pullToRefresh(page, expect);
|
|
112
282
|
await expect.poll(() => bootstrapRequests).toBeGreaterThan(bootstrapRequestsBeforePull);
|
|
113
283
|
} else {
|
|
114
|
-
await expect(
|
|
115
|
-
await
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
expect(
|
|
284
|
+
await expect(drawer).toBeVisible();
|
|
285
|
+
if (await drawer.getAttribute("data-presentation") === "rail") {
|
|
286
|
+
await toggle.click();
|
|
287
|
+
}
|
|
288
|
+
await expect(drawer).toHaveAttribute("data-presentation", "drawer");
|
|
289
|
+
await expectDrawerWidth(drawer, expect, await readConfiguredWidth(drawer, "data-drawer-width"));
|
|
290
|
+
await expectContentAwareDrawerFit(drawer, expect);
|
|
291
|
+
await page.keyboard.press("Escape");
|
|
292
|
+
await expect(drawer).toHaveAttribute("data-presentation", "drawer");
|
|
293
|
+
|
|
294
|
+
await toggle.click();
|
|
295
|
+
await expect(drawer).toHaveAttribute("data-presentation", "rail");
|
|
296
|
+
await expectDrawerWidth(drawer, expect, await readConfiguredWidth(drawer, "data-rail-width"));
|
|
297
|
+
await expectCentredRailNavigation(page, drawer, expect);
|
|
298
|
+
await expectNoHorizontalOverflow(page, expect);
|
|
123
299
|
}
|
|
124
300
|
}
|
|
125
301
|
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { chromium, expect } from "@playwright/test";
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_VIEWPORTS,
|
|
11
|
+
runAdaptiveShellSmokeCase
|
|
12
|
+
} from "../src/test/adaptiveShellSmoke.js";
|
|
13
|
+
|
|
14
|
+
const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const REPOSITORY_ROOT = path.resolve(TEST_DIRECTORY, "../../..");
|
|
16
|
+
const FIXTURE_ROOT = path.resolve(TEST_DIRECTORY, "../fixtures/adaptive-shell");
|
|
17
|
+
const VITE_CLI = path.join(REPOSITORY_ROOT, "node_modules", "vite", "bin", "vite.js");
|
|
18
|
+
const RUN_BROWSER_TEST = process.env.JSKIT_SHELL_WEB_BROWSER_INTEGRATION === "1";
|
|
19
|
+
|
|
20
|
+
function startVite(port) {
|
|
21
|
+
const child = spawn(process.execPath, [
|
|
22
|
+
VITE_CLI,
|
|
23
|
+
"--config",
|
|
24
|
+
path.join(FIXTURE_ROOT, "vite.config.mjs"),
|
|
25
|
+
"--port",
|
|
26
|
+
String(port),
|
|
27
|
+
"--clearScreen",
|
|
28
|
+
"false"
|
|
29
|
+
], {
|
|
30
|
+
cwd: FIXTURE_ROOT,
|
|
31
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
32
|
+
env: {
|
|
33
|
+
...process.env,
|
|
34
|
+
NO_COLOR: "1",
|
|
35
|
+
FORCE_COLOR: "0"
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
let output = "";
|
|
39
|
+
child.stdout.on("data", collectOutput);
|
|
40
|
+
child.stderr.on("data", collectOutput);
|
|
41
|
+
|
|
42
|
+
function collectOutput(chunk) {
|
|
43
|
+
output += chunk.toString("utf8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function waitUntilReady() {
|
|
47
|
+
await new Promise((resolve, reject) => {
|
|
48
|
+
const startedAt = Date.now();
|
|
49
|
+
const timer = setInterval(checkReadiness, 50);
|
|
50
|
+
|
|
51
|
+
function checkReadiness() {
|
|
52
|
+
if (new RegExp(`http://127\\.0\\.0\\.1:${port}/`, "u").test(output)) {
|
|
53
|
+
clearInterval(timer);
|
|
54
|
+
resolve();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (child.exitCode !== null) {
|
|
58
|
+
clearInterval(timer);
|
|
59
|
+
reject(new Error(`Vite exited before becoming ready.\n${output}`));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (Date.now() - startedAt >= 30_000) {
|
|
63
|
+
clearInterval(timer);
|
|
64
|
+
reject(new Error(`Timed out waiting for Vite.\n${output}`));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return Object.freeze({ child, waitUntilReady });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function stopVite(runtime) {
|
|
74
|
+
const child = runtime?.child;
|
|
75
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
await new Promise((resolve) => {
|
|
79
|
+
const forceTimer = setTimeout(forceStop, 5_000);
|
|
80
|
+
child.once("exit", finishStop);
|
|
81
|
+
child.kill("SIGTERM");
|
|
82
|
+
|
|
83
|
+
function forceStop() {
|
|
84
|
+
child.kill("SIGKILL");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function finishStop() {
|
|
88
|
+
clearTimeout(forceTimer);
|
|
89
|
+
resolve();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function reservePort() {
|
|
95
|
+
const server = net.createServer();
|
|
96
|
+
await new Promise((resolve, reject) => {
|
|
97
|
+
server.once("error", reject);
|
|
98
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
99
|
+
});
|
|
100
|
+
const address = server.address();
|
|
101
|
+
assert.ok(address && typeof address === "object");
|
|
102
|
+
await new Promise((resolve, reject) => {
|
|
103
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
104
|
+
});
|
|
105
|
+
return address.port;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseRgb(color) {
|
|
109
|
+
const channels = String(color || "").match(/[\d.]+/gu)?.map(Number) || [];
|
|
110
|
+
return {
|
|
111
|
+
red: channels[0] || 0,
|
|
112
|
+
green: channels[1] || 0,
|
|
113
|
+
blue: channels[2] || 0,
|
|
114
|
+
alpha: channels.length > 3 ? channels[3] : 1
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function relativeLuminance(color) {
|
|
119
|
+
const channels = [color.red, color.green, color.blue].map(normalizeChannel);
|
|
120
|
+
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeChannel(channel) {
|
|
124
|
+
const normalized = channel / 255;
|
|
125
|
+
return normalized <= 0.03928
|
|
126
|
+
? normalized / 12.92
|
|
127
|
+
: ((normalized + 0.055) / 1.055) ** 2.4;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function contrastRatio(foreground, background) {
|
|
131
|
+
const foregroundLuminance = relativeLuminance(foreground);
|
|
132
|
+
const backgroundLuminance = relativeLuminance(background);
|
|
133
|
+
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
|
|
134
|
+
const darker = Math.min(foregroundLuminance, backgroundLuminance);
|
|
135
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function assertTooltipContrast(page, linkName, interaction) {
|
|
139
|
+
const link = page.getByRole("link", { name: linkName, exact: true });
|
|
140
|
+
if (interaction === "focus") {
|
|
141
|
+
await link.focus();
|
|
142
|
+
} else {
|
|
143
|
+
await link.hover();
|
|
144
|
+
}
|
|
145
|
+
const tooltip = page.locator(".shell-navigation-tooltip").filter({ hasText: linkName }).last();
|
|
146
|
+
await expect(tooltip).toBeVisible();
|
|
147
|
+
const colors = await tooltip.evaluate((element) => {
|
|
148
|
+
const style = window.getComputedStyle(element);
|
|
149
|
+
return {
|
|
150
|
+
background: style.backgroundColor,
|
|
151
|
+
color: style.color,
|
|
152
|
+
opacity: Number(style.opacity)
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
const background = parseRgb(colors.background);
|
|
156
|
+
const foreground = parseRgb(colors.color);
|
|
157
|
+
assert.equal(colors.opacity, 1);
|
|
158
|
+
assert.equal(background.alpha, 1);
|
|
159
|
+
assert.ok(
|
|
160
|
+
contrastRatio(foreground, background) >= 4.5,
|
|
161
|
+
`${linkName} tooltip contrast was ${contrastRatio(foreground, background).toFixed(2)}:1.`
|
|
162
|
+
);
|
|
163
|
+
await page.keyboard.press("Escape");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function assertDrawerFit(page) {
|
|
167
|
+
const drawer = page.getByTestId("jskit-shell-drawer");
|
|
168
|
+
await expect(drawer).toHaveAttribute("data-presentation", "drawer");
|
|
169
|
+
const configuredSpacing = Number(await drawer.getAttribute("data-navigation-item-spacing"));
|
|
170
|
+
assert.ok(Number.isFinite(configuredSpacing));
|
|
171
|
+
await expect.poll(async () => {
|
|
172
|
+
const configured = Number(await drawer.getAttribute("data-drawer-width"));
|
|
173
|
+
const box = await drawer.boundingBox();
|
|
174
|
+
return box ? Math.abs(box.width - configured) : Number.POSITIVE_INFINITY;
|
|
175
|
+
}).toBeLessThanOrEqual(1);
|
|
176
|
+
const result = await drawer.evaluate((element) => {
|
|
177
|
+
const drawerRect = element.getBoundingClientRect();
|
|
178
|
+
const border = Number.parseFloat(window.getComputedStyle(element).borderRightWidth) || 0;
|
|
179
|
+
const innerEnd = drawerRect.right - border;
|
|
180
|
+
const labels = Array.from(element.querySelectorAll(".v-list-item-title")).map((label) => {
|
|
181
|
+
const range = document.createRange();
|
|
182
|
+
range.selectNodeContents(label);
|
|
183
|
+
const rect = range.getBoundingClientRect();
|
|
184
|
+
range.detach?.();
|
|
185
|
+
const iconRect = label.closest(".v-list-item")?.querySelector(".v-icon")?.getBoundingClientRect();
|
|
186
|
+
return {
|
|
187
|
+
clientHeight: label.clientHeight,
|
|
188
|
+
clientWidth: label.clientWidth,
|
|
189
|
+
rect,
|
|
190
|
+
scrollHeight: label.scrollHeight,
|
|
191
|
+
scrollWidth: label.scrollWidth,
|
|
192
|
+
iconLabelGap: iconRect ? rect.left - iconRect.right : null
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
const furthestEnd = Math.max(...labels.map((label) => label.rect.right));
|
|
196
|
+
return {
|
|
197
|
+
clipped: labels.some((label) => (
|
|
198
|
+
label.scrollWidth > label.clientWidth ||
|
|
199
|
+
label.scrollHeight > label.clientHeight
|
|
200
|
+
)),
|
|
201
|
+
endGap: innerEnd - furthestEnd,
|
|
202
|
+
iconLabelGaps: labels
|
|
203
|
+
.map((label) => label.iconLabelGap)
|
|
204
|
+
.filter((gap) => Number.isFinite(gap)),
|
|
205
|
+
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
assert.equal(result.clipped, false, "A drawer label was clipped or ellipsized.");
|
|
209
|
+
assert.ok(
|
|
210
|
+
Math.abs(result.endGap - configuredSpacing) <= 1.1,
|
|
211
|
+
`Drawer label end gap was ${result.endGap}px; expected ${configuredSpacing}px.`
|
|
212
|
+
);
|
|
213
|
+
assert.ok(result.iconLabelGaps.length > 0, "Drawer links exposed no measurable icon/label gap.");
|
|
214
|
+
for (const gap of result.iconLabelGaps) {
|
|
215
|
+
assert.ok(
|
|
216
|
+
Math.abs(gap - configuredSpacing) <= 1,
|
|
217
|
+
`Drawer icon/label gap was ${gap}px; expected ${configuredSpacing}px.`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
assert.ok(result.overflow <= 1, `Page overflowed horizontally by ${result.overflow}px.`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function assertRailKeyboardAndSelection(page) {
|
|
224
|
+
const drawer = page.getByTestId("jskit-shell-drawer");
|
|
225
|
+
await page.getByTestId("jskit-shell-nav-toggle").click();
|
|
226
|
+
await expect(drawer).toHaveAttribute("data-presentation", "rail");
|
|
227
|
+
const configuredRailWidth = Number(await drawer.getAttribute("data-rail-width"));
|
|
228
|
+
await expect.poll(async () => {
|
|
229
|
+
const box = await drawer.boundingBox();
|
|
230
|
+
return box ? Math.abs(box.width - configuredRailWidth) : Number.POSITIVE_INFINITY;
|
|
231
|
+
}).toBeLessThanOrEqual(1);
|
|
232
|
+
|
|
233
|
+
const activeLink = page.getByRole("link", { name: "Bookings", exact: true });
|
|
234
|
+
const activeBackground = await activeLink.locator(".v-list-item__overlay").evaluate(
|
|
235
|
+
(element) => window.getComputedStyle(element).backgroundColor
|
|
236
|
+
);
|
|
237
|
+
assert.notEqual(activeBackground, "rgba(0, 0, 0, 0)");
|
|
238
|
+
|
|
239
|
+
const contacts = page.getByRole("link", { name: "Contacts", exact: true });
|
|
240
|
+
await contacts.focus();
|
|
241
|
+
await contacts.press("Enter");
|
|
242
|
+
await expect(page).toHaveURL(/\/contacts(?:\?|$)/u);
|
|
243
|
+
|
|
244
|
+
const assistant = page.getByRole("link", { name: "Assistant", exact: true });
|
|
245
|
+
await assistant.focus();
|
|
246
|
+
const scrollBeforeSpace = await page.evaluate(() => window.scrollY);
|
|
247
|
+
await assistant.press("Space");
|
|
248
|
+
await expect(page).toHaveURL(/\/assistant(?:\?|$)/u);
|
|
249
|
+
assert.equal(await page.evaluate(() => window.scrollY), scrollBeforeSpace);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
test("shell-web adaptive navigation passes package-owned browser contracts", {
|
|
253
|
+
skip: RUN_BROWSER_TEST
|
|
254
|
+
? false
|
|
255
|
+
: "set JSKIT_SHELL_WEB_BROWSER_INTEGRATION=1 to run shell-web browser integration",
|
|
256
|
+
timeout: 180_000
|
|
257
|
+
}, async () => {
|
|
258
|
+
const port = await reservePort();
|
|
259
|
+
const vite = startVite(port);
|
|
260
|
+
let browser = null;
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
await vite.waitUntilReady();
|
|
264
|
+
const executablePath = String(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || "").trim();
|
|
265
|
+
browser = await chromium.launch({
|
|
266
|
+
headless: true,
|
|
267
|
+
...(executablePath ? { executablePath } : {})
|
|
268
|
+
});
|
|
269
|
+
const context = await browser.newContext({
|
|
270
|
+
baseURL: `http://127.0.0.1:${port}`,
|
|
271
|
+
locale: "en-US"
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
for (const viewport of DEFAULT_VIEWPORTS) {
|
|
275
|
+
const page = await context.newPage();
|
|
276
|
+
await runAdaptiveShellSmokeCase({ page, expect, viewport });
|
|
277
|
+
await page.close();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const pathAndTheme of [
|
|
281
|
+
"/home?theme=light",
|
|
282
|
+
"/home?theme=dark",
|
|
283
|
+
"/w/acme/admin/bookings?theme=light",
|
|
284
|
+
"/w/acme/admin/bookings?theme=dark"
|
|
285
|
+
]) {
|
|
286
|
+
const page = await context.newPage();
|
|
287
|
+
await page.setViewportSize({ width: 1280, height: 900 });
|
|
288
|
+
await page.goto(pathAndTheme);
|
|
289
|
+
await expect(page.getByTestId("jskit-shell-drawer")).toBeVisible();
|
|
290
|
+
await assertDrawerFit(page);
|
|
291
|
+
await assertTooltipContrast(page, "Bookings", "focus");
|
|
292
|
+
await assertTooltipContrast(page, "Help and support", "hover");
|
|
293
|
+
await page.close();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const railPage = await context.newPage();
|
|
297
|
+
await railPage.setViewportSize({ width: 1280, height: 900 });
|
|
298
|
+
await railPage.goto("/w/acme/admin/bookings?theme=light");
|
|
299
|
+
await assertDrawerFit(railPage);
|
|
300
|
+
await assertRailKeyboardAndSelection(railPage);
|
|
301
|
+
await railPage.close();
|
|
302
|
+
|
|
303
|
+
const configuredPage = await context.newPage();
|
|
304
|
+
await configuredPage.setViewportSize({ width: 1280, height: 900 });
|
|
305
|
+
await configuredPage.goto(
|
|
306
|
+
"/w/acme/admin/bookings?theme=light&railWidth=64&navigationItemSpacing=8"
|
|
307
|
+
);
|
|
308
|
+
const configuredDrawer = configuredPage.getByTestId("jskit-shell-drawer");
|
|
309
|
+
await expect(configuredDrawer).toHaveAttribute("data-navigation-item-spacing", "8");
|
|
310
|
+
await assertDrawerFit(configuredPage);
|
|
311
|
+
await configuredPage.getByTestId("jskit-shell-nav-toggle").click();
|
|
312
|
+
await expect(configuredDrawer).toHaveAttribute("data-presentation", "rail");
|
|
313
|
+
await expect(configuredDrawer).toHaveAttribute("data-rail-width", "64");
|
|
314
|
+
await expect.poll(async () => {
|
|
315
|
+
const box = await configuredDrawer.boundingBox();
|
|
316
|
+
return box ? Math.abs(box.width - 64) : Number.POSITIVE_INFINITY;
|
|
317
|
+
}).toBeLessThanOrEqual(1);
|
|
318
|
+
await configuredPage.close();
|
|
319
|
+
await context.close();
|
|
320
|
+
} finally {
|
|
321
|
+
await browser?.close();
|
|
322
|
+
await stopVite(vite);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_SHELL_DRAWER_WIDTH,
|
|
5
|
+
DEFAULT_SHELL_NAVIGATION_ITEM_SPACING,
|
|
6
|
+
DEFAULT_SHELL_RAIL_WIDTH,
|
|
7
|
+
normalizeShellDrawerWidth,
|
|
8
|
+
normalizeShellNavigationItemSpacing,
|
|
9
|
+
normalizeShellRailWidth,
|
|
10
|
+
resolveContentAwareDrawerWidth
|
|
11
|
+
} from "../src/client/support/drawerWidth.js";
|
|
12
|
+
|
|
13
|
+
test("content-aware drawer width includes the rendered label start, width, and balanced end spacing", () => {
|
|
14
|
+
assert.equal(
|
|
15
|
+
resolveContentAwareDrawerWidth([
|
|
16
|
+
{ logicalStart: 72, textWidth: 40 },
|
|
17
|
+
{ logicalStart: 72, textWidth: 78 }
|
|
18
|
+
]),
|
|
19
|
+
162
|
|
20
|
+
);
|
|
21
|
+
assert.equal(
|
|
22
|
+
resolveContentAwareDrawerWidth([
|
|
23
|
+
{ logicalStart: 72, textWidth: 99.015625 }
|
|
24
|
+
]),
|
|
25
|
+
183.25
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("drawer and rail widths have stable defaults and bounded overrides", () => {
|
|
30
|
+
assert.equal(DEFAULT_SHELL_RAIL_WIDTH, 80);
|
|
31
|
+
assert.equal(normalizeShellDrawerWidth(null), DEFAULT_SHELL_DRAWER_WIDTH);
|
|
32
|
+
assert.equal(normalizeShellDrawerWidth(180), 180);
|
|
33
|
+
assert.equal(normalizeShellDrawerWidth(1000), 360);
|
|
34
|
+
assert.equal(normalizeShellRailWidth(null), DEFAULT_SHELL_RAIL_WIDTH);
|
|
35
|
+
assert.equal(normalizeShellRailWidth(96), 96);
|
|
36
|
+
assert.equal(normalizeShellRailWidth(20), 48);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("navigation item spacing has a Material-aligned default and bounded overrides", () => {
|
|
40
|
+
assert.equal(DEFAULT_SHELL_NAVIGATION_ITEM_SPACING, 12);
|
|
41
|
+
assert.equal(normalizeShellNavigationItemSpacing(null), DEFAULT_SHELL_NAVIGATION_ITEM_SPACING);
|
|
42
|
+
assert.equal(normalizeShellNavigationItemSpacing(14), 14);
|
|
43
|
+
assert.equal(normalizeShellNavigationItemSpacing(2), 8);
|
|
44
|
+
assert.equal(normalizeShellNavigationItemSpacing(100), 24);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("content-aware width ignores invalid measurements and uses a bounded fallback", () => {
|
|
48
|
+
assert.equal(
|
|
49
|
+
resolveContentAwareDrawerWidth([
|
|
50
|
+
{ logicalStart: -1, textWidth: 20 },
|
|
51
|
+
{ logicalStart: 40, textWidth: Number.NaN }
|
|
52
|
+
]),
|
|
53
|
+
DEFAULT_SHELL_DRAWER_WIDTH
|
|
54
|
+
);
|
|
55
|
+
assert.equal(
|
|
56
|
+
resolveContentAwareDrawerWidth([{ logicalStart: 72, textWidth: 900 }]),
|
|
57
|
+
360
|
|
58
|
+
);
|
|
59
|
+
});
|
|
@@ -152,13 +152,17 @@ test("shell-web generic link items support the expected shared route and icon be
|
|
|
152
152
|
assert.match(shellMenuSource, /exact:\s*\{/);
|
|
153
153
|
assert.match(shellMenuSource, /:exact="props\.exact"/);
|
|
154
154
|
assert.match(shellMenuSource, /:aria-label="props\.label"/);
|
|
155
|
-
assert.match(shellMenuSource, /
|
|
155
|
+
assert.match(shellMenuSource, /ShellNavigationTooltip/);
|
|
156
|
+
assert.match(shellMenuSource, /@keydown\.space="activateShellNavigationLinkOnSpace"/);
|
|
157
|
+
assert.doesNotMatch(shellMenuSource, /v-tooltip="props\.label"/);
|
|
156
158
|
assert.match(shellMenuSource, /class="shell-menu-link-item"/);
|
|
157
159
|
assert.match(shellMenuSource, /min-height:\s*48px/);
|
|
158
160
|
assert.match(shellSurfaceAwareSource, /exact:\s*\{/);
|
|
159
161
|
assert.match(shellSurfaceAwareSource, /:exact="props\.exact"/);
|
|
160
162
|
assert.match(shellSurfaceAwareSource, /:aria-label="props\.label"/);
|
|
161
|
-
assert.match(shellSurfaceAwareSource, /
|
|
163
|
+
assert.match(shellSurfaceAwareSource, /ShellNavigationTooltip/);
|
|
164
|
+
assert.match(shellSurfaceAwareSource, /@keydown\.space="activateShellNavigationLinkOnSpace"/);
|
|
165
|
+
assert.doesNotMatch(shellSurfaceAwareSource, /v-tooltip="props\.label"/);
|
|
162
166
|
assert.match(shellSurfaceAwareSource, /class="shell-menu-link-item"/);
|
|
163
167
|
assert.match(shellSurfaceAwareSource, /min-height:\s*48px/);
|
|
164
168
|
assert.match(shellTabSource, /icon:\s*\{/);
|
|
@@ -167,6 +171,18 @@ test("shell-web generic link items support the expected shared route and icon be
|
|
|
167
171
|
assert.match(shellTabSource, /stacked/);
|
|
168
172
|
assert.match(shellTabSource, /min-height:\s*48px/);
|
|
169
173
|
assert.match(shellTabSource, /<v-icon v-if="resolvedIcon" :icon="resolvedIcon" \/>/);
|
|
174
|
+
|
|
175
|
+
const tooltipSource = await readFile(
|
|
176
|
+
path.join(PACKAGE_DIR, "src", "client", "components", "ShellNavigationTooltip.vue"),
|
|
177
|
+
"utf8"
|
|
178
|
+
);
|
|
179
|
+
assert.match(tooltipSource, /:theme="theme\.name\.value"/);
|
|
180
|
+
assert.match(tooltipSource, /location="end"/);
|
|
181
|
+
assert.match(tooltipSource, /:open-on-focus="true"/);
|
|
182
|
+
assert.match(tooltipSource, /:open-on-hover="true"/);
|
|
183
|
+
assert.match(tooltipSource, /--v-theme-inverse-surface, var\(--v-theme-on-surface\)/);
|
|
184
|
+
assert.match(tooltipSource, /--v-theme-inverse-on-surface, var\(--v-theme-surface\)/);
|
|
185
|
+
assert.match(tooltipSource, /opacity:\s*1\s*!important/);
|
|
170
186
|
});
|
|
171
187
|
|
|
172
188
|
test("shell-web binds the local link-item wrapper tokens into MainClientProvider", () => {
|