@jskit-ai/shell-web 0.1.149 → 0.1.150

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.
@@ -5,6 +5,8 @@ import {
5
5
  useWebPlacementContext
6
6
  } from "../placement/index.js";
7
7
  import { resolveMenuLinkIcon } from "../lib/menuIcons.js";
8
+ import { activateShellNavigationLinkOnSpace } from "../support/navigationLinkKeyboard.js";
9
+ import ShellNavigationTooltip from "./ShellNavigationTooltip.vue";
8
10
 
9
11
  const props = defineProps({
10
12
  label: {
@@ -59,18 +61,22 @@ const resolvedIcon = computed(() =>
59
61
  </script>
60
62
 
61
63
  <template>
62
- <v-list-item
63
- v-if="resolvedTarget.href"
64
- class="shell-menu-link-item"
65
- :title="props.label"
66
- :to="resolvedTarget.sameOrigin ? resolvedTarget.href : undefined"
67
- :href="resolvedTarget.sameOrigin ? undefined : resolvedTarget.href"
68
- :prepend-icon="resolvedIcon || undefined"
69
- :disabled="props.disabled"
70
- :exact="props.exact"
71
- :aria-label="props.label"
72
- v-tooltip="props.label"
73
- />
64
+ <ShellNavigationTooltip v-if="resolvedTarget.href" :label="props.label">
65
+ <template #default="{ activatorProps }">
66
+ <v-list-item
67
+ v-bind="activatorProps"
68
+ class="shell-menu-link-item"
69
+ :title="props.label"
70
+ :to="resolvedTarget.sameOrigin ? resolvedTarget.href : undefined"
71
+ :href="resolvedTarget.sameOrigin ? undefined : resolvedTarget.href"
72
+ :prepend-icon="resolvedIcon || undefined"
73
+ :disabled="props.disabled"
74
+ :exact="props.exact"
75
+ :aria-label="props.label"
76
+ @keydown.space="activateShellNavigationLinkOnSpace"
77
+ />
78
+ </template>
79
+ </ShellNavigationTooltip>
74
80
  </template>
75
81
 
76
82
  <style scoped>
@@ -0,0 +1,37 @@
1
+ <script setup>
2
+ import { useTheme } from "vuetify";
3
+
4
+ const props = defineProps({
5
+ label: {
6
+ type: String,
7
+ default: ""
8
+ }
9
+ });
10
+
11
+ const theme = useTheme();
12
+ </script>
13
+
14
+ <template>
15
+ <v-tooltip
16
+ :text="props.label"
17
+ :theme="theme.name.value"
18
+ location="end"
19
+ content-class="shell-navigation-tooltip"
20
+ :open-on-focus="true"
21
+ :open-on-hover="true"
22
+ >
23
+ <template #activator="{ props: activatorProps }">
24
+ <slot :activator-props="activatorProps" />
25
+ </template>
26
+ </v-tooltip>
27
+ </template>
28
+
29
+ <style>
30
+ .shell-navigation-tooltip {
31
+ background-color:
32
+ rgb(var(--v-theme-inverse-surface, var(--v-theme-on-surface))) !important;
33
+ color:
34
+ rgb(var(--v-theme-inverse-on-surface, var(--v-theme-surface))) !important;
35
+ opacity: 1 !important;
36
+ }
37
+ </style>
@@ -9,6 +9,8 @@ import {
9
9
  } from "../placement/index.js";
10
10
  import { resolveMenuLinkIcon } from "../lib/menuIcons.js";
11
11
  import { resolveMenuLinkTarget } from "../support/menuLinkTarget.js";
12
+ import { activateShellNavigationLinkOnSpace } from "../support/navigationLinkKeyboard.js";
13
+ import ShellNavigationTooltip from "./ShellNavigationTooltip.vue";
12
14
 
13
15
  const props = defineProps({
14
16
  label: {
@@ -104,18 +106,22 @@ const resolvedIcon = computed(() =>
104
106
  </script>
105
107
 
106
108
  <template>
107
- <v-list-item
108
- v-if="resolvedTarget.href"
109
- class="shell-menu-link-item"
110
- :title="props.label"
111
- :to="resolvedTarget.sameOrigin ? resolvedTarget.href : undefined"
112
- :href="resolvedTarget.sameOrigin ? undefined : resolvedTarget.href"
113
- :prepend-icon="resolvedIcon || undefined"
114
- :disabled="props.disabled"
115
- :exact="props.exact"
116
- :aria-label="props.label"
117
- v-tooltip="props.label"
118
- />
109
+ <ShellNavigationTooltip v-if="resolvedTarget.href" :label="props.label">
110
+ <template #default="{ activatorProps }">
111
+ <v-list-item
112
+ v-bind="activatorProps"
113
+ class="shell-menu-link-item"
114
+ :title="props.label"
115
+ :to="resolvedTarget.sameOrigin ? resolvedTarget.href : undefined"
116
+ :href="resolvedTarget.sameOrigin ? undefined : resolvedTarget.href"
117
+ :prepend-icon="resolvedIcon || undefined"
118
+ :disabled="props.disabled"
119
+ :exact="props.exact"
120
+ :aria-label="props.label"
121
+ @keydown.space="activateShellNavigationLinkOnSpace"
122
+ />
123
+ </template>
124
+ </ShellNavigationTooltip>
119
125
  </template>
120
126
 
121
127
  <style scoped>
@@ -0,0 +1,74 @@
1
+ const DEFAULT_SHELL_DRAWER_WIDTH = 248;
2
+ const DEFAULT_SHELL_RAIL_WIDTH = 80;
3
+ const MINIMUM_SHELL_DRAWER_WIDTH = 120;
4
+ const MAXIMUM_SHELL_DRAWER_WIDTH = 360;
5
+ const MINIMUM_SHELL_RAIL_WIDTH = 48;
6
+ const MAXIMUM_SHELL_RAIL_WIDTH = 160;
7
+ const SHELL_DRAWER_LABEL_END_GAP = 10;
8
+
9
+ function clampNumber(value, minimum, maximum) {
10
+ return Math.min(maximum, Math.max(minimum, value));
11
+ }
12
+
13
+ function normalizeShellDrawerWidth(value, fallback = DEFAULT_SHELL_DRAWER_WIDTH) {
14
+ const parsed = Number(value);
15
+ if (!Number.isFinite(parsed) || parsed <= 0) {
16
+ return fallback;
17
+ }
18
+ return clampNumber(
19
+ parsed,
20
+ MINIMUM_SHELL_DRAWER_WIDTH,
21
+ MAXIMUM_SHELL_DRAWER_WIDTH
22
+ );
23
+ }
24
+
25
+ function normalizeShellRailWidth(value, fallback = DEFAULT_SHELL_RAIL_WIDTH) {
26
+ const parsed = Number(value);
27
+ if (!Number.isFinite(parsed) || parsed <= 0) {
28
+ return fallback;
29
+ }
30
+ return clampNumber(
31
+ Math.round(parsed),
32
+ MINIMUM_SHELL_RAIL_WIDTH,
33
+ MAXIMUM_SHELL_RAIL_WIDTH
34
+ );
35
+ }
36
+
37
+ function resolveContentAwareDrawerWidth(
38
+ measurements = [],
39
+ {
40
+ endGap = SHELL_DRAWER_LABEL_END_GAP,
41
+ minimum = MINIMUM_SHELL_DRAWER_WIDTH,
42
+ maximum = MAXIMUM_SHELL_DRAWER_WIDTH,
43
+ fallback = DEFAULT_SHELL_DRAWER_WIDTH
44
+ } = {}
45
+ ) {
46
+ let requiredWidth = 0;
47
+ for (const measurement of measurements) {
48
+ const logicalStart = Number(measurement?.logicalStart);
49
+ const textWidth = Number(measurement?.textWidth);
50
+ if (!Number.isFinite(logicalStart) || logicalStart < 0 || !Number.isFinite(textWidth) || textWidth <= 0) {
51
+ continue;
52
+ }
53
+ requiredWidth = Math.max(requiredWidth, logicalStart + textWidth + Number(endGap || 0));
54
+ }
55
+
56
+ if (requiredWidth <= 0) {
57
+ return normalizeShellDrawerWidth(fallback);
58
+ }
59
+ // Layout boxes expose integer client widths even when the rendered glyph
60
+ // range is fractional. Round outward so the final glyph is never ellipsized
61
+ // at the exact end-gap boundary.
62
+ return clampNumber(Math.ceil(requiredWidth), minimum, maximum);
63
+ }
64
+
65
+ export {
66
+ DEFAULT_SHELL_DRAWER_WIDTH,
67
+ DEFAULT_SHELL_RAIL_WIDTH,
68
+ MAXIMUM_SHELL_DRAWER_WIDTH,
69
+ MINIMUM_SHELL_DRAWER_WIDTH,
70
+ SHELL_DRAWER_LABEL_END_GAP,
71
+ normalizeShellDrawerWidth,
72
+ normalizeShellRailWidth,
73
+ resolveContentAwareDrawerWidth
74
+ };
@@ -0,0 +1,10 @@
1
+ function activateShellNavigationLinkOnSpace(event) {
2
+ if (event?.key !== " " || typeof event.currentTarget?.click !== "function") {
3
+ return;
4
+ }
5
+ event.preventDefault();
6
+ event.stopPropagation();
7
+ event.currentTarget.click();
8
+ }
9
+
10
+ export { activateShellNavigationLinkOnSpace };
@@ -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: 768, height: 1024 }),
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,152 @@ 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
+
108
+ const fit = await drawer.evaluate((element) => {
109
+ const drawerRect = element.getBoundingClientRect();
110
+ const drawerStyle = window.getComputedStyle(element);
111
+ const rightToLeft = drawerStyle.direction === "rtl";
112
+ const endBorderWidth = Number.parseFloat(
113
+ rightToLeft ? drawerStyle.borderLeftWidth : drawerStyle.borderRightWidth
114
+ ) || 0;
115
+ const innerEnd = rightToLeft
116
+ ? drawerRect.left + endBorderWidth
117
+ : drawerRect.right - endBorderWidth;
118
+ const labels = Array.from(element.querySelectorAll(".v-list-item-title"))
119
+ .filter((label) => {
120
+ const style = window.getComputedStyle(label);
121
+ return style.display !== "none" && style.visibility !== "hidden";
122
+ })
123
+ .map((label) => {
124
+ const range = document.createRange();
125
+ range.selectNodeContents(label);
126
+ const textRect = range.getBoundingClientRect();
127
+ range.detach?.();
128
+ return {
129
+ clipped: (
130
+ label.scrollWidth > label.clientWidth ||
131
+ label.scrollHeight > label.clientHeight ||
132
+ (rightToLeft
133
+ ? textRect.left < innerEnd - 1
134
+ : textRect.right > innerEnd + 1)
135
+ ),
136
+ logicalEnd: rightToLeft ? textRect.left : textRect.right,
137
+ width: textRect.width
138
+ };
139
+ })
140
+ .filter((label) => label.width > 0);
141
+
142
+ if (labels.length === 0) {
143
+ return null;
144
+ }
145
+ const furthestLabel = labels.reduce((furthest, label) => {
146
+ if (!furthest) {
147
+ return label;
148
+ }
149
+ return rightToLeft
150
+ ? label.logicalEnd < furthest.logicalEnd ? label : furthest
151
+ : label.logicalEnd > furthest.logicalEnd ? label : furthest;
152
+ }, null);
153
+ return {
154
+ clipped: labels.some((label) => label.clipped),
155
+ endGap: rightToLeft
156
+ ? furthestLabel.logicalEnd - innerEnd
157
+ : innerEnd - furthestLabel.logicalEnd
158
+ };
159
+ });
160
+
161
+ expect(fit).not.toBeNull();
162
+ expect(fit.clipped).toBe(false);
163
+ expect(fit.endGap).toBeGreaterThanOrEqual(9);
164
+ expect(fit.endGap).toBeLessThanOrEqual(11);
165
+ }
166
+
167
+ async function expectCentredRailNavigation(page, drawer, expect) {
168
+ const links = drawer.locator("a.shell-menu-link-item[href]");
169
+ await expect(links.first()).toBeVisible();
170
+ const firstLink = links.first();
171
+ const firstIcon = firstLink.locator(".v-icon").first();
172
+ const geometry = await Promise.all([drawer.boundingBox(), firstLink.boundingBox(), firstIcon.boundingBox()]);
173
+ const [drawerBox, linkBox, iconBox] = geometry;
174
+
175
+ expect(drawerBox).not.toBeNull();
176
+ expect(linkBox).not.toBeNull();
177
+ expect(iconBox).not.toBeNull();
178
+ expect(linkBox.width).toBeGreaterThanOrEqual(48);
179
+ expect(linkBox.height).toBeGreaterThanOrEqual(48);
180
+ expect(Math.abs(
181
+ iconBox.x + iconBox.width / 2 - (drawerBox.x + drawerBox.width / 2)
182
+ )).toBeLessThanOrEqual(1);
183
+
184
+ const currentUrl = new URL(page.url());
185
+ const linkCount = await links.count();
186
+ let navigationLink = null;
187
+ let targetUrl = null;
188
+ for (let index = 0; index < linkCount; index += 1) {
189
+ const candidate = links.nth(index);
190
+ const href = await candidate.getAttribute("href");
191
+ if (!href) {
192
+ continue;
193
+ }
194
+ const resolved = new URL(href, currentUrl);
195
+ if (resolved.pathname !== currentUrl.pathname || resolved.search !== currentUrl.search) {
196
+ navigationLink = candidate;
197
+ targetUrl = resolved;
198
+ break;
199
+ }
200
+ }
201
+
202
+ expect(navigationLink).not.toBeNull();
203
+ await navigationLink.locator(".v-icon").first().click();
204
+ await expect.poll(() => {
205
+ const actual = new URL(page.url());
206
+ return `${actual.pathname}${actual.search}`;
207
+ }).toBe(`${targetUrl.pathname}${targetUrl.search}`);
208
+ }
209
+
210
+ async function openCompactDrawer(page, expect) {
211
+ const drawer = page.getByTestId("jskit-shell-drawer");
212
+ await page.getByTestId("jskit-shell-nav-toggle").click();
213
+ await expect(drawer).toHaveAttribute("data-presentation", "modal");
214
+ await expectElementVisibility(page, expect, "jskit-shell-drawer", true);
215
+ }
216
+
71
217
  async function runAdaptiveShellSmokeCase({
72
218
  page,
73
219
  expect,
@@ -83,8 +229,11 @@ async function runAdaptiveShellSmokeCase({
83
229
  await expect(page.locator("body")).toBeVisible();
84
230
  await expectGeneratedScreenContract(page, expect);
85
231
  await expectNoHorizontalOverflow(page, expect);
232
+ const drawer = page.getByTestId("jskit-shell-drawer");
233
+ const toggle = page.getByTestId("jskit-shell-nav-toggle");
234
+ const layoutClass = await readShellLayoutClass(drawer);
86
235
 
87
- if (viewport.name === "compact") {
236
+ if (layoutClass === "compact") {
88
237
  let bootstrapRequests = 0;
89
238
  await page.route("**/api/bootstrap**", async (route) => {
90
239
  bootstrapRequests += 1;
@@ -93,12 +242,20 @@ async function runAdaptiveShellSmokeCase({
93
242
  const bootstrapRequestsBeforePull = bootstrapRequests;
94
243
 
95
244
  await expect(page.getByTestId("jskit-shell-bottom-nav")).toBeVisible();
96
- expect(await isElementVisibleInViewport(page, "jskit-shell-drawer")).toBe(false);
97
- await page.getByTestId("jskit-shell-nav-toggle").click();
98
- await expect(page.getByTestId("jskit-shell-drawer")).toBeVisible();
99
- await expect(page.getByTestId("jskit-shell-drawer")).toHaveAttribute("data-presentation", "modal");
245
+ await expectElementVisibility(page, expect, "jskit-shell-drawer", false);
246
+
247
+ await openCompactDrawer(page, expect);
248
+ await toggle.click();
249
+ await expectElementVisibility(page, expect, "jskit-shell-drawer", false);
250
+
251
+ await openCompactDrawer(page, expect);
252
+ await page.locator(".v-navigation-drawer__scrim").click();
253
+ await expectElementVisibility(page, expect, "jskit-shell-drawer", false);
254
+
255
+ await openCompactDrawer(page, expect);
100
256
  await page.keyboard.press("Escape");
101
- expect(await isElementVisibleInViewport(page, "jskit-shell-drawer")).toBe(false);
257
+ await expectElementVisibility(page, expect, "jskit-shell-drawer", false);
258
+ await expect(toggle).toBeFocused();
102
259
 
103
260
  const navButtonHeights = await page.getByTestId("jskit-shell-bottom-nav").locator(".v-btn").evaluateAll((buttons) =>
104
261
  buttons.map((button) => button.getBoundingClientRect().height)
@@ -111,15 +268,21 @@ async function runAdaptiveShellSmokeCase({
111
268
  await pullToRefresh(page, expect);
112
269
  await expect.poll(() => bootstrapRequests).toBeGreaterThan(bootstrapRequestsBeforePull);
113
270
  } else {
114
- await expect(page.getByTestId("jskit-shell-drawer")).toBeVisible();
115
- await page.getByTestId("jskit-shell-nav-toggle").click();
116
- await expect(page.getByTestId("jskit-shell-drawer")).toBeVisible();
117
- await expect(page.getByTestId("jskit-shell-drawer")).toHaveAttribute("data-presentation", "rail");
118
- const railWidth = await page.getByTestId("jskit-shell-drawer").evaluate(
119
- (element) => element.getBoundingClientRect().width
120
- );
121
- expect(railWidth).toBeGreaterThanOrEqual(79);
122
- expect(railWidth).toBeLessThanOrEqual(81);
271
+ await expect(drawer).toBeVisible();
272
+ if (await drawer.getAttribute("data-presentation") === "rail") {
273
+ await toggle.click();
274
+ }
275
+ await expect(drawer).toHaveAttribute("data-presentation", "drawer");
276
+ await expectDrawerWidth(drawer, expect, await readConfiguredWidth(drawer, "data-drawer-width"));
277
+ await expectContentAwareDrawerFit(drawer, expect);
278
+ await page.keyboard.press("Escape");
279
+ await expect(drawer).toHaveAttribute("data-presentation", "drawer");
280
+
281
+ await toggle.click();
282
+ await expect(drawer).toHaveAttribute("data-presentation", "rail");
283
+ await expectDrawerWidth(drawer, expect, await readConfiguredWidth(drawer, "data-rail-width"));
284
+ await expectCentredRailNavigation(page, drawer, expect);
285
+ await expectNoHorizontalOverflow(page, expect);
123
286
  }
124
287
  }
125
288
 
@@ -6,6 +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
10
  return () => h(PackageShellLayout, attrs, slots);
10
11
  }
11
12
  };