@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.
Files changed (47) hide show
  1. package/fixtures/adaptive-shell/src/main.js +6 -3
  2. package/package.json +2 -2
  3. package/patterns/application-shell/PATTERN.md +18 -4
  4. package/patterns/application-shell/example/src/pages/home/index.vue +15 -5
  5. package/patterns/application-shell/example/tests/e2e/adaptive-shell.spec.ts +1 -1
  6. package/src/architecture/inspectVueArchitecture.js +1092 -336
  7. package/src/client/asyncModuleRecovery/runtime.js +116 -0
  8. package/src/client/components/ShellLayout.vue +18 -288
  9. package/src/client/components/ShellMenuLinkItem.vue +3 -21
  10. package/src/client/components/ShellOutlet.vue +2 -24
  11. package/src/client/components/ShellRouteTransition.vue +16 -114
  12. package/src/client/components/ShellSurfaceAwareMenuLinkItem.vue +5 -100
  13. package/src/client/components/ShellTabLinkItem.vue +5 -101
  14. package/src/client/composables/useShellNavigationLink.js +85 -0
  15. package/src/client/composables/useShellPullToRefresh.js +260 -0
  16. package/src/client/error/index.js +1 -1
  17. package/src/client/error/inject.js +4 -1
  18. package/src/client/error/policy.js +4 -13
  19. package/src/client/error/presenters.js +4 -9
  20. package/src/client/error/store.js +1 -1
  21. package/src/client/navigation/linkResolver.js +5 -27
  22. package/src/client/navigation/usePaths.js +1 -21
  23. package/src/client/placement/index.js +2 -0
  24. package/src/client/placement/inject.js +4 -1
  25. package/src/client/placement/runtime.js +2 -4
  26. package/src/client/placement/surfaceContext.js +11 -8
  27. package/src/client/placement/validators.js +18 -65
  28. package/src/client/providers/ShellWebClientProvider.js +100 -356
  29. package/src/client/requestRecovery/runtime.js +29 -77
  30. package/src/client/runtime/bootstrapRuntime.js +5 -4
  31. package/src/client/runtime/refreshRuntime.js +122 -0
  32. package/src/client/support/gestureTargets.js +26 -0
  33. package/src/client/support/menuLinkTarget.js +35 -19
  34. package/src/client/support/routeParams.js +26 -0
  35. package/src/client/support/routeSwipeGesture.js +88 -0
  36. package/src/client/support/routeTransitionKey.js +1 -3
  37. package/src/client/support/shellLayoutClass.js +32 -0
  38. package/test/bootstrapRuntime.test.js +98 -0
  39. package/test/errorActions.test.js +2 -1
  40. package/test/errorRuntime.test.js +30 -0
  41. package/test/linkResolver.test.js +23 -1
  42. package/test/menuLinkTarget.test.js +77 -0
  43. package/test/routeSwipeGesture.test.js +49 -0
  44. package/test/settingsPlacementContract.test.js +16 -6
  45. package/test/shellGestureSupport.test.js +46 -0
  46. package/test/surfaceContext.test.js +53 -0
  47. 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 REQUEST_RECOVERY_RELOAD_FAILURE_MESSAGE =
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 isRequestRecoveryDisabled(query = null) {
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 normalizeText(
157
- jskitMeta.requestRecoveryDedupeKey,
158
- fallback
159
- );
160
- }
161
-
162
- function resolveQueryRecoveryDedupeWindowMs(query = null) {
163
- const meta = resolveQueryMeta(query);
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
- isRequestRecoveryDisabled(query) ||
279
- !isSafeQueryRecoveryMethod(query) ||
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 source = resolveQueryRecoverySource(query);
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: resolveQueryRecoveryLabel(query),
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 = REQUEST_RECOVERY_RELOAD_FAILURE_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(error, options, retry) {
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 || REQUEST_RECOVERY_RELOAD_FAILURE_MESSAGE
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(error, options, retry)
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
- const request = await resolveBootstrapRequest(reason);
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 { resolveSurfaceDefinitionFromPlacementContext } from "../placement/surfaceContext.js";
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
- const resolvedSuffixTarget =
73
- typeof resolvePagePath === "function" &&
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
- if (isRelativeMenuLinkTarget(explicitTarget)) {
88
- return resolvedSuffixTarget;
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
- return explicitTarget;
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 };
@@ -1,6 +1,4 @@
1
- function normalizeText(value = "") {
2
- return String(value || "").trim();
3
- }
1
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
2
 
5
3
  function resolveShellRouteTransitionKey({
6
4
  routePathKey = "",
@@ -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 };