@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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { guardedReloadApp } from "@jskit-ai/kernel/client/asyncModuleRecovery";
|
|
2
|
-
import { isRecord } from "@jskit-ai/kernel/shared/support";
|
|
2
|
+
import { isRecord, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
3
|
import { createProviderLogger as createSharedProviderLogger } from "@jskit-ai/kernel/shared/support/providerLogger";
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const APP_RELOAD_FAILURE_MESSAGE =
|
|
6
6
|
"The app cannot reload because the app server is not reachable. Restart the server, then click Reload.";
|
|
7
7
|
|
|
8
8
|
const RECOVERABLE_REQUEST_ERROR_MESSAGES = Object.freeze([
|
|
@@ -36,11 +36,6 @@ const SAFE_REQUEST_RECOVERY_METHODS = Object.freeze(new Set([
|
|
|
36
36
|
"HEAD"
|
|
37
37
|
]));
|
|
38
38
|
|
|
39
|
-
function normalizeText(value, fallback = "") {
|
|
40
|
-
const normalized = String(value || "").trim();
|
|
41
|
-
return normalized || String(fallback || "").trim();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
39
|
function errorMessage(error = null) {
|
|
45
40
|
return normalizeText(error?.message || error?.cause?.message || error);
|
|
46
41
|
}
|
|
@@ -106,7 +101,7 @@ function requestRecoveryMessage(error = null, {
|
|
|
106
101
|
return explicitMessage;
|
|
107
102
|
}
|
|
108
103
|
|
|
109
|
-
const requestLabel = normalizeText(label, "Request");
|
|
104
|
+
const requestLabel = normalizeText(label, { fallback: "Request" });
|
|
110
105
|
if (requestLabel === "Request") {
|
|
111
106
|
return "The app could not reach the server or network. Check the connection and try again.";
|
|
112
107
|
}
|
|
@@ -123,59 +118,19 @@ function resolveQueryMeta(query = null) {
|
|
|
123
118
|
return {};
|
|
124
119
|
}
|
|
125
120
|
|
|
126
|
-
function
|
|
127
|
-
const meta = resolveQueryMeta(query);
|
|
128
|
-
const jskitMeta = isRecord(meta.jskit) ? meta.jskit : {};
|
|
129
|
-
return jskitMeta.requestRecovery === false;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function resolveQueryRecoveryLabel(query = null) {
|
|
133
|
-
const meta = resolveQueryMeta(query);
|
|
134
|
-
const jskitMeta = isRecord(meta.jskit) ? meta.jskit : {};
|
|
135
|
-
|
|
136
|
-
return normalizeText(
|
|
137
|
-
jskitMeta.requestRecoveryLabel,
|
|
138
|
-
"Request"
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function resolveQueryRecoverySource(query = null) {
|
|
143
|
-
const meta = resolveQueryMeta(query);
|
|
144
|
-
const jskitMeta = isRecord(meta.jskit) ? meta.jskit : {};
|
|
145
|
-
|
|
146
|
-
return normalizeText(
|
|
147
|
-
jskitMeta.requestRecoverySource,
|
|
148
|
-
"shell-web.request-recovery.query"
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function resolveQueryRecoveryDedupeKey(query = null, fallback = "") {
|
|
121
|
+
function readQueryRecoveryMeta(query = null) {
|
|
153
122
|
const meta = resolveQueryMeta(query);
|
|
154
123
|
const jskitMeta = isRecord(meta.jskit) ? meta.jskit : {};
|
|
124
|
+
const dedupeWindowMs = jskitMeta.requestRecoveryDedupeWindowMs;
|
|
155
125
|
|
|
156
|
-
return
|
|
157
|
-
jskitMeta.
|
|
158
|
-
fallback
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const jskitMeta = isRecord(meta.jskit) ? meta.jskit : {};
|
|
165
|
-
const value = jskitMeta.requestRecoveryDedupeWindowMs;
|
|
166
|
-
|
|
167
|
-
return Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : null;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function resolveQueryRecoveryMethod(query = null) {
|
|
171
|
-
const meta = resolveQueryMeta(query);
|
|
172
|
-
const jskitMeta = isRecord(meta.jskit) ? meta.jskit : {};
|
|
173
|
-
|
|
174
|
-
return normalizeText(jskitMeta.requestRecoveryMethod).toUpperCase();
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function isSafeQueryRecoveryMethod(query = null) {
|
|
178
|
-
return SAFE_REQUEST_RECOVERY_METHODS.has(resolveQueryRecoveryMethod(query));
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
disabled: jskitMeta.requestRecovery === false,
|
|
128
|
+
label: normalizeText(jskitMeta.requestRecoveryLabel, { fallback: "Request" }),
|
|
129
|
+
source: normalizeText(jskitMeta.requestRecoverySource, { fallback: "shell-web.request-recovery.query" }),
|
|
130
|
+
dedupeKey: normalizeText(jskitMeta.requestRecoveryDedupeKey),
|
|
131
|
+
dedupeWindowMs: Number.isFinite(Number(dedupeWindowMs)) ? Math.max(0, Number(dedupeWindowMs)) : null,
|
|
132
|
+
method: normalizeText(jskitMeta.requestRecoveryMethod).toUpperCase()
|
|
133
|
+
});
|
|
179
134
|
}
|
|
180
135
|
|
|
181
136
|
function isActiveQuery(query = null) {
|
|
@@ -273,10 +228,11 @@ function installRecoverableQueryObserver({
|
|
|
273
228
|
|
|
274
229
|
function inspectQuery(query = null) {
|
|
275
230
|
const queryHash = resolveQueryHash(query);
|
|
231
|
+
const recoveryMeta = readQueryRecoveryMeta(query);
|
|
276
232
|
if (
|
|
277
233
|
!query ||
|
|
278
|
-
|
|
279
|
-
!
|
|
234
|
+
recoveryMeta.disabled ||
|
|
235
|
+
!SAFE_REQUEST_RECOVERY_METHODS.has(recoveryMeta.method) ||
|
|
280
236
|
!isActiveQuery(query)
|
|
281
237
|
) {
|
|
282
238
|
if (queryHash) {
|
|
@@ -300,18 +256,13 @@ function installRecoverableQueryObserver({
|
|
|
300
256
|
return;
|
|
301
257
|
}
|
|
302
258
|
|
|
303
|
-
const
|
|
304
|
-
const dedupeKey = resolveQueryRecoveryDedupeKey(
|
|
305
|
-
query,
|
|
306
|
-
`shell-web.request-recovery.query.${queryHash}.${errorUpdateCount}`
|
|
307
|
-
);
|
|
308
|
-
const dedupeWindowMs = resolveQueryRecoveryDedupeWindowMs(query);
|
|
259
|
+
const { dedupeWindowMs } = recoveryMeta;
|
|
309
260
|
const result = runtime.report(error, {
|
|
310
|
-
label:
|
|
261
|
+
label: recoveryMeta.label,
|
|
311
262
|
retry: createQueryRetry(queryClient, query),
|
|
312
|
-
source,
|
|
263
|
+
source: recoveryMeta.source,
|
|
313
264
|
stale: query?.state?.data !== undefined,
|
|
314
|
-
dedupeKey
|
|
265
|
+
dedupeKey: recoveryMeta.dedupeKey || `shell-web.request-recovery.query.${queryHash}.${errorUpdateCount}`,
|
|
315
266
|
...(dedupeWindowMs !== null ? { dedupeWindowMs } : {})
|
|
316
267
|
});
|
|
317
268
|
|
|
@@ -376,7 +327,7 @@ function createShellRequestRecoveryRuntime({
|
|
|
376
327
|
|
|
377
328
|
async function reload({
|
|
378
329
|
label = "App",
|
|
379
|
-
message =
|
|
330
|
+
message = APP_RELOAD_FAILURE_MESSAGE
|
|
380
331
|
} = {}) {
|
|
381
332
|
const reloaded = await guardedReloadApp({
|
|
382
333
|
label,
|
|
@@ -396,9 +347,9 @@ function createShellRequestRecoveryRuntime({
|
|
|
396
347
|
return reloaded;
|
|
397
348
|
}
|
|
398
349
|
|
|
399
|
-
function retryAction(
|
|
350
|
+
function retryAction(options, retry) {
|
|
400
351
|
return {
|
|
401
|
-
label: normalizeText(options.actionLabel, "Retry"),
|
|
352
|
+
label: normalizeText(options.actionLabel, { fallback: "Retry" }),
|
|
402
353
|
dismissOnRun: true,
|
|
403
354
|
async handler() {
|
|
404
355
|
try {
|
|
@@ -415,12 +366,12 @@ function createShellRequestRecoveryRuntime({
|
|
|
415
366
|
|
|
416
367
|
function reloadAction(options) {
|
|
417
368
|
return {
|
|
418
|
-
label: normalizeText(options.actionLabel, "Reload"),
|
|
369
|
+
label: normalizeText(options.actionLabel, { fallback: "Reload" }),
|
|
419
370
|
dismissOnRun: true,
|
|
420
371
|
handler() {
|
|
421
372
|
return reload({
|
|
422
373
|
label: options.label,
|
|
423
|
-
message: options.reloadFailureMessage ||
|
|
374
|
+
message: options.reloadFailureMessage || APP_RELOAD_FAILURE_MESSAGE
|
|
424
375
|
});
|
|
425
376
|
}
|
|
426
377
|
};
|
|
@@ -438,7 +389,7 @@ function createShellRequestRecoveryRuntime({
|
|
|
438
389
|
return null;
|
|
439
390
|
}
|
|
440
391
|
|
|
441
|
-
const source = normalizeText(options.source, "shell-web.request-recovery");
|
|
392
|
+
const source = normalizeText(options.source, { fallback: "shell-web.request-recovery" });
|
|
442
393
|
if (!errorRuntime || typeof errorRuntime.report !== "function") {
|
|
443
394
|
runtimeLogger.warn(
|
|
444
395
|
{
|
|
@@ -452,7 +403,7 @@ function createShellRequestRecoveryRuntime({
|
|
|
452
403
|
|
|
453
404
|
const retry = typeof options.retry === "function" ? options.retry : null;
|
|
454
405
|
const action = retry
|
|
455
|
-
? retryAction(
|
|
406
|
+
? retryAction(options, retry)
|
|
456
407
|
: options.reload === true
|
|
457
408
|
? reloadAction(options)
|
|
458
409
|
: null;
|
|
@@ -463,7 +414,7 @@ function createShellRequestRecoveryRuntime({
|
|
|
463
414
|
message: requestRecoveryMessage(error, options),
|
|
464
415
|
cause: error || null,
|
|
465
416
|
intent: "app-recoverable",
|
|
466
|
-
severity: normalizeText(options.severity, options.stale ? "warning" : "error"),
|
|
417
|
+
severity: normalizeText(options.severity, { fallback: options.stale ? "warning" : "error" }),
|
|
467
418
|
dedupeKey: normalizeText(options.dedupeKey),
|
|
468
419
|
dedupeWindowMs: Number.isFinite(Number(options.dedupeWindowMs))
|
|
469
420
|
? Math.max(0, Number(options.dedupeWindowMs))
|
|
@@ -513,6 +464,7 @@ function createShellRequestRecoveryRuntime({
|
|
|
513
464
|
}
|
|
514
465
|
|
|
515
466
|
export {
|
|
467
|
+
APP_RELOAD_FAILURE_MESSAGE,
|
|
516
468
|
createShellRequestRecoveryRuntime,
|
|
517
469
|
isRecoverableRequestError,
|
|
518
470
|
requestRecoveryMessage
|
|
@@ -139,10 +139,11 @@ function createShellBootstrapRuntime({
|
|
|
139
139
|
throw new Error("Bootstrap payload fetch requires a fetch implementation.");
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
const url = buildBootstrapUrl(request);
|
|
142
|
+
let request = Object.freeze({ path: bootstrapPath, query: Object.freeze({}), meta: Object.freeze({}) });
|
|
144
143
|
|
|
145
144
|
try {
|
|
145
|
+
request = await resolveBootstrapRequest(reason);
|
|
146
|
+
const url = buildBootstrapUrl(request);
|
|
146
147
|
const response = await fetchImplementation(url, {
|
|
147
148
|
method: "GET",
|
|
148
149
|
credentials: "include",
|
|
@@ -156,7 +157,7 @@ function createShellBootstrapRuntime({
|
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
const payload = await response.json();
|
|
159
|
-
return applyBootstrapPayload(payload, reason, request);
|
|
160
|
+
return await applyBootstrapPayload(payload, reason, request);
|
|
160
161
|
} catch (error) {
|
|
161
162
|
await applyBootstrapError(error, reason, request);
|
|
162
163
|
requestRecoveryRuntime?.report?.(error, {
|
|
@@ -177,7 +178,7 @@ function createShellBootstrapRuntime({
|
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
function refresh(reason = "manual") {
|
|
180
|
-
refreshQueue = refreshQueue.then(() => performRefresh(reason));
|
|
181
|
+
refreshQueue = refreshQueue.catch(() => null).then(() => performRefresh(reason));
|
|
181
182
|
return refreshQueue;
|
|
182
183
|
}
|
|
183
184
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { isRecord } from "@jskit-ai/kernel/shared/support";
|
|
2
|
+
import { createProviderLogger as createSharedProviderLogger } from "@jskit-ai/kernel/shared/support/providerLogger";
|
|
3
|
+
|
|
4
|
+
const SHELL_WEB_REFRESH_RUNTIME_KEY = "jskit.shell-web.runtime.web-refresh.client";
|
|
5
|
+
|
|
6
|
+
function isPullRefreshQuery(query = null) {
|
|
7
|
+
const meta = isRecord(query?.meta) ? query.meta : {};
|
|
8
|
+
if (meta.jskitRefresh === "pull") {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
if (meta.jskitRefresh === false) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const jskitMeta = isRecord(meta.jskit) ? meta.jskit : {};
|
|
16
|
+
return jskitMeta.refreshOnPull !== false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createShellRefreshRuntime({
|
|
20
|
+
bootstrapRuntime = null,
|
|
21
|
+
queryClient = null,
|
|
22
|
+
errorRuntime = null,
|
|
23
|
+
logger = null
|
|
24
|
+
} = {}) {
|
|
25
|
+
const runtimeLogger = createSharedProviderLogger(logger);
|
|
26
|
+
let refreshQueue = Promise.resolve(null);
|
|
27
|
+
|
|
28
|
+
async function refreshBootstrap(reason) {
|
|
29
|
+
if (!bootstrapRuntime || typeof bootstrapRuntime.refresh !== "function") {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await bootstrapRuntime.refresh(reason);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function refetchPullQueries() {
|
|
38
|
+
if (!queryClient || typeof queryClient.refetchQueries !== "function") {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await queryClient.refetchQueries(
|
|
43
|
+
{
|
|
44
|
+
type: "active",
|
|
45
|
+
predicate: isPullRefreshQuery
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
throwOnError: false
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function reportRefreshFailure(error) {
|
|
55
|
+
runtimeLogger.warn(
|
|
56
|
+
{
|
|
57
|
+
error: String(error?.message || error || "unknown error")
|
|
58
|
+
},
|
|
59
|
+
"Shell refresh failed."
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (!errorRuntime || typeof errorRuntime.report !== "function") {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
errorRuntime.report({
|
|
67
|
+
source: "shell-web.refresh",
|
|
68
|
+
message: "Unable to refresh. Check the connection and try again.",
|
|
69
|
+
intent: "app-recoverable",
|
|
70
|
+
severity: "error",
|
|
71
|
+
dedupeKey: "shell-web.refresh.failed",
|
|
72
|
+
dedupeWindowMs: 2000,
|
|
73
|
+
action: {
|
|
74
|
+
label: "Retry",
|
|
75
|
+
dismissOnRun: true,
|
|
76
|
+
handler() {
|
|
77
|
+
void refresh("retry");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function performRefresh(reason = "manual") {
|
|
84
|
+
const normalizedReason = String(reason || "manual").trim() || "manual";
|
|
85
|
+
try {
|
|
86
|
+
const [bootstrapRefreshed, queriesRefetched] = await Promise.all([
|
|
87
|
+
refreshBootstrap(normalizedReason),
|
|
88
|
+
refetchPullQueries()
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
reason: normalizedReason,
|
|
93
|
+
bootstrapRefreshed,
|
|
94
|
+
queriesRefetched
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
reportRefreshFailure(error);
|
|
98
|
+
return Object.freeze({
|
|
99
|
+
reason: normalizedReason,
|
|
100
|
+
bootstrapRefreshed: false,
|
|
101
|
+
queriesRefetched: false,
|
|
102
|
+
error
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function refresh(reason = "manual") {
|
|
108
|
+
refreshQueue = refreshQueue
|
|
109
|
+
.catch(() => null)
|
|
110
|
+
.then(() => performRefresh(reason));
|
|
111
|
+
return refreshQueue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
refresh
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export {
|
|
120
|
+
SHELL_WEB_REFRESH_RUNTIME_KEY,
|
|
121
|
+
createShellRefreshRuntime
|
|
122
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const SHELL_GESTURE_IGNORED_SELECTORS = Object.freeze([
|
|
2
|
+
"a",
|
|
3
|
+
"button",
|
|
4
|
+
"input",
|
|
5
|
+
"select",
|
|
6
|
+
"textarea",
|
|
7
|
+
"summary",
|
|
8
|
+
"[role='button']",
|
|
9
|
+
"[role='link']",
|
|
10
|
+
"[role='slider']",
|
|
11
|
+
"[contenteditable='true']",
|
|
12
|
+
"[data-shell-swipe-ignore]"
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
// Shell gestures follow touch and pen contact, never a mouse drag.
|
|
16
|
+
function isPrimaryTouchPointer(event) {
|
|
17
|
+
return event?.isPrimary !== false && event?.button === 0 && event?.pointerType !== "mouse";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Interactive controls keep their own gestures; the shell does not start one on them.
|
|
21
|
+
function isShellGestureIgnoredTarget(target, { extraSelectors = [] } = {}) {
|
|
22
|
+
const selectors = [...SHELL_GESTURE_IGNORED_SELECTORS, ...extraSelectors].join(",");
|
|
23
|
+
return Boolean(target?.closest?.(selectors));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { isPrimaryTouchPointer, isShellGestureIgnoredTarget };
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
-
import {
|
|
2
|
+
import { resolveShellLinkPath } from "../navigation/linkResolver.js";
|
|
3
|
+
import {
|
|
4
|
+
resolveSurfaceDefinitionFromPlacementContext,
|
|
5
|
+
resolveSurfaceNavigationTargetFromPlacementContext
|
|
6
|
+
} from "../placement/surfaceContext.js";
|
|
3
7
|
|
|
4
8
|
function normalizeMenuLinkPathname(pathname = "") {
|
|
5
9
|
const source = String(pathname || "").trim();
|
|
@@ -61,37 +65,49 @@ function resolveMenuLinkTarget({
|
|
|
61
65
|
placementContext = null,
|
|
62
66
|
scopedSuffix = "/",
|
|
63
67
|
unscopedSuffix = "/",
|
|
64
|
-
routeParams = {}
|
|
65
|
-
resolvePagePath = null
|
|
68
|
+
routeParams = {}
|
|
66
69
|
} = {}) {
|
|
67
70
|
const explicitTarget = normalizeText(to);
|
|
71
|
+
if (explicitTarget && !isRelativeMenuLinkTarget(explicitTarget)) {
|
|
72
|
+
return explicitTarget;
|
|
73
|
+
}
|
|
74
|
+
|
|
68
75
|
const targetSurfaceId = resolveMenuLinkSurfaceId(surface, currentSurfaceId);
|
|
69
76
|
const scopedRouteRequired = surfaceRequiresWorkspaceFromPlacementContext(placementContext, targetSurfaceId);
|
|
70
77
|
const suffixTemplate = normalizeText(scopedRouteRequired ? scopedSuffix : unscopedSuffix) || "/";
|
|
71
78
|
const interpolatedSuffix = interpolateBracketParams(suffixTemplate, routeParams);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
targetSurfaceId &&
|
|
75
|
-
interpolatedSuffix &&
|
|
76
|
-
!interpolatedSuffix.includes("[")
|
|
77
|
-
? normalizeText(resolvePagePath(interpolatedSuffix, {
|
|
78
|
-
surface: targetSurfaceId,
|
|
79
|
-
mode: "auto"
|
|
80
|
-
}))
|
|
81
|
-
: "";
|
|
82
|
-
|
|
83
|
-
if (!explicitTarget) {
|
|
84
|
-
return resolvedSuffixTarget;
|
|
79
|
+
if (!targetSurfaceId || !interpolatedSuffix || interpolatedSuffix.includes("[")) {
|
|
80
|
+
return "";
|
|
85
81
|
}
|
|
86
82
|
|
|
87
|
-
|
|
88
|
-
|
|
83
|
+
return normalizeText(resolveShellLinkPath({
|
|
84
|
+
context: placementContext,
|
|
85
|
+
surface: targetSurfaceId,
|
|
86
|
+
relativePath: interpolatedSuffix,
|
|
87
|
+
params: routeParams,
|
|
88
|
+
strictParams: true
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Menu and tab links render nothing for an empty target.
|
|
93
|
+
function resolveMenuLinkHref(placementContext = null, target = "") {
|
|
94
|
+
const path = String(target || "").trim();
|
|
95
|
+
if (!path) {
|
|
96
|
+
return {
|
|
97
|
+
href: "",
|
|
98
|
+
sameOrigin: true
|
|
99
|
+
};
|
|
89
100
|
}
|
|
90
101
|
|
|
91
|
-
|
|
102
|
+
const navigationTarget = resolveSurfaceNavigationTargetFromPlacementContext(placementContext, { path });
|
|
103
|
+
return {
|
|
104
|
+
href: navigationTarget.href,
|
|
105
|
+
sameOrigin: navigationTarget.sameOrigin
|
|
106
|
+
};
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
export {
|
|
95
110
|
normalizeMenuLinkPathname,
|
|
111
|
+
resolveMenuLinkHref,
|
|
96
112
|
resolveMenuLinkTarget
|
|
97
113
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
+
|
|
3
|
+
// Route params as path builders consume them: trimmed keys, the first value of a
|
|
4
|
+
// repeated param, and no empty values.
|
|
5
|
+
function normalizeRouteParams(params = null) {
|
|
6
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
7
|
+
return {};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const output = {};
|
|
11
|
+
for (const [rawKey, rawValue] of Object.entries(params)) {
|
|
12
|
+
const key = normalizeText(rawKey);
|
|
13
|
+
if (!key) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const value = Array.isArray(rawValue) ? rawValue[0] : rawValue;
|
|
18
|
+
const normalizedValue = normalizeText(value);
|
|
19
|
+
if (normalizedValue) {
|
|
20
|
+
output[key] = normalizedValue;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return output;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { normalizeRouteParams };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const SWIPE_MIN_DISTANCE = 36;
|
|
2
|
+
const SWIPE_MAX_VERTICAL_DRIFT = 120;
|
|
3
|
+
const SWIPE_MIN_VELOCITY = 0.08;
|
|
4
|
+
const SWIPE_CLAIM_DISTANCE = 10;
|
|
5
|
+
const NO_SWIPE = Object.freeze({ claim: false, offset: 0 });
|
|
6
|
+
|
|
7
|
+
function isAcceptedSwipe({ deltaX = 0, deltaY = 0, elapsed = 1 } = {}) {
|
|
8
|
+
const absX = Math.abs(deltaX);
|
|
9
|
+
const absY = Math.abs(deltaY);
|
|
10
|
+
const velocity = absX / Math.max(elapsed, 1);
|
|
11
|
+
return absX >= SWIPE_MIN_DISTANCE && absY <= SWIPE_MAX_VERTICAL_DRIFT && absX > absY && velocity >= SWIPE_MIN_VELOCITY;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function isVerticalDrift(absX, absY) {
|
|
15
|
+
return absY > SWIPE_MAX_VERTICAL_DRIFT || (absY > 28 && absY > absX * 1.3);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Tracks one horizontal swipe between primary navigation destinations. A swipe
|
|
19
|
+
// that drifts vertically is dropped for good: the gesture became a scroll.
|
|
20
|
+
function createRouteSwipeGesture({ now = () => performance.now() } = {}) {
|
|
21
|
+
let active = null;
|
|
22
|
+
|
|
23
|
+
function tracks(pointerId) {
|
|
24
|
+
return Boolean(active) && pointerId === active.pointerId;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function start({ pointerId, clientX, clientY }) {
|
|
28
|
+
active = { pointerId, startX: clientX, startY: clientY, startTime: now() };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function reset() {
|
|
32
|
+
active = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function measure({ clientX, clientY }) {
|
|
36
|
+
return {
|
|
37
|
+
deltaX: clientX - active.startX,
|
|
38
|
+
deltaY: clientY - active.startY,
|
|
39
|
+
elapsed: Math.max(now() - active.startTime, 1)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function offsetFor(deltaX) {
|
|
44
|
+
return deltaX < 0 ? 1 : -1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function move(event) {
|
|
48
|
+
if (!tracks(event.pointerId)) {
|
|
49
|
+
return NO_SWIPE;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const swipe = measure(event);
|
|
53
|
+
const absX = Math.abs(swipe.deltaX);
|
|
54
|
+
const absY = Math.abs(swipe.deltaY);
|
|
55
|
+
if (isVerticalDrift(absX, absY)) {
|
|
56
|
+
active = null;
|
|
57
|
+
return NO_SWIPE;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const claim = absX > SWIPE_CLAIM_DISTANCE && absX > absY;
|
|
61
|
+
if (!isAcceptedSwipe(swipe)) {
|
|
62
|
+
return { claim, offset: 0 };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
active = null;
|
|
66
|
+
return { claim, offset: offsetFor(swipe.deltaX) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function end(event) {
|
|
70
|
+
if (!tracks(event.pointerId)) {
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const swipe = measure(event);
|
|
75
|
+
active = null;
|
|
76
|
+
return isAcceptedSwipe(swipe) ? offsetFor(swipe.deltaX) : 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cancel(event) {
|
|
80
|
+
if (tracks(event.pointerId)) {
|
|
81
|
+
active = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Object.freeze({ start, reset, move, end, cancel });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export { createRouteSwipeGesture };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const COMPACT_MAX_WIDTH = 600;
|
|
2
|
+
const MEDIUM_MAX_WIDTH = 1280;
|
|
3
|
+
|
|
4
|
+
// Maps the Vuetify display breakpoint name to the shell layout class. Without
|
|
5
|
+
// a known breakpoint the viewport width decides.
|
|
6
|
+
function resolveShellLayoutClass(displayName = "", viewportWidth = 0) {
|
|
7
|
+
const name = String(displayName || "").trim().toLowerCase();
|
|
8
|
+
if (name === "xs" || name === "sm") {
|
|
9
|
+
return "compact";
|
|
10
|
+
}
|
|
11
|
+
if (name === "md") {
|
|
12
|
+
return "medium";
|
|
13
|
+
}
|
|
14
|
+
if (name === "lg" || name === "xl" || name === "xxl") {
|
|
15
|
+
return "expanded";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const width = Number(viewportWidth) || 0;
|
|
19
|
+
if (width > 0 && width < COMPACT_MAX_WIDTH) {
|
|
20
|
+
return "compact";
|
|
21
|
+
}
|
|
22
|
+
if (width > 0 && width < MEDIUM_MAX_WIDTH) {
|
|
23
|
+
return "medium";
|
|
24
|
+
}
|
|
25
|
+
return "expanded";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readViewportWidth() {
|
|
29
|
+
return typeof window === "object" ? Number(window.innerWidth || 0) : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { readViewportWidth, resolveShellLayoutClass };
|