@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
@@ -9,7 +9,6 @@ import {
9
9
  import { useRoute, useRouter } from "vue-router";
10
10
  import { useDisplay } from "vuetify";
11
11
  import { normalizePathname } from "@jskit-ai/kernel/shared";
12
- import { resolveShellLinkPath } from "../navigation/linkResolver.js";
13
12
  import {
14
13
  useWebPlacementContext,
15
14
  useWebPlacementRuntime
@@ -25,6 +24,9 @@ import {
25
24
  resolveMenuLinkTarget
26
25
  } from "../support/menuLinkTarget.js";
27
26
  import { resolveShellRouteTransitionKey } from "../support/routeTransitionKey.js";
27
+ import { createRouteSwipeGesture } from "../support/routeSwipeGesture.js";
28
+ import { isPrimaryTouchPointer, isShellGestureIgnoredTarget } from "../support/gestureTargets.js";
29
+ import { readViewportWidth, resolveShellLayoutClass } from "../support/shellLayoutClass.js";
28
30
 
29
31
  const props = defineProps({
30
32
  enabled: {
@@ -55,13 +57,9 @@ const revision = ref(
55
57
  );
56
58
  const transitionDirection = ref("none");
57
59
  let unsubscribe = null;
58
- let activeSwipe = null;
60
+ const swipeGesture = createRouteSwipeGesture();
59
61
  let stopSwipeClassWatch = null;
60
62
 
61
- const SWIPE_MIN_DISTANCE = 36;
62
- const SWIPE_MAX_VERTICAL_DRIFT = 120;
63
- const SWIPE_MIN_VELOCITY = 0.08;
64
-
65
63
  onMounted(() => {
66
64
  if (typeof placementRuntime.subscribe !== "function") {
67
65
  unsubscribe = null;
@@ -111,16 +109,7 @@ onBeforeUnmount(() => {
111
109
  }
112
110
  });
113
111
 
114
- const resolvedLayoutClass = computed(() => {
115
- const displayName = String(display?.name?.value || "").trim().toLowerCase();
116
- if (displayName === "xs" || displayName === "sm") {
117
- return "compact";
118
- }
119
- if (displayName === "md") {
120
- return "medium";
121
- }
122
- return "expanded";
123
- });
112
+ const resolvedLayoutClass = computed(() => resolveShellLayoutClass(display?.name?.value, readViewportWidth()));
124
113
 
125
114
  const currentPathname = computed(() =>
126
115
  normalizeComparablePathname(resolveRuntimePathname(route?.path || route?.fullPath || "/"))
@@ -158,16 +147,7 @@ const primaryNavEntries = computed(() => {
158
147
  placementContext: contextValue,
159
148
  scopedSuffix: entryProps.scopedSuffix,
160
149
  unscopedSuffix: entryProps.unscopedSuffix,
161
- routeParams,
162
- resolvePagePath(relativePath, options = {}) {
163
- return resolveShellLinkPath({
164
- context: contextValue,
165
- surface: options.surface,
166
- relativePath,
167
- params: routeParams,
168
- strictParams: options.strictParams !== false
169
- });
170
- }
150
+ routeParams
171
151
  });
172
152
  const navigationTarget = resolveSurfaceNavigationTargetFromPlacementContext(contextValue, {
173
153
  path: target,
@@ -259,18 +239,12 @@ const swipeNavigationEnabled = computed(() =>
259
239
  );
260
240
 
261
241
  function handlePointerDown(event) {
262
- if (!swipeNavigationEnabled.value || !isPrimaryPointerEvent(event) || isSwipeIgnoredTarget(event.target)) {
263
- activeSwipe = null;
242
+ if (!swipeNavigationEnabled.value || !isPrimaryTouchPointer(event) || isShellGestureIgnoredTarget(event.target)) {
243
+ swipeGesture.reset();
264
244
  return;
265
245
  }
266
246
 
267
- activeSwipe = {
268
- pointerId: event.pointerId,
269
- startX: event.clientX,
270
- startY: event.clientY,
271
- startTime: performance.now(),
272
- cancelled: false
273
- };
247
+ swipeGesture.start(event);
274
248
  try {
275
249
  event.target?.setPointerCapture?.(event.pointerId);
276
250
  } catch {
@@ -279,60 +253,24 @@ function handlePointerDown(event) {
279
253
  }
280
254
 
281
255
  function handlePointerMove(event) {
282
- if (!activeSwipe || event.pointerId !== activeSwipe.pointerId) {
283
- return;
284
- }
285
-
286
- const deltaX = event.clientX - activeSwipe.startX;
287
- const deltaY = event.clientY - activeSwipe.startY;
288
- const absX = Math.abs(deltaX);
289
- const absY = Math.abs(deltaY);
290
-
291
- if (absY > SWIPE_MAX_VERTICAL_DRIFT || (absY > 28 && absY > absX * 1.3)) {
292
- activeSwipe.cancelled = true;
293
- return;
294
- }
295
-
296
- if (absX > 10 && absX > absY) {
256
+ const { claim, offset } = swipeGesture.move(event);
257
+ if (claim) {
297
258
  event.preventDefault();
298
259
  }
299
-
300
- if (isAcceptedSwipe({ deltaX, deltaY, elapsed: Math.max(performance.now() - activeSwipe.startTime, 1) })) {
301
- const offset = deltaX < 0 ? 1 : -1;
302
- activeSwipe = null;
260
+ if (offset) {
303
261
  navigateBySwipe(offset);
304
262
  }
305
263
  }
306
264
 
307
265
  function handlePointerEnd(event) {
308
- if (!activeSwipe || event.pointerId !== activeSwipe.pointerId) {
309
- return;
310
- }
311
-
312
- const swipe = activeSwipe;
313
- activeSwipe = null;
314
-
315
- if (swipe.cancelled || !swipeNavigationEnabled.value) {
316
- return;
317
- }
318
-
319
- const deltaX = event.clientX - swipe.startX;
320
- const deltaY = event.clientY - swipe.startY;
321
- const elapsed = Math.max(performance.now() - swipe.startTime, 1);
322
- const absX = Math.abs(deltaX);
323
- const velocity = absX / elapsed;
324
-
325
- if (!isAcceptedSwipe({ deltaX, deltaY, elapsed, velocity })) {
326
- return;
266
+ const offset = swipeGesture.end(event);
267
+ if (offset && swipeNavigationEnabled.value) {
268
+ navigateBySwipe(offset);
327
269
  }
328
-
329
- navigateBySwipe(deltaX < 0 ? 1 : -1);
330
270
  }
331
271
 
332
272
  function handlePointerCancel(event) {
333
- if (activeSwipe && event.pointerId === activeSwipe.pointerId) {
334
- activeSwipe = null;
335
- }
273
+ swipeGesture.cancel(event);
336
274
  }
337
275
 
338
276
  function navigateBySwipe(offset = 0) {
@@ -345,18 +283,6 @@ function navigateBySwipe(offset = 0) {
345
283
  void router.push(nextEntry.href).catch(() => {});
346
284
  }
347
285
 
348
- function isAcceptedSwipe({ deltaX = 0, deltaY = 0, elapsed = 1, velocity = null } = {}) {
349
- const absX = Math.abs(deltaX);
350
- const absY = Math.abs(deltaY);
351
- const resolvedVelocity = velocity == null ? absX / Math.max(elapsed, 1) : velocity;
352
- return Boolean(
353
- absX >= SWIPE_MIN_DISTANCE &&
354
- absY <= SWIPE_MAX_VERTICAL_DRIFT &&
355
- absX > absY &&
356
- resolvedVelocity >= SWIPE_MIN_VELOCITY
357
- );
358
- }
359
-
360
286
  function normalizeComparablePathname(value = "") {
361
287
  const raw = String(value || "").trim();
362
288
  if (!raw) {
@@ -384,30 +310,6 @@ function pathMatchesNavigationEntry(pathname = "", entry = {}) {
384
310
  }
385
311
  return pathname === target || pathname.startsWith(`${target}/`);
386
312
  }
387
-
388
- function isPrimaryPointerEvent(event) {
389
- return event?.isPrimary !== false && event?.button === 0 && event?.pointerType !== "mouse";
390
- }
391
-
392
- function isSwipeIgnoredTarget(target) {
393
- return Boolean(
394
- target?.closest?.(
395
- [
396
- "a",
397
- "button",
398
- "input",
399
- "select",
400
- "textarea",
401
- "summary",
402
- "[role='button']",
403
- "[role='link']",
404
- "[role='slider']",
405
- "[contenteditable='true']",
406
- "[data-shell-swipe-ignore]"
407
- ].join(",")
408
- )
409
- );
410
- }
411
313
  </script>
412
314
 
413
315
  <template>
@@ -1,108 +1,13 @@
1
1
  <script setup>
2
- import { computed } from "vue";
3
- import { useRoute } from "vue-router";
4
- import { resolveShellLinkPath } from "../navigation/linkResolver.js";
5
2
  import {
6
- resolveSurfaceIdFromPlacementPathname,
7
- resolveSurfaceNavigationTargetFromPlacementContext,
8
- useWebPlacementContext
9
- } from "../placement/index.js";
10
- import { resolveMenuLinkIcon } from "../lib/menuIcons.js";
11
- import { resolveMenuLinkTarget } from "../support/menuLinkTarget.js";
3
+ shellNavigationLinkProps,
4
+ useShellNavigationLink
5
+ } from "../composables/useShellNavigationLink.js";
12
6
  import { activateShellNavigationLinkOnSpace } from "../support/navigationLinkKeyboard.js";
13
7
  import ShellNavigationTooltip from "./ShellNavigationTooltip.vue";
14
8
 
15
- const props = defineProps({
16
- label: {
17
- type: String,
18
- default: ""
19
- },
20
- to: {
21
- type: String,
22
- default: ""
23
- },
24
- icon: {
25
- type: String,
26
- default: ""
27
- },
28
- surface: {
29
- type: String,
30
- default: ""
31
- },
32
- scopedSuffix: {
33
- type: String,
34
- default: "/"
35
- },
36
- unscopedSuffix: {
37
- type: String,
38
- default: "/"
39
- },
40
- disabled: {
41
- type: Boolean,
42
- default: false
43
- },
44
- exact: {
45
- type: Boolean,
46
- default: false
47
- }
48
- });
49
-
50
- const route = useRoute();
51
- const { context: placementContext } = useWebPlacementContext();
52
-
53
- const currentSurfaceId = computed(() => {
54
- return resolveSurfaceIdFromPlacementPathname(
55
- placementContext.value,
56
- String(route?.path || route?.fullPath || "").trim()
57
- );
58
- });
59
-
60
- const resolvedTo = computed(() => {
61
- return resolveMenuLinkTarget({
62
- to: props.to,
63
- surface: props.surface,
64
- currentSurfaceId: currentSurfaceId.value,
65
- placementContext: placementContext.value,
66
- scopedSuffix: props.scopedSuffix,
67
- unscopedSuffix: props.unscopedSuffix,
68
- routeParams: route.params || {},
69
- resolvePagePath(relativePath, options = {}) {
70
- return resolveShellLinkPath({
71
- context: placementContext.value,
72
- surface: options.surface,
73
- relativePath,
74
- params: route.params || {},
75
- strictParams: options.strictParams !== false
76
- });
77
- }
78
- });
79
- });
80
-
81
- const resolvedTarget = computed(() => {
82
- const target = String(resolvedTo.value || "").trim();
83
- if (!target) {
84
- return {
85
- href: "",
86
- sameOrigin: true
87
- };
88
- }
89
-
90
- const navigationTarget = resolveSurfaceNavigationTargetFromPlacementContext(placementContext.value, {
91
- path: target
92
- });
93
- return {
94
- href: navigationTarget.href,
95
- sameOrigin: navigationTarget.sameOrigin
96
- };
97
- });
98
-
99
- const resolvedIcon = computed(() =>
100
- resolveMenuLinkIcon({
101
- icon: props.icon,
102
- label: props.label,
103
- to: resolvedTarget.value.href || resolvedTo.value
104
- })
105
- );
9
+ const props = defineProps(shellNavigationLinkProps);
10
+ const { resolvedTarget, resolvedIcon } = useShellNavigationLink(props);
106
11
  </script>
107
12
 
108
13
  <template>
@@ -1,107 +1,11 @@
1
1
  <script setup>
2
- import { computed } from "vue";
3
- import { useRoute } from "vue-router";
4
- import { resolveShellLinkPath } from "../navigation/linkResolver.js";
5
2
  import {
6
- resolveSurfaceIdFromPlacementPathname,
7
- resolveSurfaceNavigationTargetFromPlacementContext,
8
- useWebPlacementContext
9
- } from "../placement/index.js";
10
- import { resolveMenuLinkIcon } from "../lib/menuIcons.js";
11
- import { resolveMenuLinkTarget } from "../support/menuLinkTarget.js";
12
-
13
- const props = defineProps({
14
- label: {
15
- type: String,
16
- default: ""
17
- },
18
- to: {
19
- type: String,
20
- default: ""
21
- },
22
- icon: {
23
- type: String,
24
- default: ""
25
- },
26
- surface: {
27
- type: String,
28
- default: ""
29
- },
30
- scopedSuffix: {
31
- type: String,
32
- default: "/"
33
- },
34
- unscopedSuffix: {
35
- type: String,
36
- default: "/"
37
- },
38
- disabled: {
39
- type: Boolean,
40
- default: false
41
- },
42
- exact: {
43
- type: Boolean,
44
- default: false
45
- }
46
- });
47
-
48
- const route = useRoute();
49
- const { context: placementContext } = useWebPlacementContext();
50
-
51
- const currentSurfaceId = computed(() => {
52
- return resolveSurfaceIdFromPlacementPathname(
53
- placementContext.value,
54
- String(route?.path || route?.fullPath || "").trim()
55
- );
56
- });
57
-
58
- const resolvedTo = computed(() => {
59
- return resolveMenuLinkTarget({
60
- to: props.to,
61
- surface: props.surface,
62
- currentSurfaceId: currentSurfaceId.value,
63
- placementContext: placementContext.value,
64
- scopedSuffix: props.scopedSuffix,
65
- unscopedSuffix: props.unscopedSuffix,
66
- routeParams: route.params || {},
67
- resolvePagePath(relativePath, options = {}) {
68
- return resolveShellLinkPath({
69
- context: placementContext.value,
70
- surface: options.surface,
71
- relativePath,
72
- params: route.params || {},
73
- strictParams: options.strictParams !== false
74
- });
75
- }
76
- });
77
- });
78
-
79
- const resolvedTarget = computed(() => {
80
- const target = String(resolvedTo.value || "").trim();
81
- if (!target) {
82
- return {
83
- href: "",
84
- sameOrigin: true
85
- };
86
- }
87
-
88
- const navigationTarget = resolveSurfaceNavigationTargetFromPlacementContext(placementContext.value, {
89
- path: target
90
- });
91
- return {
92
- href: navigationTarget.href,
93
- sameOrigin: navigationTarget.sameOrigin
94
- };
95
- });
96
-
97
- const resolvedIcon = computed(() =>
98
- resolveMenuLinkIcon({
99
- icon: props.icon,
100
- label: props.label,
101
- to: resolvedTarget.value.href || resolvedTo.value
102
- })
103
- );
3
+ shellNavigationLinkProps,
4
+ useShellNavigationLink
5
+ } from "../composables/useShellNavigationLink.js";
104
6
 
7
+ const props = defineProps(shellNavigationLinkProps);
8
+ const { resolvedTarget, resolvedIcon } = useShellNavigationLink(props);
105
9
  </script>
106
10
 
107
11
  <template>
@@ -0,0 +1,85 @@
1
+ import { computed } from "vue";
2
+ import { useRoute } from "vue-router";
3
+ import {
4
+ resolveSurfaceIdFromPlacementPathname,
5
+ useWebPlacementContext
6
+ } from "../placement/index.js";
7
+ import { resolveMenuLinkIcon } from "../lib/menuIcons.js";
8
+ import { resolveMenuLinkHref, resolveMenuLinkTarget } from "../support/menuLinkTarget.js";
9
+
10
+ const shellNavigationLinkProps = Object.freeze({
11
+ label: {
12
+ type: String,
13
+ default: ""
14
+ },
15
+ to: {
16
+ type: String,
17
+ default: ""
18
+ },
19
+ icon: {
20
+ type: String,
21
+ default: ""
22
+ },
23
+ surface: {
24
+ type: String,
25
+ default: ""
26
+ },
27
+ scopedSuffix: {
28
+ type: String,
29
+ default: "/"
30
+ },
31
+ unscopedSuffix: {
32
+ type: String,
33
+ default: "/"
34
+ },
35
+ disabled: {
36
+ type: Boolean,
37
+ default: false
38
+ },
39
+ exact: {
40
+ type: Boolean,
41
+ default: false
42
+ }
43
+ });
44
+
45
+ // Surface-aware link resolution shared by the shell menu and tab link items.
46
+ function useShellNavigationLink(props) {
47
+ const route = useRoute();
48
+ const { context: placementContext } = useWebPlacementContext();
49
+
50
+ const resolvedTo = computed(() => {
51
+ const currentSurfaceId = resolveSurfaceIdFromPlacementPathname(
52
+ placementContext.value,
53
+ String(route?.path || route?.fullPath || "").trim()
54
+ );
55
+ return resolveMenuLinkTarget({
56
+ to: props.to,
57
+ surface: props.surface,
58
+ currentSurfaceId,
59
+ placementContext: placementContext.value,
60
+ scopedSuffix: props.scopedSuffix,
61
+ unscopedSuffix: props.unscopedSuffix,
62
+ routeParams: route.params || {}
63
+ });
64
+ });
65
+
66
+ const resolvedTarget = computed(() => resolveMenuLinkHref(placementContext.value, resolvedTo.value));
67
+
68
+ const resolvedIcon = computed(() =>
69
+ resolveMenuLinkIcon({
70
+ icon: props.icon,
71
+ label: props.label,
72
+ to: resolvedTarget.value.href || resolvedTo.value
73
+ })
74
+ );
75
+
76
+ return {
77
+ resolvedTarget,
78
+ resolvedIcon
79
+ };
80
+ }
81
+
82
+ export {
83
+ shellNavigationLinkProps,
84
+ useShellNavigationLink
85
+ };