@jskit-ai/shell-web 0.1.150 → 0.1.152
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 +11 -0
- package/fixtures/adaptive-shell/src/App.vue +8 -1
- package/package.descriptor.mjs +1 -1
- package/package.json +1 -1
- package/src/client/components/ShellLayout.vue +41 -9
- package/src/client/support/drawerWidth.js +24 -7
- package/src/test/adaptiveShellSmoke.js +46 -12
- package/templates/src/components/ShellLayout.vue +1 -1
- package/test/adaptiveShell.browser.test.js +41 -2
- package/test/drawerWidth.test.js +14 -3
- package/test/playwrightContract.test.js +4 -2
- package/test/settingsPlacementContract.test.js +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.152 - 2026-08-13
|
|
4
|
+
|
|
5
|
+
- Remove the redundant uppercase surface subheader from the expanded drawer; the top app bar remains the single shell-owned surface label.
|
|
6
|
+
- Align expanded drawer icons to the collapsed rail centreline so toggling navigation reveals or hides labels without shifting the icons horizontally.
|
|
7
|
+
- Preserve the Material 3 80px default rail and the public `railWidth` and `navigationItemSpacing` density controls.
|
|
8
|
+
|
|
9
|
+
## 0.1.151 - 2026-08-13
|
|
10
|
+
|
|
11
|
+
- Replace Vuetify's oversized default drawer icon spacer with one public `navigationItemSpacing` value, defaulting to 12px for both icon-to-label and widest-label-to-edge spacing.
|
|
12
|
+
- Keep the Material 3 80px default rail while documenting `railWidth` as the supported product-density override.
|
|
13
|
+
|
|
3
14
|
## 0.1.150 - 2026-08-13
|
|
4
15
|
|
|
5
16
|
- Give shell navigation tooltips one opaque, theme-owned Material color pair on hover and keyboard focus.
|
|
@@ -9,6 +9,8 @@ const theme = useTheme();
|
|
|
9
9
|
const surface = computed(() => route.path.startsWith("/w/") ? "admin" : "home");
|
|
10
10
|
const surfaceLabel = computed(() => surface.value === "admin" ? "Admin" : "Home");
|
|
11
11
|
const themeMode = computed(() => route.query.theme === "dark" ? "dark" : "light");
|
|
12
|
+
const railWidth = computed(() => Number(route.query.railWidth) || undefined);
|
|
13
|
+
const navigationItemSpacing = computed(() => Number(route.query.navigationItemSpacing) || undefined);
|
|
12
14
|
|
|
13
15
|
watchEffect(function applyFixtureTheme() {
|
|
14
16
|
const themeName = `${surface.value}-${themeMode.value}`;
|
|
@@ -20,7 +22,12 @@ watchEffect(function applyFixtureTheme() {
|
|
|
20
22
|
|
|
21
23
|
<template>
|
|
22
24
|
<v-app>
|
|
23
|
-
<ShellLayout
|
|
25
|
+
<ShellLayout
|
|
26
|
+
:surface="surface"
|
|
27
|
+
:surface-label="surfaceLabel"
|
|
28
|
+
:rail-width="railWidth"
|
|
29
|
+
:navigation-item-spacing="navigationItemSpacing"
|
|
30
|
+
>
|
|
24
31
|
<RouterView />
|
|
25
32
|
</ShellLayout>
|
|
26
33
|
</v-app>
|
package/package.descriptor.mjs
CHANGED
package/package.json
CHANGED
|
@@ -16,9 +16,10 @@ import {
|
|
|
16
16
|
} from "../support/drawerPresentation.js";
|
|
17
17
|
import {
|
|
18
18
|
DEFAULT_SHELL_DRAWER_WIDTH,
|
|
19
|
+
DEFAULT_SHELL_NAVIGATION_ITEM_SPACING,
|
|
19
20
|
DEFAULT_SHELL_RAIL_WIDTH,
|
|
20
|
-
SHELL_DRAWER_LABEL_END_GAP,
|
|
21
21
|
normalizeShellDrawerWidth,
|
|
22
|
+
normalizeShellNavigationItemSpacing,
|
|
22
23
|
normalizeShellRailWidth,
|
|
23
24
|
resolveContentAwareDrawerWidth
|
|
24
25
|
} from "../support/drawerWidth.js";
|
|
@@ -53,6 +54,10 @@ const props = defineProps({
|
|
|
53
54
|
railWidth: {
|
|
54
55
|
type: Number,
|
|
55
56
|
default: DEFAULT_SHELL_RAIL_WIDTH
|
|
57
|
+
},
|
|
58
|
+
navigationItemSpacing: {
|
|
59
|
+
type: Number,
|
|
60
|
+
default: DEFAULT_SHELL_NAVIGATION_ITEM_SPACING
|
|
56
61
|
}
|
|
57
62
|
});
|
|
58
63
|
|
|
@@ -111,6 +116,9 @@ const resolvedDrawerWidth = computed(() => {
|
|
|
111
116
|
return normalizeShellDrawerWidth(measuredDrawerWidth.value);
|
|
112
117
|
});
|
|
113
118
|
const resolvedRailWidth = computed(() => normalizeShellRailWidth(props.railWidth));
|
|
119
|
+
const resolvedNavigationItemSpacing = computed(() =>
|
|
120
|
+
normalizeShellNavigationItemSpacing(props.navigationItemSpacing)
|
|
121
|
+
);
|
|
114
122
|
const pullProgress = computed(() =>
|
|
115
123
|
Math.min(100, Math.round((pullDistance.value / PULL_REFRESH_TRIGGER_DISTANCE) * 100))
|
|
116
124
|
);
|
|
@@ -140,6 +148,7 @@ watch(
|
|
|
140
148
|
|
|
141
149
|
watch(drawerPresentation, scheduleDrawerWidthMeasurement, { flush: "post" });
|
|
142
150
|
watch(resolvedSurface, scheduleDrawerWidthMeasurement, { flush: "post" });
|
|
151
|
+
watch(resolvedNavigationItemSpacing, scheduleDrawerWidthMeasurement, { flush: "post" });
|
|
143
152
|
|
|
144
153
|
onMounted(attachShellListeners);
|
|
145
154
|
onBeforeUnmount(detachShellListeners);
|
|
@@ -299,7 +308,7 @@ function measureDrawerContentWidth() {
|
|
|
299
308
|
}
|
|
300
309
|
|
|
301
310
|
const nextWidth = resolveContentAwareDrawerWidth(measurements, {
|
|
302
|
-
endGap:
|
|
311
|
+
endGap: resolvedNavigationItemSpacing.value + endBorderWidth
|
|
303
312
|
});
|
|
304
313
|
if (Math.abs(nextWidth - measuredDrawerWidth.value) >= 0.25) {
|
|
305
314
|
measuredDrawerWidth.value = nextWidth;
|
|
@@ -617,8 +626,12 @@ function touchListIncludesActiveTouch(touchList) {
|
|
|
617
626
|
:data-presentation="drawerPresentation.kind"
|
|
618
627
|
:data-drawer-width-mode="props.drawerWidth === null || props.drawerWidth === undefined ? 'content' : 'fixed'"
|
|
619
628
|
:data-drawer-width="resolvedDrawerWidth"
|
|
629
|
+
:data-navigation-item-spacing="resolvedNavigationItemSpacing"
|
|
620
630
|
:data-rail-width="resolvedRailWidth"
|
|
621
|
-
:style="{
|
|
631
|
+
:style="{
|
|
632
|
+
'--shell-navigation-item-spacing': `${resolvedNavigationItemSpacing}px`,
|
|
633
|
+
'--shell-navigation-rail-width': `${resolvedRailWidth}px`
|
|
634
|
+
}"
|
|
622
635
|
:temporary="isCompactLayout"
|
|
623
636
|
:permanent="!isCompactLayout"
|
|
624
637
|
:width="resolvedDrawerWidth"
|
|
@@ -627,10 +640,12 @@ function touchListIncludesActiveTouch(touchList) {
|
|
|
627
640
|
@update:model-value="handleDrawerVisibilityChange"
|
|
628
641
|
>
|
|
629
642
|
<slot name="menu" :surface="resolvedSurface" :rail="drawerPresentation.rail">
|
|
630
|
-
<v-list
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
643
|
+
<v-list
|
|
644
|
+
nav
|
|
645
|
+
density="comfortable"
|
|
646
|
+
class="pt-2"
|
|
647
|
+
:prepend-gap="resolvedNavigationItemSpacing"
|
|
648
|
+
>
|
|
634
649
|
<ShellOutlet
|
|
635
650
|
target="shell-layout:primary-menu"
|
|
636
651
|
default
|
|
@@ -719,9 +734,26 @@ function touchListIncludesActiveTouch(touchList) {
|
|
|
719
734
|
white-space: nowrap;
|
|
720
735
|
}
|
|
721
736
|
|
|
722
|
-
.shell-layout__drawer
|
|
737
|
+
.shell-layout__drawer {
|
|
738
|
+
--shell-navigation-drawer-inset: 12px;
|
|
739
|
+
--shell-navigation-icon-size: 24px;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
.shell-layout__drawer[data-presentation="drawer"] :deep(.v-list--nav) {
|
|
743
|
+
padding-inline-end: calc(var(--shell-navigation-item-spacing) / 2);
|
|
744
|
+
padding-inline-start: var(--shell-navigation-drawer-inset);
|
|
745
|
+
}
|
|
746
|
+
|
|
723
747
|
.shell-layout__drawer[data-presentation="drawer"] :deep(.v-list-item) {
|
|
724
|
-
padding-inline-end: calc(var(--shell-
|
|
748
|
+
padding-inline-end: calc(var(--shell-navigation-item-spacing) / 2);
|
|
749
|
+
padding-inline-start: max(
|
|
750
|
+
0px,
|
|
751
|
+
calc(
|
|
752
|
+
var(--shell-navigation-rail-width) / 2 -
|
|
753
|
+
var(--shell-navigation-icon-size) / 2 -
|
|
754
|
+
var(--shell-navigation-drawer-inset)
|
|
755
|
+
)
|
|
756
|
+
);
|
|
725
757
|
}
|
|
726
758
|
|
|
727
759
|
.shell-layout__drawer[data-presentation="rail"] :deep(.v-list-item) {
|
|
@@ -4,7 +4,9 @@ const MINIMUM_SHELL_DRAWER_WIDTH = 120;
|
|
|
4
4
|
const MAXIMUM_SHELL_DRAWER_WIDTH = 360;
|
|
5
5
|
const MINIMUM_SHELL_RAIL_WIDTH = 48;
|
|
6
6
|
const MAXIMUM_SHELL_RAIL_WIDTH = 160;
|
|
7
|
-
const
|
|
7
|
+
const DEFAULT_SHELL_NAVIGATION_ITEM_SPACING = 12;
|
|
8
|
+
const MINIMUM_SHELL_NAVIGATION_ITEM_SPACING = 8;
|
|
9
|
+
const MAXIMUM_SHELL_NAVIGATION_ITEM_SPACING = 24;
|
|
8
10
|
|
|
9
11
|
function clampNumber(value, minimum, maximum) {
|
|
10
12
|
return Math.min(maximum, Math.max(minimum, value));
|
|
@@ -34,10 +36,25 @@ function normalizeShellRailWidth(value, fallback = DEFAULT_SHELL_RAIL_WIDTH) {
|
|
|
34
36
|
);
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
function normalizeShellNavigationItemSpacing(
|
|
40
|
+
value,
|
|
41
|
+
fallback = DEFAULT_SHELL_NAVIGATION_ITEM_SPACING
|
|
42
|
+
) {
|
|
43
|
+
const parsed = Number(value);
|
|
44
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
return clampNumber(
|
|
48
|
+
Math.round(parsed),
|
|
49
|
+
MINIMUM_SHELL_NAVIGATION_ITEM_SPACING,
|
|
50
|
+
MAXIMUM_SHELL_NAVIGATION_ITEM_SPACING
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
function resolveContentAwareDrawerWidth(
|
|
38
55
|
measurements = [],
|
|
39
56
|
{
|
|
40
|
-
endGap =
|
|
57
|
+
endGap = DEFAULT_SHELL_NAVIGATION_ITEM_SPACING,
|
|
41
58
|
minimum = MINIMUM_SHELL_DRAWER_WIDTH,
|
|
42
59
|
maximum = MAXIMUM_SHELL_DRAWER_WIDTH,
|
|
43
60
|
fallback = DEFAULT_SHELL_DRAWER_WIDTH
|
|
@@ -56,19 +73,19 @@ function resolveContentAwareDrawerWidth(
|
|
|
56
73
|
if (requiredWidth <= 0) {
|
|
57
74
|
return normalizeShellDrawerWidth(fallback);
|
|
58
75
|
}
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
return clampNumber(Math.ceil(requiredWidth), minimum, maximum);
|
|
76
|
+
// Preserve subpixel font metrics while rounding outward enough to avoid
|
|
77
|
+
// clipping the final glyph at the exact spacing boundary.
|
|
78
|
+
return clampNumber(Math.ceil(requiredWidth * 4) / 4, minimum, maximum);
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
export {
|
|
66
82
|
DEFAULT_SHELL_DRAWER_WIDTH,
|
|
83
|
+
DEFAULT_SHELL_NAVIGATION_ITEM_SPACING,
|
|
67
84
|
DEFAULT_SHELL_RAIL_WIDTH,
|
|
68
85
|
MAXIMUM_SHELL_DRAWER_WIDTH,
|
|
69
86
|
MINIMUM_SHELL_DRAWER_WIDTH,
|
|
70
|
-
SHELL_DRAWER_LABEL_END_GAP,
|
|
71
87
|
normalizeShellDrawerWidth,
|
|
88
|
+
normalizeShellNavigationItemSpacing,
|
|
72
89
|
normalizeShellRailWidth,
|
|
73
90
|
resolveContentAwareDrawerWidth
|
|
74
91
|
};
|
|
@@ -104,6 +104,8 @@ async function expectContentAwareDrawerFit(drawer, expect) {
|
|
|
104
104
|
if (await drawer.getAttribute("data-drawer-width-mode") !== "content") {
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
|
+
const configuredSpacing = Number(await drawer.getAttribute("data-navigation-item-spacing"));
|
|
108
|
+
expect(Number.isFinite(configuredSpacing)).toBe(true);
|
|
107
109
|
|
|
108
110
|
const fit = await drawer.evaluate((element) => {
|
|
109
111
|
const drawerRect = element.getBoundingClientRect();
|
|
@@ -125,6 +127,8 @@ async function expectContentAwareDrawerFit(drawer, expect) {
|
|
|
125
127
|
range.selectNodeContents(label);
|
|
126
128
|
const textRect = range.getBoundingClientRect();
|
|
127
129
|
range.detach?.();
|
|
130
|
+
const icon = label.closest(".v-list-item")?.querySelector(".v-icon");
|
|
131
|
+
const iconRect = icon?.getBoundingClientRect();
|
|
128
132
|
return {
|
|
129
133
|
clipped: (
|
|
130
134
|
label.scrollWidth > label.clientWidth ||
|
|
@@ -133,6 +137,9 @@ async function expectContentAwareDrawerFit(drawer, expect) {
|
|
|
133
137
|
? textRect.left < innerEnd - 1
|
|
134
138
|
: textRect.right > innerEnd + 1)
|
|
135
139
|
),
|
|
140
|
+
iconLabelGap: iconRect
|
|
141
|
+
? rightToLeft ? iconRect.left - textRect.right : textRect.left - iconRect.right
|
|
142
|
+
: null,
|
|
136
143
|
logicalEnd: rightToLeft ? textRect.left : textRect.right,
|
|
137
144
|
width: textRect.width
|
|
138
145
|
};
|
|
@@ -154,32 +161,58 @@ async function expectContentAwareDrawerFit(drawer, expect) {
|
|
|
154
161
|
clipped: labels.some((label) => label.clipped),
|
|
155
162
|
endGap: rightToLeft
|
|
156
163
|
? furthestLabel.logicalEnd - innerEnd
|
|
157
|
-
: innerEnd - furthestLabel.logicalEnd
|
|
164
|
+
: innerEnd - furthestLabel.logicalEnd,
|
|
165
|
+
iconLabelGaps: labels
|
|
166
|
+
.map((label) => label.iconLabelGap)
|
|
167
|
+
.filter((gap) => Number.isFinite(gap))
|
|
158
168
|
};
|
|
159
169
|
});
|
|
160
170
|
|
|
161
171
|
expect(fit).not.toBeNull();
|
|
162
172
|
expect(fit.clipped).toBe(false);
|
|
163
|
-
expect(fit.endGap).
|
|
164
|
-
expect(fit.
|
|
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 readNavigationIconCenters(drawer) {
|
|
181
|
+
const icons = drawer.locator("a.shell-menu-link-item[href] .v-icon");
|
|
182
|
+
await icons.first().waitFor({ state: "visible" });
|
|
183
|
+
const centers = [];
|
|
184
|
+
const iconCount = await icons.count();
|
|
185
|
+
for (let index = 0; index < iconCount; index += 1) {
|
|
186
|
+
const box = await icons.nth(index).boundingBox();
|
|
187
|
+
if (!box) {
|
|
188
|
+
throw new Error("Shell navigation exposed an unmeasurable icon.");
|
|
189
|
+
}
|
|
190
|
+
centers.push(box.x + box.width / 2);
|
|
191
|
+
}
|
|
192
|
+
return centers;
|
|
165
193
|
}
|
|
166
194
|
|
|
167
|
-
async function expectCentredRailNavigation(page, drawer, expect) {
|
|
195
|
+
async function expectCentredRailNavigation(page, drawer, expect, expandedIconCenters) {
|
|
168
196
|
const links = drawer.locator("a.shell-menu-link-item[href]");
|
|
169
197
|
await expect(links.first()).toBeVisible();
|
|
170
198
|
const firstLink = links.first();
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
const [drawerBox, linkBox, iconBox] = geometry;
|
|
199
|
+
const geometry = await Promise.all([drawer.boundingBox(), firstLink.boundingBox()]);
|
|
200
|
+
const [drawerBox, linkBox] = geometry;
|
|
174
201
|
|
|
175
202
|
expect(drawerBox).not.toBeNull();
|
|
176
203
|
expect(linkBox).not.toBeNull();
|
|
177
|
-
expect(iconBox).not.toBeNull();
|
|
178
204
|
expect(linkBox.width).toBeGreaterThanOrEqual(48);
|
|
179
205
|
expect(linkBox.height).toBeGreaterThanOrEqual(48);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
206
|
+
const railIconCenters = await readNavigationIconCenters(drawer);
|
|
207
|
+
expect(railIconCenters.length).toBe(expandedIconCenters.length);
|
|
208
|
+
for (let index = 0; index < railIconCenters.length; index += 1) {
|
|
209
|
+
expect(Math.abs(
|
|
210
|
+
railIconCenters[index] - (drawerBox.x + drawerBox.width / 2)
|
|
211
|
+
)).toBeLessThanOrEqual(1);
|
|
212
|
+
expect(Math.abs(
|
|
213
|
+
railIconCenters[index] - expandedIconCenters[index]
|
|
214
|
+
)).toBeLessThanOrEqual(1);
|
|
215
|
+
}
|
|
183
216
|
|
|
184
217
|
const currentUrl = new URL(page.url());
|
|
185
218
|
const linkCount = await links.count();
|
|
@@ -275,13 +308,14 @@ async function runAdaptiveShellSmokeCase({
|
|
|
275
308
|
await expect(drawer).toHaveAttribute("data-presentation", "drawer");
|
|
276
309
|
await expectDrawerWidth(drawer, expect, await readConfiguredWidth(drawer, "data-drawer-width"));
|
|
277
310
|
await expectContentAwareDrawerFit(drawer, expect);
|
|
311
|
+
const expandedIconCenters = await readNavigationIconCenters(drawer);
|
|
278
312
|
await page.keyboard.press("Escape");
|
|
279
313
|
await expect(drawer).toHaveAttribute("data-presentation", "drawer");
|
|
280
314
|
|
|
281
315
|
await toggle.click();
|
|
282
316
|
await expect(drawer).toHaveAttribute("data-presentation", "rail");
|
|
283
317
|
await expectDrawerWidth(drawer, expect, await readConfiguredWidth(drawer, "data-rail-width"));
|
|
284
|
-
await expectCentredRailNavigation(page, drawer, expect);
|
|
318
|
+
await expectCentredRailNavigation(page, drawer, expect, expandedIconCenters);
|
|
285
319
|
await expectNoHorizontalOverflow(page, expect);
|
|
286
320
|
}
|
|
287
321
|
}
|
|
@@ -6,7 +6,7 @@ export default {
|
|
|
6
6
|
name: "ShellLayout",
|
|
7
7
|
inheritAttrs: false,
|
|
8
8
|
setup(_, { attrs, slots }) {
|
|
9
|
-
// Keep drawerWidth, railWidth, and future shell props package-owned.
|
|
9
|
+
// Keep drawerWidth, railWidth, navigationItemSpacing, and future shell props package-owned.
|
|
10
10
|
return () => h(PackageShellLayout, attrs, slots);
|
|
11
11
|
}
|
|
12
12
|
};
|
|
@@ -166,6 +166,13 @@ async function assertTooltipContrast(page, linkName, interaction) {
|
|
|
166
166
|
async function assertDrawerFit(page) {
|
|
167
167
|
const drawer = page.getByTestId("jskit-shell-drawer");
|
|
168
168
|
await expect(drawer).toHaveAttribute("data-presentation", "drawer");
|
|
169
|
+
assert.equal(
|
|
170
|
+
await drawer.locator(".v-list-subheader").count(),
|
|
171
|
+
0,
|
|
172
|
+
"The drawer must not repeat the surface label from the top app bar."
|
|
173
|
+
);
|
|
174
|
+
const configuredSpacing = Number(await drawer.getAttribute("data-navigation-item-spacing"));
|
|
175
|
+
assert.ok(Number.isFinite(configuredSpacing));
|
|
169
176
|
await expect.poll(async () => {
|
|
170
177
|
const configured = Number(await drawer.getAttribute("data-drawer-width"));
|
|
171
178
|
const box = await drawer.boundingBox();
|
|
@@ -180,12 +187,14 @@ async function assertDrawerFit(page) {
|
|
|
180
187
|
range.selectNodeContents(label);
|
|
181
188
|
const rect = range.getBoundingClientRect();
|
|
182
189
|
range.detach?.();
|
|
190
|
+
const iconRect = label.closest(".v-list-item")?.querySelector(".v-icon")?.getBoundingClientRect();
|
|
183
191
|
return {
|
|
184
192
|
clientHeight: label.clientHeight,
|
|
185
193
|
clientWidth: label.clientWidth,
|
|
186
194
|
rect,
|
|
187
195
|
scrollHeight: label.scrollHeight,
|
|
188
|
-
scrollWidth: label.scrollWidth
|
|
196
|
+
scrollWidth: label.scrollWidth,
|
|
197
|
+
iconLabelGap: iconRect ? rect.left - iconRect.right : null
|
|
189
198
|
};
|
|
190
199
|
});
|
|
191
200
|
const furthestEnd = Math.max(...labels.map((label) => label.rect.right));
|
|
@@ -195,11 +204,24 @@ async function assertDrawerFit(page) {
|
|
|
195
204
|
label.scrollHeight > label.clientHeight
|
|
196
205
|
)),
|
|
197
206
|
endGap: innerEnd - furthestEnd,
|
|
207
|
+
iconLabelGaps: labels
|
|
208
|
+
.map((label) => label.iconLabelGap)
|
|
209
|
+
.filter((gap) => Number.isFinite(gap)),
|
|
198
210
|
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth
|
|
199
211
|
};
|
|
200
212
|
});
|
|
201
213
|
assert.equal(result.clipped, false, "A drawer label was clipped or ellipsized.");
|
|
202
|
-
assert.ok(
|
|
214
|
+
assert.ok(
|
|
215
|
+
Math.abs(result.endGap - configuredSpacing) <= 1.1,
|
|
216
|
+
`Drawer label end gap was ${result.endGap}px; expected ${configuredSpacing}px.`
|
|
217
|
+
);
|
|
218
|
+
assert.ok(result.iconLabelGaps.length > 0, "Drawer links exposed no measurable icon/label gap.");
|
|
219
|
+
for (const gap of result.iconLabelGaps) {
|
|
220
|
+
assert.ok(
|
|
221
|
+
Math.abs(gap - configuredSpacing) <= 1,
|
|
222
|
+
`Drawer icon/label gap was ${gap}px; expected ${configuredSpacing}px.`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
203
225
|
assert.ok(result.overflow <= 1, `Page overflowed horizontally by ${result.overflow}px.`);
|
|
204
226
|
}
|
|
205
227
|
|
|
@@ -282,6 +304,23 @@ test("shell-web adaptive navigation passes package-owned browser contracts", {
|
|
|
282
304
|
await assertDrawerFit(railPage);
|
|
283
305
|
await assertRailKeyboardAndSelection(railPage);
|
|
284
306
|
await railPage.close();
|
|
307
|
+
|
|
308
|
+
const configuredPage = await context.newPage();
|
|
309
|
+
await configuredPage.setViewportSize({ width: 1280, height: 900 });
|
|
310
|
+
await configuredPage.goto(
|
|
311
|
+
"/w/acme/admin/bookings?theme=light&railWidth=64&navigationItemSpacing=8"
|
|
312
|
+
);
|
|
313
|
+
const configuredDrawer = configuredPage.getByTestId("jskit-shell-drawer");
|
|
314
|
+
await expect(configuredDrawer).toHaveAttribute("data-navigation-item-spacing", "8");
|
|
315
|
+
await assertDrawerFit(configuredPage);
|
|
316
|
+
await configuredPage.getByTestId("jskit-shell-nav-toggle").click();
|
|
317
|
+
await expect(configuredDrawer).toHaveAttribute("data-presentation", "rail");
|
|
318
|
+
await expect(configuredDrawer).toHaveAttribute("data-rail-width", "64");
|
|
319
|
+
await expect.poll(async () => {
|
|
320
|
+
const box = await configuredDrawer.boundingBox();
|
|
321
|
+
return box ? Math.abs(box.width - 64) : Number.POSITIVE_INFINITY;
|
|
322
|
+
}).toBeLessThanOrEqual(1);
|
|
323
|
+
await configuredPage.close();
|
|
285
324
|
await context.close();
|
|
286
325
|
} finally {
|
|
287
326
|
await browser?.close();
|
package/test/drawerWidth.test.js
CHANGED
|
@@ -2,29 +2,32 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_SHELL_DRAWER_WIDTH,
|
|
5
|
+
DEFAULT_SHELL_NAVIGATION_ITEM_SPACING,
|
|
5
6
|
DEFAULT_SHELL_RAIL_WIDTH,
|
|
6
7
|
normalizeShellDrawerWidth,
|
|
8
|
+
normalizeShellNavigationItemSpacing,
|
|
7
9
|
normalizeShellRailWidth,
|
|
8
10
|
resolveContentAwareDrawerWidth
|
|
9
11
|
} from "../src/client/support/drawerWidth.js";
|
|
10
12
|
|
|
11
|
-
test("content-aware drawer width includes the rendered label start, width, and
|
|
13
|
+
test("content-aware drawer width includes the rendered label start, width, and balanced end spacing", () => {
|
|
12
14
|
assert.equal(
|
|
13
15
|
resolveContentAwareDrawerWidth([
|
|
14
16
|
{ logicalStart: 72, textWidth: 40 },
|
|
15
17
|
{ logicalStart: 72, textWidth: 78 }
|
|
16
18
|
]),
|
|
17
|
-
|
|
19
|
+
162
|
|
18
20
|
);
|
|
19
21
|
assert.equal(
|
|
20
22
|
resolveContentAwareDrawerWidth([
|
|
21
23
|
{ logicalStart: 72, textWidth: 99.015625 }
|
|
22
24
|
]),
|
|
23
|
-
|
|
25
|
+
183.25
|
|
24
26
|
);
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
test("drawer and rail widths have stable defaults and bounded overrides", () => {
|
|
30
|
+
assert.equal(DEFAULT_SHELL_RAIL_WIDTH, 80);
|
|
28
31
|
assert.equal(normalizeShellDrawerWidth(null), DEFAULT_SHELL_DRAWER_WIDTH);
|
|
29
32
|
assert.equal(normalizeShellDrawerWidth(180), 180);
|
|
30
33
|
assert.equal(normalizeShellDrawerWidth(1000), 360);
|
|
@@ -33,6 +36,14 @@ test("drawer and rail widths have stable defaults and bounded overrides", () =>
|
|
|
33
36
|
assert.equal(normalizeShellRailWidth(20), 48);
|
|
34
37
|
});
|
|
35
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
|
+
|
|
36
47
|
test("content-aware width ignores invalid measurements and uses a bounded fallback", () => {
|
|
37
48
|
assert.equal(
|
|
38
49
|
resolveContentAwareDrawerWidth([
|
|
@@ -29,13 +29,15 @@ test("adaptive shell smoke follows rendered layout state and waits for drawer tr
|
|
|
29
29
|
const source = await readFile(path.join(PACKAGE_ROOT, "src/test/adaptiveShellSmoke.js"), "utf8");
|
|
30
30
|
|
|
31
31
|
assert.match(source, /data-layout/u);
|
|
32
|
+
assert.match(source, /data-navigation-item-spacing/u);
|
|
32
33
|
assert.match(source, /data-rail-width/u);
|
|
33
34
|
assert.match(source, /expect\.poll/u);
|
|
34
35
|
assert.match(source, /toBeFocused/u);
|
|
35
36
|
assert.match(source, /v-navigation-drawer__scrim/u);
|
|
36
37
|
assert.match(source, /page\.keyboard\.press\("Escape"\)[\s\S]*data-presentation", "drawer"/u);
|
|
37
|
-
assert.match(source, /
|
|
38
|
-
assert.match(source, /endGap
|
|
38
|
+
assert.match(source, /centers\.push\(box\.x \+ box\.width \/ 2\)/u);
|
|
39
|
+
assert.match(source, /fit\.endGap - configuredSpacing/u);
|
|
40
|
+
assert.match(source, /fit\.iconLabelGaps/u);
|
|
39
41
|
assert.doesNotMatch(source, /viewport\.name === "compact"/u);
|
|
40
42
|
assert.doesNotMatch(source, /await new Promise.*setTimeout|waitForTimeout/u);
|
|
41
43
|
});
|
|
@@ -100,13 +100,20 @@ test("shell-web shell layout registers navigation at the app layout level", asyn
|
|
|
100
100
|
assert.match(source, /:rail="drawerPresentation\.rail"/);
|
|
101
101
|
assert.match(source, /drawerWidth/);
|
|
102
102
|
assert.match(source, /railWidth/);
|
|
103
|
+
assert.match(source, /navigationItemSpacing/);
|
|
103
104
|
assert.match(source, /:width="resolvedDrawerWidth"/);
|
|
104
105
|
assert.match(source, /:rail-width="resolvedRailWidth"/);
|
|
105
106
|
assert.match(source, /data-drawer-width="resolvedDrawerWidth"/);
|
|
107
|
+
assert.match(source, /data-navigation-item-spacing="resolvedNavigationItemSpacing"/);
|
|
106
108
|
assert.match(source, /data-rail-width="resolvedRailWidth"/);
|
|
107
109
|
assert.match(source, /data-layout="layoutClass"/);
|
|
108
110
|
assert.match(source, /measureDrawerContentWidth/);
|
|
109
|
-
assert.match(source, /
|
|
111
|
+
assert.match(source, /DEFAULT_SHELL_NAVIGATION_ITEM_SPACING/);
|
|
112
|
+
assert.match(source, /:prepend-gap="resolvedNavigationItemSpacing"/);
|
|
113
|
+
assert.match(source, /--shell-navigation-drawer-inset:\s*12px/);
|
|
114
|
+
assert.match(source, /--shell-navigation-rail-width/);
|
|
115
|
+
assert.match(source, /padding-inline-start:\s*var\(--shell-navigation-drawer-inset\)/);
|
|
116
|
+
assert.doesNotMatch(source, /<v-list-subheader/);
|
|
110
117
|
assert.doesNotMatch(source, /:width="248"/);
|
|
111
118
|
assert.doesNotMatch(source, /:rail-width="80"/);
|
|
112
119
|
assert.match(source, /:model-value="drawerPresentation\.visible"/);
|
|
@@ -117,7 +124,7 @@ test("shell-web shell layout registers navigation at the app layout level", asyn
|
|
|
117
124
|
const template = await readFile(path.join(PACKAGE_DIR, "templates", "src", "components", "ShellLayout.vue"), "utf8");
|
|
118
125
|
|
|
119
126
|
assert.match(template, /PackageShellLayout from "@jskit-ai\/shell-web\/client\/components\/ShellLayout"/);
|
|
120
|
-
assert.match(template, /drawerWidth, railWidth, and future shell props package-owned/);
|
|
127
|
+
assert.match(template, /drawerWidth, railWidth, navigationItemSpacing, and future shell props package-owned/);
|
|
121
128
|
assert.match(template, /h\(PackageShellLayout, attrs, slots\)/);
|
|
122
129
|
assert.doesNotMatch(template, /ShellOutlet|ShellRouteTransition|useShellLayoutState|pointerdown|v-navigation-drawer|v-bottom-navigation/);
|
|
123
130
|
});
|