@jskit-ai/shell-web 0.1.250 → 0.1.252
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/main.js +6 -3
- package/package.json +2 -2
- package/patterns/application-shell/PATTERN.md +18 -4
- package/patterns/application-shell/example/src/pages/home/index.vue +15 -5
- package/patterns/application-shell/example/tests/e2e/adaptive-shell.spec.ts +1 -1
- package/src/architecture/inspectVueArchitecture.js +1092 -336
- package/src/client/asyncModuleRecovery/runtime.js +116 -0
- package/src/client/components/ShellLayout.vue +18 -288
- package/src/client/components/ShellMenuLinkItem.vue +3 -21
- package/src/client/components/ShellOutlet.vue +2 -24
- package/src/client/components/ShellRouteTransition.vue +16 -114
- package/src/client/components/ShellSurfaceAwareMenuLinkItem.vue +5 -100
- package/src/client/components/ShellTabLinkItem.vue +5 -101
- package/src/client/composables/useShellNavigationLink.js +85 -0
- package/src/client/composables/useShellPullToRefresh.js +260 -0
- package/src/client/error/index.js +1 -1
- package/src/client/error/inject.js +4 -1
- package/src/client/error/policy.js +4 -13
- package/src/client/error/presenters.js +4 -9
- package/src/client/error/store.js +1 -1
- package/src/client/navigation/linkResolver.js +5 -27
- package/src/client/navigation/usePaths.js +1 -21
- package/src/client/placement/index.js +2 -0
- package/src/client/placement/inject.js +4 -1
- package/src/client/placement/runtime.js +2 -4
- package/src/client/placement/surfaceContext.js +11 -8
- package/src/client/placement/validators.js +18 -65
- package/src/client/providers/ShellWebClientProvider.js +100 -356
- package/src/client/requestRecovery/runtime.js +29 -77
- package/src/client/runtime/bootstrapRuntime.js +5 -4
- package/src/client/runtime/refreshRuntime.js +122 -0
- package/src/client/support/gestureTargets.js +26 -0
- package/src/client/support/menuLinkTarget.js +35 -19
- package/src/client/support/routeParams.js +26 -0
- package/src/client/support/routeSwipeGesture.js +88 -0
- package/src/client/support/routeTransitionKey.js +1 -3
- package/src/client/support/shellLayoutClass.js +32 -0
- package/test/bootstrapRuntime.test.js +98 -0
- package/test/errorActions.test.js +2 -1
- package/test/errorRuntime.test.js +30 -0
- package/test/linkResolver.test.js +23 -1
- package/test/menuLinkTarget.test.js +77 -0
- package/test/routeSwipeGesture.test.js +49 -0
- package/test/settingsPlacementContract.test.js +16 -6
- package/test/shellGestureSupport.test.js +46 -0
- package/test/surfaceContext.test.js +53 -0
- package/test/useShellPullToRefresh.test.js +123 -0
|
@@ -134,3 +134,101 @@ test("shell bootstrap runtime refreshes /api/bootstrap on init and applies regis
|
|
|
134
134
|
consoleowner: true
|
|
135
135
|
});
|
|
136
136
|
});
|
|
137
|
+
|
|
138
|
+
test("shell bootstrap runtime reports a failing payload handler and keeps refreshing afterwards", async () => {
|
|
139
|
+
const placementRuntime = createPlacementRuntime({});
|
|
140
|
+
const calls = [];
|
|
141
|
+
const handledErrors = [];
|
|
142
|
+
const reported = [];
|
|
143
|
+
let failNextApply = true;
|
|
144
|
+
let failNextResolve = false;
|
|
145
|
+
const handlers = createBootstrapPayloadHandlerRegistry();
|
|
146
|
+
handlers.register(Object.freeze({
|
|
147
|
+
handlerId: "test.bootstrap.flaky",
|
|
148
|
+
order: 0,
|
|
149
|
+
resolveBootstrapRequest() {
|
|
150
|
+
if (failNextResolve) {
|
|
151
|
+
failNextResolve = false;
|
|
152
|
+
throw new Error("resolve failed");
|
|
153
|
+
}
|
|
154
|
+
return {};
|
|
155
|
+
},
|
|
156
|
+
applyBootstrapPayload() {
|
|
157
|
+
if (failNextApply) {
|
|
158
|
+
failNextApply = false;
|
|
159
|
+
throw new Error("apply failed");
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
handleBootstrapError({ error, request }) {
|
|
163
|
+
handledErrors.push({ message: error.message, path: request.path });
|
|
164
|
+
}
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
const runtime = createShellBootstrapRuntime({
|
|
168
|
+
handlers,
|
|
169
|
+
placementRuntime,
|
|
170
|
+
requestRecoveryRuntime: {
|
|
171
|
+
report(error, options) {
|
|
172
|
+
reported.push({ message: error.message, source: options.source });
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
logger: { warn() {}, info() {}, error() {}, debug() {} },
|
|
176
|
+
fetchImplementation: async (url) => {
|
|
177
|
+
calls.push(String(url || ""));
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
async json() {
|
|
181
|
+
return { ok: true };
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
assert.equal(await runtime.refresh("manual"), null);
|
|
188
|
+
assert.deepEqual(handledErrors, [{ message: "apply failed", path: "/api/bootstrap" }]);
|
|
189
|
+
assert.deepEqual(reported, [{ message: "apply failed", source: "shell-web.bootstrap" }]);
|
|
190
|
+
|
|
191
|
+
failNextResolve = true;
|
|
192
|
+
assert.equal(await runtime.refresh("manual"), null);
|
|
193
|
+
assert.deepEqual(handledErrors[1], { message: "resolve failed", path: "/api/bootstrap" });
|
|
194
|
+
|
|
195
|
+
assert.deepEqual(await runtime.refresh("manual"), { ok: true });
|
|
196
|
+
assert.deepEqual(calls, ["/api/bootstrap", "/api/bootstrap"]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("shell bootstrap runtime queue recovers after a refresh rejects", async () => {
|
|
200
|
+
const handlers = createBootstrapPayloadHandlerRegistry();
|
|
201
|
+
let failErrorHandler = true;
|
|
202
|
+
handlers.register(Object.freeze({
|
|
203
|
+
handlerId: "test.bootstrap.error-handler",
|
|
204
|
+
order: 0,
|
|
205
|
+
applyBootstrapPayload() {},
|
|
206
|
+
handleBootstrapError() {
|
|
207
|
+
if (failErrorHandler) {
|
|
208
|
+
failErrorHandler = false;
|
|
209
|
+
throw new Error("error handler failed");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}));
|
|
213
|
+
let fail = true;
|
|
214
|
+
const runtime = createShellBootstrapRuntime({
|
|
215
|
+
handlers,
|
|
216
|
+
placementRuntime: createPlacementRuntime({}),
|
|
217
|
+
logger: { warn() {}, info() {}, error() {}, debug() {} },
|
|
218
|
+
fetchImplementation: async () => {
|
|
219
|
+
if (fail) {
|
|
220
|
+
fail = false;
|
|
221
|
+
return { ok: false, status: 503 };
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
async json() {
|
|
226
|
+
return { recovered: true };
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await assert.rejects(runtime.refresh("manual"), /error handler failed/);
|
|
233
|
+
assert.deepEqual(await runtime.refresh("manual"), { recovered: true });
|
|
234
|
+
});
|
|
@@ -6,6 +6,7 @@ import { compileScript, parse } from "@vue/compiler-sfc";
|
|
|
6
6
|
import { createRenderer, defineComponent, h, nextTick } from "vue";
|
|
7
7
|
import { createPinia } from "pinia";
|
|
8
8
|
import { createErrorRuntime } from "../src/client/error/runtime.js";
|
|
9
|
+
import { SHELL_WEB_ERROR_RUNTIME_KEY } from "../src/client/error/inject.js";
|
|
9
10
|
import { createDefaultMaterialErrorPresenters } from "../src/client/error/presenters.js";
|
|
10
11
|
import { createErrorPresentationStore } from "../src/client/error/store.js";
|
|
11
12
|
import { useShellErrorPresentationStore } from "../src/client/stores/useShellErrorPresentationStore.js";
|
|
@@ -48,7 +49,7 @@ function fixture(t) {
|
|
|
48
49
|
const pinia = createPinia();
|
|
49
50
|
app.use(pinia);
|
|
50
51
|
useShellErrorPresentationStore(pinia).attachRuntimeStore(store);
|
|
51
|
-
app.provide(
|
|
52
|
+
app.provide(SHELL_WEB_ERROR_RUNTIME_KEY, runtime);
|
|
52
53
|
for (const name of ["VSnackbar", "VDialog", "VCard", "VCardTitle", "VCardText", "VCardActions", "VSpacer", "VAlert", "VBtn"]) {
|
|
53
54
|
app.component(name, defineComponent({ setup: (_props, { slots }) => () =>
|
|
54
55
|
h(name, {}, [slots.default?.(), slots.actions?.()]) }));
|
|
@@ -231,3 +231,33 @@ test("error runtime throws when no presenter supports channel", () => {
|
|
|
231
231
|
/No error presenter supports channel "dialog"/,
|
|
232
232
|
);
|
|
233
233
|
});
|
|
234
|
+
|
|
235
|
+
test("error runtime honours an explicit persist on any channel and defaults by channel otherwise", async () => {
|
|
236
|
+
const { createErrorPresentationStore } = await import("../src/client/error/store.js");
|
|
237
|
+
const { createDefaultMaterialErrorPresenters } = await import("../src/client/error/presenters.js");
|
|
238
|
+
const store = createErrorPresentationStore({ now: () => 1000 });
|
|
239
|
+
const runtime = createErrorRuntime({
|
|
240
|
+
presenters: createDefaultMaterialErrorPresenters({ store }),
|
|
241
|
+
moduleDefaultPresenterId: "material.snackbar"
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const persistentSnackbar = runtime.report({ message: "Keep me", channel: "snackbar", persist: true });
|
|
245
|
+
const transientSnackbar = runtime.report({ message: "Drop me", channel: "snackbar" });
|
|
246
|
+
const defaultBanner = runtime.report({ message: "Offline", intent: "app-recoverable" });
|
|
247
|
+
const transientDialog = runtime.report({ message: "Heads up", channel: "dialog", persist: false });
|
|
248
|
+
|
|
249
|
+
assert.equal(persistentSnackbar.decision.persist, true);
|
|
250
|
+
assert.equal(transientSnackbar.decision.persist, false);
|
|
251
|
+
assert.equal(defaultBanner.decision.persist, true);
|
|
252
|
+
assert.equal(transientDialog.decision.persist, false);
|
|
253
|
+
const { channels } = store.getState();
|
|
254
|
+
assert.deepEqual(channels.snackbar.map((entry) => [entry.message, entry.persist]), [["Keep me", true], ["Drop me", false]]);
|
|
255
|
+
assert.deepEqual(channels.banner.map((entry) => [entry.message, entry.persist]), [["Offline", true]]);
|
|
256
|
+
assert.deepEqual(channels.dialog.map((entry) => [entry.message, entry.persist]), [["Heads up", false]]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("default error policy decides only channel and severity", () => {
|
|
260
|
+
const policy = createDefaultErrorPolicy({ defaultSeverity: "warning" });
|
|
261
|
+
|
|
262
|
+
assert.deepEqual(policy({ message: "Saved?", action: { label: "Retry" } }), { channel: "snackbar", severity: "warning" });
|
|
263
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import { createApp } from "vue";
|
|
4
|
+
import { resolveShellLinkPath, useShellLinkResolver } from "../src/client/navigation/linkResolver.js";
|
|
4
5
|
|
|
5
6
|
function createPlacementContext() {
|
|
6
7
|
return {
|
|
@@ -110,3 +111,24 @@ test("resolveShellLinkPath uses deterministic surface fallback when context is m
|
|
|
110
111
|
|
|
111
112
|
assert.equal(to, "/admin/contacts/5");
|
|
112
113
|
});
|
|
114
|
+
|
|
115
|
+
test("resolveShellLinkPath uses the first value of a repeated route param", () => {
|
|
116
|
+
const to = resolveShellLinkPath({
|
|
117
|
+
context: createPlacementContext(),
|
|
118
|
+
surface: "app",
|
|
119
|
+
params: {
|
|
120
|
+
workspaceSlug: [" acme ", "other"]
|
|
121
|
+
},
|
|
122
|
+
relativePath: "/projects"
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
assert.equal(to, "/w/acme/projects");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("useShellLinkResolver exposes only surface-relative resolve", () => {
|
|
129
|
+
const app = createApp({ render: () => null });
|
|
130
|
+
const resolver = app.runWithContext(() => useShellLinkResolver({ surface: "console" }));
|
|
131
|
+
|
|
132
|
+
assert.deepEqual(Object.keys(resolver), ["resolve"]);
|
|
133
|
+
assert.equal(resolver.resolve("/reports"), "/console/reports");
|
|
134
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { resolveMenuLinkTarget } from "../src/client/support/menuLinkTarget.js";
|
|
4
|
+
|
|
5
|
+
function createPlacementContext() {
|
|
6
|
+
return {
|
|
7
|
+
surfaceConfig: {
|
|
8
|
+
defaultSurfaceId: "home",
|
|
9
|
+
enabledSurfaceIds: ["home", "app"],
|
|
10
|
+
surfacesById: {
|
|
11
|
+
home: {
|
|
12
|
+
id: "home",
|
|
13
|
+
pagesRoot: "home",
|
|
14
|
+
routeBase: "/home",
|
|
15
|
+
enabled: true,
|
|
16
|
+
requiresWorkspace: false
|
|
17
|
+
},
|
|
18
|
+
app: {
|
|
19
|
+
id: "app",
|
|
20
|
+
pagesRoot: "w/[workspaceSlug]",
|
|
21
|
+
routeBase: "/w/:workspaceSlug",
|
|
22
|
+
enabled: true,
|
|
23
|
+
requiresWorkspace: true
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("menu link target returns an explicit absolute target without resolving the target surface", () => {
|
|
31
|
+
assert.equal(
|
|
32
|
+
resolveMenuLinkTarget({
|
|
33
|
+
to: "/w/acme/x",
|
|
34
|
+
surface: "app",
|
|
35
|
+
currentSurfaceId: "home",
|
|
36
|
+
placementContext: createPlacementContext(),
|
|
37
|
+
routeParams: {}
|
|
38
|
+
}),
|
|
39
|
+
"/w/acme/x"
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("menu link target resolves suffixes for missing and relative targets", () => {
|
|
44
|
+
const placementContext = createPlacementContext();
|
|
45
|
+
assert.equal(
|
|
46
|
+
resolveMenuLinkTarget({
|
|
47
|
+
surface: "app",
|
|
48
|
+
currentSurfaceId: "home",
|
|
49
|
+
placementContext,
|
|
50
|
+
scopedSuffix: "/projects",
|
|
51
|
+
routeParams: { workspaceSlug: "acme" }
|
|
52
|
+
}),
|
|
53
|
+
"/w/acme/projects"
|
|
54
|
+
);
|
|
55
|
+
assert.equal(
|
|
56
|
+
resolveMenuLinkTarget({
|
|
57
|
+
to: "./settings",
|
|
58
|
+
currentSurfaceId: "home",
|
|
59
|
+
placementContext,
|
|
60
|
+
unscopedSuffix: "/settings"
|
|
61
|
+
}),
|
|
62
|
+
"/home/settings"
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("menu link target still requires surface params when it must build the path", () => {
|
|
67
|
+
assert.throws(
|
|
68
|
+
() => resolveMenuLinkTarget({
|
|
69
|
+
surface: "app",
|
|
70
|
+
currentSurfaceId: "home",
|
|
71
|
+
placementContext: createPlacementContext(),
|
|
72
|
+
scopedSuffix: "/projects",
|
|
73
|
+
routeParams: {}
|
|
74
|
+
}),
|
|
75
|
+
/Missing required surface route params/
|
|
76
|
+
);
|
|
77
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createRouteSwipeGesture } from "../src/client/support/routeSwipeGesture.js";
|
|
4
|
+
|
|
5
|
+
function createClock() {
|
|
6
|
+
const clock = { time: 0, now: () => clock.time };
|
|
7
|
+
return clock;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
test("route swipe gesture navigates on a fast horizontal swipe", () => {
|
|
11
|
+
const clock = createClock();
|
|
12
|
+
const gesture = createRouteSwipeGesture({ now: clock.now });
|
|
13
|
+
gesture.start({ pointerId: 1, clientX: 200, clientY: 100 });
|
|
14
|
+
clock.time = 50;
|
|
15
|
+
assert.deepEqual(gesture.move({ pointerId: 1, clientX: 185, clientY: 102 }), { claim: true, offset: 0 });
|
|
16
|
+
clock.time = 100;
|
|
17
|
+
assert.deepEqual(gesture.move({ pointerId: 1, clientX: 150, clientY: 104 }), { claim: true, offset: 1 });
|
|
18
|
+
assert.equal(gesture.end({ pointerId: 1, clientX: 150, clientY: 104 }), 0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("route swipe gesture resolves the direction on pointer end", () => {
|
|
22
|
+
const clock = createClock();
|
|
23
|
+
const gesture = createRouteSwipeGesture({ now: clock.now });
|
|
24
|
+
gesture.start({ pointerId: 1, clientX: 100, clientY: 100 });
|
|
25
|
+
clock.time = 100;
|
|
26
|
+
assert.equal(gesture.end({ pointerId: 1, clientX: 140, clientY: 100 }), -1);
|
|
27
|
+
assert.equal(gesture.end({ pointerId: 1, clientX: 140, clientY: 100 }), 0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("route swipe gesture stays dropped after vertical drift even when the finger turns sideways", () => {
|
|
31
|
+
const clock = createClock();
|
|
32
|
+
const gesture = createRouteSwipeGesture({ now: clock.now });
|
|
33
|
+
gesture.start({ pointerId: 1, clientX: 200, clientY: 100 });
|
|
34
|
+
clock.time = 40;
|
|
35
|
+
assert.deepEqual(gesture.move({ pointerId: 1, clientX: 200, clientY: 140 }), { claim: false, offset: 0 });
|
|
36
|
+
clock.time = 120;
|
|
37
|
+
assert.deepEqual(gesture.move({ pointerId: 1, clientX: 140, clientY: 110 }), { claim: false, offset: 0 });
|
|
38
|
+
assert.equal(gesture.end({ pointerId: 1, clientX: 140, clientY: 110 }), 0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("route swipe gesture ignores other pointers and cancelled pointers", () => {
|
|
42
|
+
const clock = createClock();
|
|
43
|
+
const gesture = createRouteSwipeGesture({ now: clock.now });
|
|
44
|
+
gesture.start({ pointerId: 1, clientX: 200, clientY: 100 });
|
|
45
|
+
clock.time = 100;
|
|
46
|
+
assert.deepEqual(gesture.move({ pointerId: 2, clientX: 100, clientY: 100 }), { claim: false, offset: 0 });
|
|
47
|
+
gesture.cancel({ pointerId: 1 });
|
|
48
|
+
assert.equal(gesture.end({ pointerId: 1, clientX: 100, clientY: 100 }), 0);
|
|
49
|
+
});
|
|
@@ -83,10 +83,17 @@ test("shell-web shell layout registers navigation at the app layout level", asyn
|
|
|
83
83
|
assert.match(source, /target="shell-layout:supporting-side-panel"/);
|
|
84
84
|
assert.match(source, /data-testid="jskit-shell-supporting-bottom-sheet"/);
|
|
85
85
|
assert.match(source, /data-testid="jskit-shell-supporting-side-panel"/);
|
|
86
|
-
assert.match(source, /inject\(
|
|
87
|
-
|
|
88
|
-
assert.match(
|
|
89
|
-
assert.match(source, /
|
|
86
|
+
assert.match(source, /inject\(SHELL_WEB_REFRESH_RUNTIME_KEY, null\)/);
|
|
87
|
+
const refreshRuntimeSource = await readFile(path.join(PACKAGE_DIR, "src", "client", "runtime", "refreshRuntime.js"), "utf8");
|
|
88
|
+
assert.match(refreshRuntimeSource, /SHELL_WEB_REFRESH_RUNTIME_KEY = "jskit\.shell-web\.runtime\.web-refresh\.client"/);
|
|
89
|
+
assert.match(source, /useShellPullToRefresh\(\{ enabled: isCompactLayout, refreshRuntime \}\)/);
|
|
90
|
+
const pullToRefreshSource = await readFile(
|
|
91
|
+
path.join(PACKAGE_DIR, "src", "client", "composables", "useShellPullToRefresh.js"),
|
|
92
|
+
"utf8"
|
|
93
|
+
);
|
|
94
|
+
assert.match(pullToRefreshSource, /window\.addEventListener\("pointerdown", handlePullPointerDown/);
|
|
95
|
+
assert.match(pullToRefreshSource, /window\.addEventListener\("touchmove", handlePullTouchMove/);
|
|
96
|
+
assert.match(pullToRefreshSource, /refreshRuntime\.refresh\("pull-to-refresh"\)/);
|
|
90
97
|
assert.match(source, /data-testid="jskit-shell-pull-refresh"/);
|
|
91
98
|
assert.match(source, /target="shell-layout:primary-menu"[\s\S]*default/);
|
|
92
99
|
assert.doesNotMatch(source, /target="shell-layout:primary-bottom-nav"[\s\S]*default/);
|
|
@@ -255,9 +262,10 @@ test("shell-web route transition keeps mobile route motion placement-driven", as
|
|
|
255
262
|
assert.match(source, /swipeEnabled:\s*\{[\s\S]*default:\s*true/);
|
|
256
263
|
assert.match(source, /window\.addEventListener\("pointerdown", handlePointerDown/);
|
|
257
264
|
assert.match(source, /document\.documentElement\.classList\.toggle\("shell-route-swipe-enabled", enabled\)/);
|
|
258
|
-
assert.match(source, /
|
|
265
|
+
assert.match(source, /createRouteSwipeGesture\(\)/);
|
|
266
|
+
assert.match(source, /const offset = swipeGesture\.end\(event\);[\s\S]*navigateBySwipe\(offset\)/);
|
|
259
267
|
assert.match(source, /router\.push\(nextEntry\.href\)/);
|
|
260
|
-
assert.match(source, /
|
|
268
|
+
assert.match(source, /isShellGestureIgnoredTarget\(event\.target\)/);
|
|
261
269
|
assert.match(source, /touch-action:\s*pan-y/);
|
|
262
270
|
assert.match(source, /\.shell-route-transition\s*\{[\s\S]*display:\s*flex/);
|
|
263
271
|
assert.match(source, /\.shell-route-transition\s*\{[\s\S]*min-height:\s*0/);
|
|
@@ -571,6 +579,8 @@ test("shell-web pattern home page relies on adaptive navigation instead of dead
|
|
|
571
579
|
assert.match(source, /--home-surface-panel-padding/);
|
|
572
580
|
assert.doesNotMatch(source, /generated-ui/u);
|
|
573
581
|
assert.match(source, /Service health/);
|
|
582
|
+
assert.match(source, /<v-skeleton-loader/u);
|
|
583
|
+
assert.doesNotMatch(source, /loading\.\.\.|v-progress-circular/u);
|
|
574
584
|
assert.match(source, /to="\/home\/settings\/general"/);
|
|
575
585
|
assert.doesNotMatch(source, /Use bottom navigation|Replace this content|Main public surface/);
|
|
576
586
|
assert.doesNotMatch(source, /\/console/);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { isPrimaryTouchPointer, isShellGestureIgnoredTarget } from "../src/client/support/gestureTargets.js";
|
|
4
|
+
import { resolveShellLayoutClass } from "../src/client/support/shellLayoutClass.js";
|
|
5
|
+
|
|
6
|
+
function createTarget(matchingSelectors = []) {
|
|
7
|
+
return {
|
|
8
|
+
closest(selectorList) {
|
|
9
|
+
const selectors = selectorList.split(",");
|
|
10
|
+
return matchingSelectors.some((selector) => selectors.includes(selector)) ? {} : null;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("shell layout class follows the display breakpoint, then the viewport width", () => {
|
|
16
|
+
assert.equal(resolveShellLayoutClass("xs", 1600), "compact");
|
|
17
|
+
assert.equal(resolveShellLayoutClass("SM"), "compact");
|
|
18
|
+
assert.equal(resolveShellLayoutClass("md", 300), "medium");
|
|
19
|
+
assert.equal(resolveShellLayoutClass("xxl", 300), "expanded");
|
|
20
|
+
assert.equal(resolveShellLayoutClass("", 599), "compact");
|
|
21
|
+
assert.equal(resolveShellLayoutClass(undefined, 600), "medium");
|
|
22
|
+
assert.equal(resolveShellLayoutClass("", 1280), "expanded");
|
|
23
|
+
assert.equal(resolveShellLayoutClass("", 0), "expanded");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("shell gestures follow primary touch and pen pointers only", () => {
|
|
27
|
+
assert.equal(isPrimaryTouchPointer({ isPrimary: true, button: 0, pointerType: "touch" }), true);
|
|
28
|
+
assert.equal(isPrimaryTouchPointer({ button: 0, pointerType: "pen" }), true);
|
|
29
|
+
assert.equal(isPrimaryTouchPointer({ isPrimary: true, button: 0, pointerType: "mouse" }), false);
|
|
30
|
+
assert.equal(isPrimaryTouchPointer({ isPrimary: false, button: 0, pointerType: "touch" }), false);
|
|
31
|
+
assert.equal(isPrimaryTouchPointer({ isPrimary: true, button: 2, pointerType: "touch" }), false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("shell gestures skip interactive targets and honour extra ignore selectors", () => {
|
|
35
|
+
assert.equal(isShellGestureIgnoredTarget(createTarget(["button"])), true);
|
|
36
|
+
assert.equal(isShellGestureIgnoredTarget(createTarget(["[data-shell-swipe-ignore]"])), true);
|
|
37
|
+
assert.equal(isShellGestureIgnoredTarget(createTarget(["[data-shell-pull-refresh-ignore]"])), false);
|
|
38
|
+
assert.equal(
|
|
39
|
+
isShellGestureIgnoredTarget(createTarget(["[data-shell-pull-refresh-ignore]"]), {
|
|
40
|
+
extraSelectors: ["[data-shell-pull-refresh-ignore]"]
|
|
41
|
+
}),
|
|
42
|
+
true
|
|
43
|
+
);
|
|
44
|
+
assert.equal(isShellGestureIgnoredTarget(createTarget([])), false);
|
|
45
|
+
assert.equal(isShellGestureIgnoredTarget(null), false);
|
|
46
|
+
});
|
|
@@ -3,6 +3,7 @@ import test from "node:test";
|
|
|
3
3
|
import {
|
|
4
4
|
buildSurfaceConfigContext,
|
|
5
5
|
readPlacementSurfaceConfig,
|
|
6
|
+
resolveAllowedOriginsFromPlacementContext,
|
|
6
7
|
joinSurfacePath,
|
|
7
8
|
resolveSurfaceIdFromPlacementPathname,
|
|
8
9
|
resolveSurfaceRootPathFromPlacementContext,
|
|
@@ -182,3 +183,55 @@ test("resolveSurfaceNavigationTargetFromPlacementContext resolves cross-origin t
|
|
|
182
183
|
external: false
|
|
183
184
|
});
|
|
184
185
|
});
|
|
186
|
+
|
|
187
|
+
test("resolveAllowedOriginsFromPlacementContext collects current and surface origins", () => {
|
|
188
|
+
const originalWindow = globalThis.window;
|
|
189
|
+
globalThis.window = {
|
|
190
|
+
location: {
|
|
191
|
+
origin: "https://current.example.com"
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const origins = resolveAllowedOriginsFromPlacementContext({
|
|
197
|
+
surfaceConfig: {
|
|
198
|
+
surfacesById: {
|
|
199
|
+
home: { origin: "https://home.example.com" },
|
|
200
|
+
app: { origin: "https://app.example.com" },
|
|
201
|
+
duplicate: { origin: "https://home.example.com" },
|
|
202
|
+
invalid: { origin: "mailto:hello@example.com" }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
assert.deepEqual(origins, [
|
|
208
|
+
"https://current.example.com",
|
|
209
|
+
"https://home.example.com",
|
|
210
|
+
"https://app.example.com"
|
|
211
|
+
]);
|
|
212
|
+
} finally {
|
|
213
|
+
if (typeof originalWindow === "undefined") {
|
|
214
|
+
delete globalThis.window;
|
|
215
|
+
} else {
|
|
216
|
+
globalThis.window = originalWindow;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("resolveAllowedOriginsFromPlacementContext reads surfaces through the placement surface config", () => {
|
|
222
|
+
const originalWindow = globalThis.window;
|
|
223
|
+
delete globalThis.window;
|
|
224
|
+
try {
|
|
225
|
+
assert.deepEqual(resolveAllowedOriginsFromPlacementContext(null), []);
|
|
226
|
+
assert.deepEqual(
|
|
227
|
+
resolveAllowedOriginsFromPlacementContext({
|
|
228
|
+
surfaceConfig: { surfacesById: { Admin: { origin: "https://ADMIN.example.com/x" } } }
|
|
229
|
+
}),
|
|
230
|
+
["https://admin.example.com"]
|
|
231
|
+
);
|
|
232
|
+
} finally {
|
|
233
|
+
if (typeof originalWindow !== "undefined") {
|
|
234
|
+
globalThis.window = originalWindow;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createRenderer, defineComponent, nextTick, ref } from "vue";
|
|
4
|
+
import { useShellPullToRefresh } from "../src/client/composables/useShellPullToRefresh.js";
|
|
5
|
+
|
|
6
|
+
function node(type) {
|
|
7
|
+
return { type, props: {}, children: [], parent: null, text: "" };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const renderer = createRenderer({
|
|
11
|
+
createElement: node,
|
|
12
|
+
createText: () => node("text"),
|
|
13
|
+
createComment: () => node("comment"),
|
|
14
|
+
setText() {},
|
|
15
|
+
setElementText() {},
|
|
16
|
+
patchProp() {},
|
|
17
|
+
parentNode: (item) => item.parent,
|
|
18
|
+
nextSibling: () => null,
|
|
19
|
+
insert(item, parent) {
|
|
20
|
+
item.parent = parent;
|
|
21
|
+
parent.children.push(item);
|
|
22
|
+
},
|
|
23
|
+
remove(item) {
|
|
24
|
+
item.parent?.children.splice(item.parent.children.indexOf(item), 1);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function installWindow() {
|
|
29
|
+
const listeners = new Map();
|
|
30
|
+
globalThis.window = {
|
|
31
|
+
scrollY: 0,
|
|
32
|
+
addEventListener(type, handler) {
|
|
33
|
+
listeners.set(type, handler);
|
|
34
|
+
},
|
|
35
|
+
removeEventListener(type, handler) {
|
|
36
|
+
if (listeners.get(type) === handler) {
|
|
37
|
+
listeners.delete(type);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
globalThis.document = { documentElement: { scrollTop: 0 }, body: { scrollTop: 0 } };
|
|
42
|
+
return {
|
|
43
|
+
listeners,
|
|
44
|
+
dispatch(type, event) {
|
|
45
|
+
listeners.get(type)?.({ preventDefault() {}, cancelable: true, target: { closest: () => null }, ...event });
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mountPull({ enabled = true } = {}) {
|
|
51
|
+
const page = installWindow();
|
|
52
|
+
const refreshes = [];
|
|
53
|
+
const refreshRuntime = {
|
|
54
|
+
async refresh(reason) {
|
|
55
|
+
refreshes.push(reason);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
let pull = null;
|
|
59
|
+
const app = renderer.createApp(defineComponent({
|
|
60
|
+
setup() {
|
|
61
|
+
pull = useShellPullToRefresh({ enabled: ref(enabled), refreshRuntime });
|
|
62
|
+
return () => null;
|
|
63
|
+
}
|
|
64
|
+
}));
|
|
65
|
+
app.mount(node("root"));
|
|
66
|
+
return { app, page, pull, refreshes };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const touchPointer = { isPrimary: true, button: 0, pointerType: "touch", pointerId: 7 };
|
|
70
|
+
|
|
71
|
+
test("pull to refresh refreshes after a long enough pull and resets", async () => {
|
|
72
|
+
const { app, page, pull, refreshes } = mountPull();
|
|
73
|
+
|
|
74
|
+
page.dispatch("pointerdown", { ...touchPointer, clientX: 100, clientY: 10 });
|
|
75
|
+
page.dispatch("pointermove", { ...touchPointer, clientX: 102, clientY: 210 });
|
|
76
|
+
assert.equal(pull.pullDistance.value, 110);
|
|
77
|
+
assert.equal(pull.pullRefreshLabel.value, "Release to refresh");
|
|
78
|
+
assert.equal(pull.pullIndicatorVisible.value, true);
|
|
79
|
+
|
|
80
|
+
page.dispatch("pointerup", { ...touchPointer });
|
|
81
|
+
assert.deepEqual(refreshes, ["pull-to-refresh"]);
|
|
82
|
+
await nextTick();
|
|
83
|
+
await nextTick();
|
|
84
|
+
assert.equal(pull.pullRefreshing.value, false);
|
|
85
|
+
assert.equal(pull.pullDistance.value, 0);
|
|
86
|
+
|
|
87
|
+
app.unmount();
|
|
88
|
+
assert.equal(page.listeners.size, 0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("pull to refresh follows the touch after the browser cancels the pointer", () => {
|
|
92
|
+
const { app, page, pull, refreshes } = mountPull();
|
|
93
|
+
|
|
94
|
+
page.dispatch("pointerdown", { ...touchPointer, clientX: 100, clientY: 10 });
|
|
95
|
+
page.dispatch("pointercancel", { ...touchPointer });
|
|
96
|
+
page.dispatch("touchmove", { touches: [{ identifier: 1, clientX: 100, clientY: 60 }] });
|
|
97
|
+
assert.equal(pull.pullDistance.value, 28);
|
|
98
|
+
assert.equal(pull.pullRefreshLabel.value, "Pull to refresh");
|
|
99
|
+
|
|
100
|
+
page.dispatch("touchend", { changedTouches: [{ identifier: 1 }] });
|
|
101
|
+
assert.deepEqual(refreshes, []);
|
|
102
|
+
assert.equal(pull.pullDistance.value, 0);
|
|
103
|
+
app.unmount();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("pull to refresh stays off outside the compact layout and on ignored targets", () => {
|
|
107
|
+
const wide = mountPull({ enabled: false });
|
|
108
|
+
wide.page.dispatch("pointerdown", { ...touchPointer, clientX: 100, clientY: 10 });
|
|
109
|
+
wide.page.dispatch("pointermove", { ...touchPointer, clientX: 100, clientY: 210 });
|
|
110
|
+
assert.equal(wide.pull.pullDistance.value, 0);
|
|
111
|
+
wide.app.unmount();
|
|
112
|
+
|
|
113
|
+
const compact = mountPull();
|
|
114
|
+
compact.page.dispatch("pointerdown", {
|
|
115
|
+
...touchPointer,
|
|
116
|
+
clientX: 100,
|
|
117
|
+
clientY: 10,
|
|
118
|
+
target: { closest: (selectors) => (selectors.includes("[data-shell-pull-refresh-ignore]") ? {} : null) }
|
|
119
|
+
});
|
|
120
|
+
compact.page.dispatch("pointermove", { ...touchPointer, clientX: 100, clientY: 210 });
|
|
121
|
+
assert.equal(compact.pull.pullDistance.value, 0);
|
|
122
|
+
compact.app.unmount();
|
|
123
|
+
});
|