@jskit-ai/shell-web 0.1.249 → 0.1.251
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/fixtures/adaptive-shell/src/App.vue +5 -0
- package/fixtures/adaptive-shell/src/main.js +19 -1
- package/package.json +2 -2
- package/patterns/application-shell/PATTERN.md +23 -0
- package/src/client/components/ShellErrorHost.vue +117 -13
- package/src/client/components/ShellLayout.vue +32 -6
- package/test/adaptiveShell.browser.test.js +115 -0
- package/test/settingsPlacementContract.test.js +1 -1
|
@@ -3,9 +3,11 @@ import { computed, watchEffect } from "vue";
|
|
|
3
3
|
import { RouterView, useRoute } from "vue-router";
|
|
4
4
|
import { useTheme } from "vuetify";
|
|
5
5
|
import ShellLayout from "@jskit-ai/shell-web/client/components/ShellLayout";
|
|
6
|
+
import ShellErrorHost from "@jskit-ai/shell-web/client/components/ShellErrorHost";
|
|
6
7
|
|
|
7
8
|
const route = useRoute();
|
|
8
9
|
const theme = useTheme();
|
|
10
|
+
const bare = computed(() => route.meta.bare === true);
|
|
9
11
|
const surface = computed(() => route.path.startsWith("/w/") ? "admin" : "home");
|
|
10
12
|
const surfaceLabel = computed(() => surface.value === "admin" ? "Admin" : "Home");
|
|
11
13
|
const themeMode = computed(() => route.query.theme === "dark" ? "dark" : "light");
|
|
@@ -22,7 +24,9 @@ watchEffect(function applyFixtureTheme() {
|
|
|
22
24
|
|
|
23
25
|
<template>
|
|
24
26
|
<v-app>
|
|
27
|
+
<RouterView v-if="bare" />
|
|
25
28
|
<ShellLayout
|
|
29
|
+
v-else
|
|
26
30
|
:surface="surface"
|
|
27
31
|
:surface-label="surfaceLabel"
|
|
28
32
|
:rail-width="railWidth"
|
|
@@ -30,5 +34,6 @@ watchEffect(function applyFixtureTheme() {
|
|
|
30
34
|
>
|
|
31
35
|
<RouterView />
|
|
32
36
|
</ShellLayout>
|
|
37
|
+
<ShellErrorHost />
|
|
33
38
|
</v-app>
|
|
34
39
|
</template>
|
|
@@ -13,6 +13,10 @@ import ShellSurfaceAwareMenuLinkItem from "@jskit-ai/shell-web/client/components
|
|
|
13
13
|
import ShellTabLinkItem from "@jskit-ai/shell-web/client/components/ShellTabLinkItem";
|
|
14
14
|
import { useShellLayoutStore } from "../../../src/client/stores/useShellLayoutStore.js";
|
|
15
15
|
import { createWebPlacementRuntime } from "../../../src/client/placement/runtime.js";
|
|
16
|
+
import { createErrorRuntime } from "../../../src/client/error/runtime.js";
|
|
17
|
+
import { createDefaultMaterialErrorPresenters } from "../../../src/client/error/presenters.js";
|
|
18
|
+
import { createErrorPresentationStore } from "../../../src/client/error/store.js";
|
|
19
|
+
import { useShellErrorPresentationStore } from "../../../src/client/stores/useShellErrorPresentationStore.js";
|
|
16
20
|
|
|
17
21
|
const COMPONENTS = new Map([
|
|
18
22
|
["fixture.menu-link", ShellMenuLinkItem],
|
|
@@ -111,7 +115,8 @@ const routes = [
|
|
|
111
115
|
{ path: "/home/settings/general", component: ScreenPage, meta: { title: "General settings" } },
|
|
112
116
|
{ path: "/home/:section?", component: ScreenPage, meta: { title: "Home surface" } },
|
|
113
117
|
{ path: "/w/:workspaceSlug/admin/:section?", component: ScreenPage, meta: { title: "Admin surface" } },
|
|
114
|
-
{ path: "/help", component: ScreenPage, meta: { title: "Help and support" } }
|
|
118
|
+
{ path: "/help", component: ScreenPage, meta: { title: "Help and support" } },
|
|
119
|
+
{ path: "/sign-in", component: ScreenPage, meta: { title: "Sign in", bare: true } }
|
|
115
120
|
];
|
|
116
121
|
const router = createRouter({ history: createWebHistory(), routes });
|
|
117
122
|
const pinia = createPinia();
|
|
@@ -179,6 +184,19 @@ app.use(pinia);
|
|
|
179
184
|
app.use(router);
|
|
180
185
|
app.use(vuetify);
|
|
181
186
|
app.provide("jskit.shell-web.runtime.web-placement.client", placementRuntime);
|
|
187
|
+
const errorStore = createErrorPresentationStore();
|
|
188
|
+
const errorRuntime = createErrorRuntime({
|
|
189
|
+
presenters: createDefaultMaterialErrorPresenters({ store: errorStore }),
|
|
190
|
+
defaultPresenterId: "material.snackbar"
|
|
191
|
+
});
|
|
192
|
+
useShellErrorPresentationStore(pinia).attachRuntimeStore(errorStore);
|
|
193
|
+
app.provide("jskit.shell-web.runtime.web-error.client", errorRuntime);
|
|
194
|
+
window.__shellFixtureReportBanner = (message) => errorRuntime.report({
|
|
195
|
+
source: "shell-web.fixture",
|
|
196
|
+
message,
|
|
197
|
+
severity: "error",
|
|
198
|
+
channel: "banner"
|
|
199
|
+
});
|
|
182
200
|
app.provide("jskit.shell-web.runtime.web-refresh.client", {
|
|
183
201
|
async refresh() {
|
|
184
202
|
await fetch("/api/bootstrap?reason=pull-to-refresh");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/shell-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.251",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"@vue/compiler-sfc": "3.5.44"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@jskit-ai/kernel": "0.1.
|
|
37
|
+
"@jskit-ai/kernel": "0.1.245",
|
|
38
38
|
"pinia": "^4.0.3",
|
|
39
39
|
"vue": "^3.5.44",
|
|
40
40
|
"vue-router": "^5.3.1",
|
|
@@ -38,6 +38,29 @@ placement registry, topology, and adaptive smoke helper exported by
|
|
|
38
38
|
- Cached route/resource state hydrates writable UI state synchronously or with
|
|
39
39
|
immediate watchers.
|
|
40
40
|
- Loading uses stable skeletons, not spinners or shifting status banners.
|
|
41
|
+
- `ShellErrorHost` stays directly inside `v-app`. Its banners float below a top
|
|
42
|
+
app bar; on a surface without one (such as sign-in) they join the page flow
|
|
43
|
+
above the content so they never cover it. A full-viewport fixed screen on
|
|
44
|
+
such a surface starts at `var(--shell-error-banners-inline-height, 0px)`, as
|
|
45
|
+
`@jskit-ai/auth-web`'s login and reset views do.
|
|
46
|
+
- Restyle shell chrome through its custom properties, not by overriding
|
|
47
|
+
Vuetify internals: `--shell-chrome-border-width` and
|
|
48
|
+
`--shell-chrome-border-color` for the shared dividers,
|
|
49
|
+
`--shell-app-bar-border-width`, `--shell-navigation-border-width` and
|
|
50
|
+
`--shell-bottom-navigation-border-width` per edge, and
|
|
51
|
+
`--shell-app-bar-background`, `--shell-navigation-background` and
|
|
52
|
+
`--shell-bottom-navigation-background` for the surfaces. The defaults keep
|
|
53
|
+
one thin divider on each separating edge, because Vuetify's default themes
|
|
54
|
+
define no Material 3 surface-container roles. A Material 3 restyle sets the
|
|
55
|
+
border widths to `0` and gives navigation a tonal surface, for example
|
|
56
|
+
`rgb(var(--v-theme-surface-container-low))` when the theme defines it.
|
|
57
|
+
- `createShellRouter()` from `@jskit-ai/kernel/client` installs
|
|
58
|
+
`shellScrollBehavior`: a new screen opens at the top (or its hash target),
|
|
59
|
+
Back/Forward restore the saved position, and query, hash, tab or sub-view
|
|
60
|
+
changes within the same page keep it. A tab or sub-view is the same page when
|
|
61
|
+
both locations share a parent page record below the surface root. Pass
|
|
62
|
+
`scrollBehavior` to replace it, or `scrollBehavior: false` to disable it; do
|
|
63
|
+
not wrap the router or reset scroll in page components.
|
|
41
64
|
- The adaptive browser check closes navigation through its public close control.
|
|
42
65
|
|
|
43
66
|
## Example files
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, ref, watch } from "vue";
|
|
2
|
+
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
3
|
+
import { useLayout } from "vuetify";
|
|
4
|
+
import { mdiAlertOutline, mdiCheckCircleOutline, mdiInformationOutline } from "@mdi/js";
|
|
3
5
|
import {
|
|
4
6
|
useShellWebErrorRuntime
|
|
5
7
|
} from "../error/inject.js";
|
|
@@ -12,6 +14,75 @@ const snackbarEntries = computed(() => store.channels.snackbar || []);
|
|
|
12
14
|
const bannerEntries = computed(() => store.channels.banner || []);
|
|
13
15
|
const dialogEntry = computed(() => store.channels.dialog[0] || null);
|
|
14
16
|
const displayedSnackbarEntry = ref(null);
|
|
17
|
+
const layoutRect = readLayoutRect();
|
|
18
|
+
|
|
19
|
+
function readLayoutRect() {
|
|
20
|
+
try {
|
|
21
|
+
return useLayout().mainRect;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Banners float just below a top app bar. A surface without one (sign-in pages)
|
|
28
|
+
// has nothing to float under, so the banners join the page flow above the
|
|
29
|
+
// content instead of covering it, and publish their height so full-viewport
|
|
30
|
+
// surfaces can start below them.
|
|
31
|
+
const BANNER_INLINE_HEIGHT_PROPERTY = "--shell-error-banners-inline-height";
|
|
32
|
+
const bannersElement = ref(null);
|
|
33
|
+
const bannerPlacement = computed(() => {
|
|
34
|
+
const rect = layoutRect?.value || {};
|
|
35
|
+
const top = Number(rect.top) || 0;
|
|
36
|
+
if (top <= 0) {
|
|
37
|
+
return { inline: true, style: undefined };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
inline: false,
|
|
42
|
+
style: {
|
|
43
|
+
top: `calc(env(safe-area-inset-top, 0px) + ${top}px)`,
|
|
44
|
+
left: `${Number(rect.left) || 0}px`,
|
|
45
|
+
right: `${Number(rect.right) || 0}px`
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
let bannerResizeObserver = null;
|
|
50
|
+
|
|
51
|
+
function publishInlineBannerHeight(height = 0) {
|
|
52
|
+
const rootStyle = globalThis.document?.documentElement?.style;
|
|
53
|
+
if (!rootStyle) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (height > 0) {
|
|
57
|
+
rootStyle.setProperty(BANNER_INLINE_HEIGHT_PROPERTY, `${Math.ceil(height)}px`);
|
|
58
|
+
} else {
|
|
59
|
+
rootStyle.removeProperty(BANNER_INLINE_HEIGHT_PROPERTY);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stopObservingBanners() {
|
|
64
|
+
bannerResizeObserver?.disconnect();
|
|
65
|
+
bannerResizeObserver = null;
|
|
66
|
+
publishInlineBannerHeight(0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
watch(
|
|
70
|
+
[bannersElement, () => bannerPlacement.value.inline],
|
|
71
|
+
([element, inline]) => {
|
|
72
|
+
stopObservingBanners();
|
|
73
|
+
if (!element || !inline || typeof globalThis.ResizeObserver !== "function") {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
bannerResizeObserver = new globalThis.ResizeObserver(() => {
|
|
77
|
+
publishInlineBannerHeight(element.getBoundingClientRect().height);
|
|
78
|
+
});
|
|
79
|
+
bannerResizeObserver.observe(element);
|
|
80
|
+
publishInlineBannerHeight(element.getBoundingClientRect().height);
|
|
81
|
+
},
|
|
82
|
+
{ flush: "post" }
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
onBeforeUnmount(stopObservingBanners);
|
|
15
86
|
const snackbarOpen = ref(false);
|
|
16
87
|
|
|
17
88
|
function isSameSnackbarEntry(left = null, right = null) {
|
|
@@ -77,15 +148,12 @@ function resolveSeverityColor(severity = "error") {
|
|
|
77
148
|
function resolveSeverityIcon(severity = "error") {
|
|
78
149
|
const normalized = String(severity || "error").trim().toLowerCase();
|
|
79
150
|
if (normalized === "info") {
|
|
80
|
-
return
|
|
151
|
+
return mdiInformationOutline;
|
|
81
152
|
}
|
|
82
153
|
if (normalized === "success") {
|
|
83
|
-
return
|
|
154
|
+
return mdiCheckCircleOutline;
|
|
84
155
|
}
|
|
85
|
-
|
|
86
|
-
return "mdi-alert-outline";
|
|
87
|
-
}
|
|
88
|
-
return "mdi-alert-outline";
|
|
156
|
+
return mdiAlertOutline;
|
|
89
157
|
}
|
|
90
158
|
|
|
91
159
|
function resolveTimeout(entry) {
|
|
@@ -155,8 +223,19 @@ function onSnackbarAfterLeave() {
|
|
|
155
223
|
</script>
|
|
156
224
|
|
|
157
225
|
<template>
|
|
158
|
-
<div
|
|
159
|
-
|
|
226
|
+
<div
|
|
227
|
+
class="shell-error-host"
|
|
228
|
+
:class="{ 'shell-error-host--inline-banners': bannerPlacement.inline }"
|
|
229
|
+
aria-live="polite"
|
|
230
|
+
>
|
|
231
|
+
<div
|
|
232
|
+
v-if="bannerEntries.length > 0"
|
|
233
|
+
ref="bannersElement"
|
|
234
|
+
class="shell-error-host__banners"
|
|
235
|
+
:class="{ 'shell-error-host__banners--inline': bannerPlacement.inline }"
|
|
236
|
+
:style="bannerPlacement.style"
|
|
237
|
+
data-testid="shell-error-host-banners"
|
|
238
|
+
>
|
|
160
239
|
<div class="shell-error-host__banner-stack">
|
|
161
240
|
<v-alert
|
|
162
241
|
v-for="entry in bannerEntries"
|
|
@@ -254,16 +333,23 @@ function onSnackbarAfterLeave() {
|
|
|
254
333
|
</template>
|
|
255
334
|
|
|
256
335
|
<style scoped>
|
|
336
|
+
.shell-error-host--inline-banners {
|
|
337
|
+
order: -1;
|
|
338
|
+
}
|
|
339
|
+
|
|
257
340
|
.shell-error-host__banners {
|
|
258
341
|
position: fixed;
|
|
259
|
-
top: calc(env(safe-area-inset-top, 0px) + var(--shell-error-banner-offset, 64px));
|
|
260
|
-
left: 0;
|
|
261
|
-
right: 0;
|
|
262
342
|
z-index: 2600;
|
|
263
343
|
pointer-events: none;
|
|
264
344
|
padding: 10px 12px;
|
|
265
345
|
}
|
|
266
346
|
|
|
347
|
+
.shell-error-host__banners--inline {
|
|
348
|
+
position: relative;
|
|
349
|
+
z-index: auto;
|
|
350
|
+
padding-top: calc(env(safe-area-inset-top, 0px) + 10px);
|
|
351
|
+
}
|
|
352
|
+
|
|
267
353
|
.shell-error-host__banner-stack {
|
|
268
354
|
margin: 0 auto;
|
|
269
355
|
width: min(1120px, 100%);
|
|
@@ -277,10 +363,28 @@ function onSnackbarAfterLeave() {
|
|
|
277
363
|
box-shadow: var(--v-shadow-4);
|
|
278
364
|
}
|
|
279
365
|
|
|
366
|
+
/*
|
|
367
|
+
* The close button always uses the banner's own on-color (the message colour) on
|
|
368
|
+
* a tonal disc of that colour, so a themed button default can never turn it into
|
|
369
|
+
* a low-contrast red-on-red mark, in light or dark themes.
|
|
370
|
+
*/
|
|
371
|
+
.shell-error-host__banner :deep(.v-alert__close .v-btn) {
|
|
372
|
+
color: inherit !important;
|
|
373
|
+
opacity: 1;
|
|
374
|
+
background-color: color-mix(in srgb, currentColor 18%, transparent);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.shell-error-host__banner :deep(.v-alert__close .v-btn .v-icon) {
|
|
378
|
+
font-size: 20px;
|
|
379
|
+
}
|
|
380
|
+
|
|
280
381
|
@media (max-width: 600px) {
|
|
281
382
|
.shell-error-host__banners {
|
|
282
|
-
top: calc(env(safe-area-inset-top, 0px) + var(--shell-error-banner-offset-mobile, 56px));
|
|
283
383
|
padding: 8px;
|
|
284
384
|
}
|
|
385
|
+
|
|
386
|
+
.shell-error-host__banners--inline {
|
|
387
|
+
padding-top: calc(env(safe-area-inset-top, 0px) + 8px);
|
|
388
|
+
}
|
|
285
389
|
}
|
|
286
390
|
</style>
|
|
@@ -563,10 +563,9 @@ function touchListIncludesActiveTouch(touchList) {
|
|
|
563
563
|
|
|
564
564
|
<template>
|
|
565
565
|
<v-app-bar
|
|
566
|
-
border
|
|
567
566
|
:density="isCompactLayout ? 'compact' : 'comfortable'"
|
|
568
567
|
elevation="0"
|
|
569
|
-
class="shell-layout__app-bar
|
|
568
|
+
class="shell-layout__app-bar"
|
|
570
569
|
data-testid="jskit-shell-app-bar"
|
|
571
570
|
>
|
|
572
571
|
<v-app-bar-nav-icon
|
|
@@ -619,8 +618,7 @@ function touchListIncludesActiveTouch(touchList) {
|
|
|
619
618
|
ref="navigationDrawer"
|
|
620
619
|
id="jskit-shell-drawer"
|
|
621
620
|
:model-value="drawerPresentation.visible"
|
|
622
|
-
|
|
623
|
-
class="shell-layout__drawer bg-surface"
|
|
621
|
+
class="shell-layout__drawer"
|
|
624
622
|
data-testid="jskit-shell-drawer"
|
|
625
623
|
:data-layout="layoutClass"
|
|
626
624
|
:data-presentation="drawerPresentation.kind"
|
|
@@ -670,7 +668,6 @@ function touchListIncludesActiveTouch(touchList) {
|
|
|
670
668
|
v-if="isCompactLayout"
|
|
671
669
|
class="shell-layout__bottom-nav"
|
|
672
670
|
data-testid="jskit-shell-bottom-nav"
|
|
673
|
-
bg-color="surface"
|
|
674
671
|
color="primary"
|
|
675
672
|
density="comfortable"
|
|
676
673
|
grow
|
|
@@ -716,6 +713,33 @@ function touchListIncludesActiveTouch(touchList) {
|
|
|
716
713
|
</template>
|
|
717
714
|
|
|
718
715
|
<style scoped>
|
|
716
|
+
/*
|
|
717
|
+
* Shell chrome follows the app's Vuetify theme and is restyled through these
|
|
718
|
+
* custom properties (set them on :root or any ancestor):
|
|
719
|
+
* --shell-chrome-border-width / --shell-chrome-border-color shared dividers
|
|
720
|
+
* --shell-app-bar-border-width, --shell-navigation-border-width,
|
|
721
|
+
* --shell-bottom-navigation-border-width per-edge overrides
|
|
722
|
+
* --shell-app-bar-background, --shell-navigation-background,
|
|
723
|
+
* --shell-bottom-navigation-background surfaces
|
|
724
|
+
* A Material 3 restyle sets the border widths to 0 and separates surfaces
|
|
725
|
+
* tonally, e.g. --shell-navigation-background: rgb(var(--v-theme-surface-container-low)).
|
|
726
|
+
*/
|
|
727
|
+
.shell-layout__app-bar {
|
|
728
|
+
border-style: solid;
|
|
729
|
+
border-color: var(--shell-chrome-border-color, rgba(var(--v-border-color), var(--v-border-opacity)));
|
|
730
|
+
border-width: 0 0 var(--shell-app-bar-border-width, var(--shell-chrome-border-width, thin));
|
|
731
|
+
background: var(--shell-app-bar-background, rgb(var(--v-theme-surface)));
|
|
732
|
+
color: rgb(var(--v-theme-on-surface));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.shell-layout__drawer {
|
|
736
|
+
border-style: solid;
|
|
737
|
+
border-color: var(--shell-chrome-border-color, rgba(var(--v-border-color), var(--v-border-opacity)));
|
|
738
|
+
border-width: 0;
|
|
739
|
+
border-inline-end-width: var(--shell-navigation-border-width, var(--shell-chrome-border-width, thin));
|
|
740
|
+
background: var(--shell-navigation-background, rgb(var(--v-theme-surface)));
|
|
741
|
+
}
|
|
742
|
+
|
|
719
743
|
.shell-layout__content {
|
|
720
744
|
padding: 0.75rem 1rem calc(1rem + env(safe-area-inset-bottom, 0px));
|
|
721
745
|
}
|
|
@@ -824,7 +848,9 @@ function touchListIncludesActiveTouch(touchList) {
|
|
|
824
848
|
}
|
|
825
849
|
|
|
826
850
|
.shell-layout__bottom-nav {
|
|
827
|
-
border-top:
|
|
851
|
+
border-top: var(--shell-bottom-navigation-border-width, var(--shell-chrome-border-width, thin)) solid
|
|
852
|
+
var(--shell-chrome-border-color, rgba(var(--v-border-color), var(--v-border-opacity)));
|
|
853
|
+
background: var(--shell-bottom-navigation-background, rgb(var(--v-theme-surface)));
|
|
828
854
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
829
855
|
}
|
|
830
856
|
|
|
@@ -361,3 +361,118 @@ test("shell-web preserves the active form across responsive changes", {
|
|
|
361
361
|
await stopProcess(vite);
|
|
362
362
|
}
|
|
363
363
|
});
|
|
364
|
+
|
|
365
|
+
function overlaps(first, second) {
|
|
366
|
+
return first.bottom > second.top && first.top < second.bottom &&
|
|
367
|
+
first.right > second.left && first.left < second.right;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function reportBannerAndMeasure(page) {
|
|
371
|
+
await page.evaluate(() => window.__shellFixtureReportBanner("Invalid email or password."));
|
|
372
|
+
const banner = page.locator(".shell-error-host__banner").first();
|
|
373
|
+
await expect(banner).toBeVisible();
|
|
374
|
+
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
|
375
|
+
return banner.evaluate((element) => {
|
|
376
|
+
const box = (node) => {
|
|
377
|
+
const rect = node.getBoundingClientRect();
|
|
378
|
+
return { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right };
|
|
379
|
+
};
|
|
380
|
+
const close = element.querySelector(".v-alert__close .v-btn");
|
|
381
|
+
const closeColor = window.getComputedStyle(close).color;
|
|
382
|
+
// A themed button default must not recolour the close mark into the banner colour.
|
|
383
|
+
close.classList.add("text-error");
|
|
384
|
+
const closeColorWithThemedDefault = window.getComputedStyle(close).color;
|
|
385
|
+
close.classList.remove("text-error");
|
|
386
|
+
const appBar = document.querySelector("[data-testid='jskit-shell-app-bar']");
|
|
387
|
+
return {
|
|
388
|
+
banner: box(element),
|
|
389
|
+
background: window.getComputedStyle(element).backgroundColor,
|
|
390
|
+
closeColor,
|
|
391
|
+
closeColorWithThemedDefault,
|
|
392
|
+
appBar: appBar ? box(appBar) : null,
|
|
393
|
+
content: Array.from(document.querySelectorAll("main h1, main input, main button"))
|
|
394
|
+
.map((node) => ({ name: node.textContent.trim() || node.name, rect: box(node) }))
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
test("shell-web error banners never cover content and keep a readable close button", {
|
|
400
|
+
skip: RUN_BROWSER_TEST ? false : "set JSKIT_SHELL_WEB_BROWSER_INTEGRATION=1",
|
|
401
|
+
timeout: 120_000
|
|
402
|
+
}, async () => {
|
|
403
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
404
|
+
let browser;
|
|
405
|
+
try {
|
|
406
|
+
browser = await chromium.launch(createChromiumLaunchOptions());
|
|
407
|
+
for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) {
|
|
408
|
+
for (const theme of ["light", "dark"]) {
|
|
409
|
+
const page = await browser.newPage({ baseURL: vite.baseURL, viewport });
|
|
410
|
+
await page.goto(`/sign-in?theme=${theme}`);
|
|
411
|
+
await expect(page.getByRole("heading", { level: 1, name: "Sign in" })).toBeVisible();
|
|
412
|
+
await expect(page.getByTestId("jskit-shell-app-bar")).toHaveCount(0);
|
|
413
|
+
const bare = await reportBannerAndMeasure(page);
|
|
414
|
+
for (const item of bare.content) {
|
|
415
|
+
assert.equal(
|
|
416
|
+
overlaps(bare.banner, item.rect),
|
|
417
|
+
false,
|
|
418
|
+
`${viewport.width}px ${theme}: the banner covers "${item.name}" on a surface without an app bar.`
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
const background = parseRgb(bare.background);
|
|
422
|
+
for (const color of [bare.closeColor, bare.closeColorWithThemedDefault]) {
|
|
423
|
+
const ratio = contrastRatio(parseRgb(color), background);
|
|
424
|
+
assert.ok(ratio >= 3, `${viewport.width}px ${theme}: banner close contrast was ${ratio.toFixed(2)}:1.`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
await page.goto(`/home?theme=${theme}`);
|
|
428
|
+
await expect(page.getByTestId("jskit-shell-app-bar")).toBeVisible();
|
|
429
|
+
const shell = await reportBannerAndMeasure(page);
|
|
430
|
+
assert.ok(
|
|
431
|
+
shell.banner.top >= shell.appBar.bottom - 1,
|
|
432
|
+
`${viewport.width}px ${theme}: the banner overlaps the top app bar.`
|
|
433
|
+
);
|
|
434
|
+
await page.close();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} finally {
|
|
438
|
+
await browser?.close();
|
|
439
|
+
await stopProcess(vite);
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("shell-web chrome borders and surfaces follow app-supplied tokens", {
|
|
444
|
+
skip: RUN_BROWSER_TEST ? false : "set JSKIT_SHELL_WEB_BROWSER_INTEGRATION=1",
|
|
445
|
+
timeout: 90_000
|
|
446
|
+
}, async () => {
|
|
447
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
448
|
+
let browser;
|
|
449
|
+
try {
|
|
450
|
+
browser = await chromium.launch(createChromiumLaunchOptions());
|
|
451
|
+
const page = await browser.newPage({ baseURL: vite.baseURL, viewport: { width: 1280, height: 900 } });
|
|
452
|
+
await page.goto("/home?theme=light");
|
|
453
|
+
await expect(page.getByTestId("jskit-shell-drawer")).toBeVisible();
|
|
454
|
+
const read = () => page.evaluate(() => {
|
|
455
|
+
const edges = (testId) => {
|
|
456
|
+
const style = window.getComputedStyle(document.querySelector(`[data-testid='${testId}']`));
|
|
457
|
+
return [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth,
|
|
458
|
+
style.backgroundColor];
|
|
459
|
+
};
|
|
460
|
+
return { appBar: edges("jskit-shell-app-bar"), drawer: edges("jskit-shell-drawer") };
|
|
461
|
+
});
|
|
462
|
+
const defaults = await read();
|
|
463
|
+
// Only the separating edges carry a divider by default.
|
|
464
|
+
assert.deepEqual(defaults.appBar.slice(0, 4), ["0px", "0px", "1px", "0px"]);
|
|
465
|
+
assert.deepEqual(defaults.drawer.slice(0, 4), ["0px", "1px", "0px", "0px"]);
|
|
466
|
+
|
|
467
|
+
await page.addStyleTag({
|
|
468
|
+
content: ":root { --shell-chrome-border-width: 0; --shell-app-bar-background: rgb(1, 2, 3); --shell-navigation-background: rgb(4, 5, 6); }"
|
|
469
|
+
});
|
|
470
|
+
const restyled = await read();
|
|
471
|
+
assert.deepEqual(restyled.appBar, ["0px", "0px", "0px", "0px", "rgb(1, 2, 3)"]);
|
|
472
|
+
assert.deepEqual(restyled.drawer, ["0px", "0px", "0px", "0px", "rgb(4, 5, 6)"]);
|
|
473
|
+
await page.close();
|
|
474
|
+
} finally {
|
|
475
|
+
await browser?.close();
|
|
476
|
+
await stopProcess(vite);
|
|
477
|
+
}
|
|
478
|
+
});
|
|
@@ -142,7 +142,7 @@ test("shell-web error host uses one explicit close affordance for banner errors"
|
|
|
142
142
|
const source = await readFile(path.join(PACKAGE_DIR, "src", "client", "components", "ShellErrorHost.vue"), "utf8");
|
|
143
143
|
|
|
144
144
|
assert.match(source, /function resolveSeverityIcon/);
|
|
145
|
-
assert.match(source, /
|
|
145
|
+
assert.match(source, /mdiAlertOutline/);
|
|
146
146
|
assert.match(source, /:icon="resolveSeverityIcon\(entry\.severity\)"/);
|
|
147
147
|
assert.match(source, /closable[\s\S]*@click:close="dismiss\(entry\)"/);
|
|
148
148
|
assert.doesNotMatch(source, /mdi-close/);
|