@jskit-ai/shell-web 0.1.64 → 0.1.66

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 (37) hide show
  1. package/package.descriptor.mjs +200 -16
  2. package/package.json +8 -7
  3. package/src/client/components/ShellErrorHost.vue +88 -15
  4. package/src/client/components/ShellLayout.vue +551 -50
  5. package/src/client/components/ShellOutlet.vue +34 -4
  6. package/src/client/components/ShellOutletMenuWidget.vue +1 -8
  7. package/src/client/components/ShellRouteTransition.vue +480 -0
  8. package/src/client/components/ShellTabLinkItem.vue +22 -6
  9. package/src/client/composables/useShellLayoutState.js +12 -1
  10. package/src/client/error/normalize.js +17 -0
  11. package/src/client/error/policy.js +25 -11
  12. package/src/client/error/runtime.js +2 -0
  13. package/src/client/index.js +1 -0
  14. package/src/client/placement/index.js +5 -0
  15. package/src/client/placement/runtime.js +149 -16
  16. package/src/client/placement/validators.js +36 -8
  17. package/src/client/providers/ShellWebClientProvider.js +189 -24
  18. package/src/client/stores/useShellLayoutStore.js +21 -1
  19. package/src/test/adaptiveShellSmoke.js +121 -0
  20. package/templates/expected-existing/src/pages/home/index.vue +40 -10
  21. package/templates/src/components/ShellLayout.vue +10 -90
  22. package/templates/src/components/menus/TabLinkItem.vue +4 -0
  23. package/templates/src/error.js +7 -1
  24. package/templates/src/pages/home/index.vue +64 -23
  25. package/templates/src/pages/home/settings/general/index.vue +12 -9
  26. package/templates/src/pages/home/settings.vue +68 -24
  27. package/templates/src/placement.js +7 -6
  28. package/templates/src/placementTopology.js +149 -0
  29. package/templates/tests/e2e/adaptive-shell.spec.ts +4 -0
  30. package/test/errorRuntime.test.js +42 -0
  31. package/test/linkItemScaffoldContract.test.js +9 -2
  32. package/test/outletMenuWidgetContract.test.js +2 -2
  33. package/test/placementRegistry.test.js +3 -3
  34. package/test/placementRuntime.test.js +144 -14
  35. package/test/provider.test.js +97 -5
  36. package/test/settingsPlacementContract.test.js +234 -20
  37. package/test/useShellLayoutState.test.js +19 -0
@@ -1,6 +1,16 @@
1
1
  <script setup>
2
+ import {
3
+ computed,
4
+ inject,
5
+ onBeforeUnmount,
6
+ onMounted,
7
+ ref,
8
+ watch
9
+ } from "vue";
10
+ import { useDisplay } from "vuetify";
2
11
  import { useShellLayoutState } from "../composables/useShellLayoutState.js";
3
12
  import ShellOutlet from "./ShellOutlet.vue";
13
+ import ShellRouteTransition from "./ShellRouteTransition.vue";
4
14
 
5
15
  const props = defineProps({
6
16
  surface: {
@@ -21,65 +31,481 @@ const props = defineProps({
21
31
  }
22
32
  });
23
33
 
24
- const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useShellLayoutState(props);
34
+ const {
35
+ drawerDefaultOpen,
36
+ drawerOpen,
37
+ setDrawerOpen,
38
+ supportingContentOpen,
39
+ supportingContentTitle,
40
+ setSupportingContentOpen,
41
+ closeSupportingContent,
42
+ toggleDrawer,
43
+ resolvedSurface,
44
+ resolvedSurfaceLabel
45
+ } = useShellLayoutState(props);
46
+ const display = useDisplay();
47
+ const refreshRuntime = inject("jskit.shell-web.runtime.web-refresh.client", null);
48
+ const pullDistance = ref(0);
49
+ const pullRefreshing = ref(false);
50
+ let activePull = null;
51
+
52
+ const PULL_REFRESH_TRIGGER_DISTANCE = 72;
53
+ const PULL_REFRESH_MAX_DISTANCE = 112;
54
+
55
+ const layoutClass = computed(() => {
56
+ const displayName = String(display?.name?.value || "").trim().toLowerCase();
57
+ if (displayName === "xs" || displayName === "sm") {
58
+ return "compact";
59
+ }
60
+ if (displayName === "md") {
61
+ return "medium";
62
+ }
63
+ return "expanded";
64
+ });
65
+ const isCompactLayout = computed(() => layoutClass.value === "compact");
66
+ const pullProgress = computed(() =>
67
+ Math.min(100, Math.round((pullDistance.value / PULL_REFRESH_TRIGGER_DISTANCE) * 100))
68
+ );
69
+ const pullIndicatorVisible = computed(() =>
70
+ Boolean(isCompactLayout.value && (pullDistance.value > 0 || pullRefreshing.value))
71
+ );
72
+ const pullRefreshLabel = computed(() => {
73
+ if (pullRefreshing.value) {
74
+ return "Refreshing";
75
+ }
76
+ return pullProgress.value >= 100 ? "Release to refresh" : "Pull to refresh";
77
+ });
78
+ const pullRefreshStyle = computed(() => ({
79
+ "--shell-pull-refresh-distance": `${Math.round(Math.min(pullDistance.value, PULL_REFRESH_MAX_DISTANCE))}px`
80
+ }));
81
+
82
+ watch(
83
+ isCompactLayout,
84
+ (compact) => {
85
+ setDrawerOpen(compact ? false : drawerDefaultOpen.value);
86
+ },
87
+ { immediate: true }
88
+ );
89
+
90
+ onMounted(() => {
91
+ if (typeof window !== "object") {
92
+ return;
93
+ }
94
+
95
+ window.addEventListener("pointerdown", handlePullPointerDown, { capture: true, passive: true });
96
+ window.addEventListener("pointermove", handlePullPointerMove, { capture: true, passive: false });
97
+ window.addEventListener("pointerup", handlePullPointerEnd, { capture: true, passive: true });
98
+ window.addEventListener("pointercancel", handlePullPointerCancel, { capture: true, passive: true });
99
+ window.addEventListener("touchstart", handlePullTouchStart, { capture: true, passive: true });
100
+ window.addEventListener("touchmove", handlePullTouchMove, { capture: true, passive: false });
101
+ window.addEventListener("touchend", handlePullTouchEnd, { capture: true, passive: true });
102
+ window.addEventListener("touchcancel", handlePullTouchCancel, { capture: true, passive: true });
103
+ });
104
+
105
+ onBeforeUnmount(() => {
106
+ if (typeof window !== "object") {
107
+ return;
108
+ }
109
+
110
+ window.removeEventListener("pointerdown", handlePullPointerDown, { capture: true });
111
+ window.removeEventListener("pointermove", handlePullPointerMove, { capture: true });
112
+ window.removeEventListener("pointerup", handlePullPointerEnd, { capture: true });
113
+ window.removeEventListener("pointercancel", handlePullPointerCancel, { capture: true });
114
+ window.removeEventListener("touchstart", handlePullTouchStart, { capture: true });
115
+ window.removeEventListener("touchmove", handlePullTouchMove, { capture: true });
116
+ window.removeEventListener("touchend", handlePullTouchEnd, { capture: true });
117
+ window.removeEventListener("touchcancel", handlePullTouchCancel, { capture: true });
118
+ });
119
+
120
+ function handlePullPointerDown(event) {
121
+ if (!canStartPullRefresh(event)) {
122
+ activePull = null;
123
+ return;
124
+ }
125
+
126
+ activePull = {
127
+ pointerId: event.pointerId,
128
+ touchIdentifier: null,
129
+ startX: event.clientX,
130
+ startY: event.clientY,
131
+ pointerCancelled: false
132
+ };
133
+ }
134
+
135
+ function handlePullPointerMove(event) {
136
+ if (!activePull || event.pointerId !== activePull.pointerId) {
137
+ return;
138
+ }
139
+
140
+ updatePullGesture(event.clientX, event.clientY, event);
141
+ }
142
+
143
+ function handlePullPointerEnd(event) {
144
+ if (!activePull || event.pointerId !== activePull.pointerId) {
145
+ return;
146
+ }
147
+
148
+ finishPullGesture();
149
+ }
150
+
151
+ function handlePullPointerCancel(event) {
152
+ if (!activePull || event.pointerId !== activePull.pointerId) {
153
+ return;
154
+ }
155
+
156
+ activePull.pointerId = null;
157
+ activePull.pointerCancelled = true;
158
+ }
159
+
160
+ function handlePullTouchStart(event) {
161
+ if (activePull || !canStartTouchPullRefresh(event)) {
162
+ return;
163
+ }
164
+
165
+ const touch = event.touches?.[0] || null;
166
+ if (!touch) {
167
+ return;
168
+ }
169
+
170
+ activePull = {
171
+ pointerId: null,
172
+ touchIdentifier: touch.identifier,
173
+ startX: touch.clientX,
174
+ startY: touch.clientY,
175
+ pointerCancelled: false
176
+ };
177
+ }
178
+
179
+ function handlePullTouchMove(event) {
180
+ const touch = findActiveTouch(event.touches);
181
+ if (!activePull || !touch) {
182
+ return;
183
+ }
184
+
185
+ updatePullGesture(touch.clientX, touch.clientY, event);
186
+ }
187
+
188
+ function handlePullTouchEnd(event) {
189
+ if (!activePull || !touchListIncludesActiveTouch(event.changedTouches)) {
190
+ return;
191
+ }
192
+
193
+ finishPullGesture();
194
+ }
195
+
196
+ function handlePullTouchCancel(event) {
197
+ if (activePull && touchListIncludesActiveTouch(event.changedTouches)) {
198
+ cancelPullRefresh();
199
+ }
200
+ }
201
+
202
+ function updatePullGesture(clientX, clientY, event) {
203
+ if (!activePull) {
204
+ return;
205
+ }
206
+
207
+ const deltaX = clientX - activePull.startX;
208
+ const deltaY = clientY - activePull.startY;
209
+ const absX = Math.abs(deltaX);
210
+
211
+ if (deltaY < -4 || (absX > 24 && absX > deltaY * 1.15)) {
212
+ cancelPullRefresh();
213
+ return;
214
+ }
215
+
216
+ if (deltaY <= 6 || !isAtPageTop()) {
217
+ return;
218
+ }
219
+
220
+ if (event?.cancelable) {
221
+ event.preventDefault();
222
+ }
223
+ pullDistance.value = Math.min(PULL_REFRESH_MAX_DISTANCE, Math.round(deltaY * 0.55));
224
+ }
225
+
226
+ function finishPullGesture() {
227
+ const shouldRefresh = pullDistance.value >= PULL_REFRESH_TRIGGER_DISTANCE;
228
+ activePull = null;
229
+
230
+ if (!shouldRefresh) {
231
+ pullDistance.value = 0;
232
+ return;
233
+ }
234
+
235
+ void refreshFromPullGesture();
236
+ }
237
+
238
+ function cancelPullRefresh() {
239
+ activePull = null;
240
+ if (!pullRefreshing.value) {
241
+ pullDistance.value = 0;
242
+ }
243
+ }
244
+
245
+ async function refreshFromPullGesture() {
246
+ if (!refreshRuntime || typeof refreshRuntime.refresh !== "function" || pullRefreshing.value) {
247
+ pullDistance.value = 0;
248
+ return;
249
+ }
250
+
251
+ pullRefreshing.value = true;
252
+ pullDistance.value = PULL_REFRESH_TRIGGER_DISTANCE;
253
+ try {
254
+ await refreshRuntime.refresh("pull-to-refresh");
255
+ } finally {
256
+ pullRefreshing.value = false;
257
+ pullDistance.value = 0;
258
+ }
259
+ }
260
+
261
+ function canStartPullRefresh(event) {
262
+ return Boolean(
263
+ canStartPullRefreshFromTarget(event.target) &&
264
+ isPrimaryTouchPointer(event)
265
+ );
266
+ }
267
+
268
+ function canStartTouchPullRefresh(event) {
269
+ return Boolean(
270
+ event?.touches?.length === 1 &&
271
+ canStartPullRefreshFromTarget(event.target)
272
+ );
273
+ }
274
+
275
+ function canStartPullRefreshFromTarget(target) {
276
+ return Boolean(
277
+ isCompactLayout.value &&
278
+ refreshRuntime &&
279
+ typeof refreshRuntime.refresh === "function" &&
280
+ !pullRefreshing.value &&
281
+ isAtPageTop() &&
282
+ !isPullRefreshIgnoredTarget(target)
283
+ );
284
+ }
285
+
286
+ function isPrimaryTouchPointer(event) {
287
+ return event?.isPrimary !== false && event?.button === 0 && event?.pointerType !== "mouse";
288
+ }
289
+
290
+ function isAtPageTop() {
291
+ if (typeof window !== "object" || typeof document !== "object") {
292
+ return false;
293
+ }
294
+
295
+ const documentScrollTop = Number(document.documentElement?.scrollTop || 0);
296
+ const bodyScrollTop = Number(document.body?.scrollTop || 0);
297
+ return Math.max(Number(window.scrollY || 0), documentScrollTop, bodyScrollTop) <= 0;
298
+ }
299
+
300
+ function isPullRefreshIgnoredTarget(target) {
301
+ return Boolean(
302
+ target?.closest?.(
303
+ [
304
+ "a",
305
+ "button",
306
+ "input",
307
+ "select",
308
+ "textarea",
309
+ "summary",
310
+ "[role='button']",
311
+ "[role='link']",
312
+ "[role='slider']",
313
+ "[contenteditable='true']",
314
+ "[data-shell-pull-refresh-ignore]",
315
+ "[data-shell-swipe-ignore]"
316
+ ].join(",")
317
+ )
318
+ );
319
+ }
320
+
321
+ function findActiveTouch(touchList) {
322
+ if (!activePull || !touchList || touchList.length < 1) {
323
+ return null;
324
+ }
325
+
326
+ if (activePull.touchIdentifier === null && touchList.length === 1) {
327
+ return touchList[0];
328
+ }
329
+
330
+ for (const touch of touchList) {
331
+ if (touch.identifier === activePull.touchIdentifier) {
332
+ return touch;
333
+ }
334
+ }
335
+
336
+ return null;
337
+ }
338
+
339
+ function touchListIncludesActiveTouch(touchList) {
340
+ if (!activePull) {
341
+ return false;
342
+ }
343
+
344
+ if (activePull.touchIdentifier === null) {
345
+ return !touchList || touchList.length <= 1;
346
+ }
347
+
348
+ return Boolean(findActiveTouch(touchList));
349
+ }
25
350
  </script>
26
351
 
27
352
  <template>
28
- <v-layout class="shell-layout border rounded-lg overflow-hidden">
29
- <v-app-bar border density="comfortable" elevation="0" class="bg-surface">
30
- <v-app-bar-nav-icon aria-label="Toggle navigation menu" @click="toggleDrawer" />
31
-
32
- <slot name="top-left" :surface="resolvedSurface">
33
- <div class="d-flex align-center ga-2">
34
- <v-chip color="primary" size="small" label>{{ resolvedSurfaceLabel }}</v-chip>
35
- <ShellOutlet target="shell-layout:top-left" />
36
- </div>
37
- </slot>
38
-
39
- <v-spacer />
40
-
41
- <slot name="top-right" :surface="resolvedSurface">
42
- <div class="d-flex align-center ga-2">
43
- <ShellOutlet target="shell-layout:top-right" />
44
- </div>
45
- </slot>
46
- </v-app-bar>
47
-
48
- <v-navigation-drawer v-model="drawerOpen" border class="bg-surface" :width="248">
49
- <slot name="menu" :surface="resolvedSurface">
50
- <v-list nav density="comfortable" class="pt-2">
51
- <v-list-subheader class="text-uppercase text-caption">{{ resolvedSurfaceLabel }}</v-list-subheader>
52
- <ShellOutlet
53
- target="shell-layout:primary-menu"
54
- default
55
- default-link-component-token="local.main.ui.surface-aware-menu-link-item"
56
- />
57
- <v-divider class="my-2" />
58
- <ShellOutlet
59
- target="shell-layout:secondary-menu"
60
- default-link-component-token="local.main.ui.surface-aware-menu-link-item"
61
- />
62
- </v-list>
63
- </slot>
64
- </v-navigation-drawer>
65
-
66
- <v-main class="bg-background">
67
- <v-container fluid class="shell-layout__content">
68
- <h1 v-if="title" class="shell-layout__title text-h5">{{ title }}</h1>
69
- <p v-if="subtitle" class="shell-layout__subtitle text-body-2 text-medium-emphasis">{{ subtitle }}</p>
353
+ <v-app-bar
354
+ border
355
+ :density="isCompactLayout ? 'compact' : 'comfortable'"
356
+ elevation="0"
357
+ class="shell-layout__app-bar bg-surface"
358
+ data-testid="jskit-shell-app-bar"
359
+ >
360
+ <v-app-bar-nav-icon
361
+ class="shell-layout__nav-toggle"
362
+ aria-label="Toggle navigation menu"
363
+ @click="toggleDrawer"
364
+ />
365
+
366
+ <slot name="top-left" :surface="resolvedSurface">
367
+ <div class="shell-layout__top-left d-flex align-center ga-2">
368
+ <span class="shell-layout__surface-label">
369
+ {{ resolvedSurfaceLabel }}
370
+ </span>
371
+ <ShellOutlet target="shell-layout:top-left" />
372
+ </div>
373
+ </slot>
374
+
375
+ <v-spacer />
376
+
377
+ <slot name="top-right" :surface="resolvedSurface">
378
+ <div class="shell-layout__top-right d-flex align-center ga-2">
379
+ <ShellOutlet target="shell-layout:top-right" />
380
+ </div>
381
+ </slot>
382
+ </v-app-bar>
383
+
384
+ <div
385
+ v-if="pullIndicatorVisible"
386
+ class="shell-layout__pull-refresh"
387
+ :class="{ 'shell-layout__pull-refresh--refreshing': pullRefreshing }"
388
+ :style="pullRefreshStyle"
389
+ data-testid="jskit-shell-pull-refresh"
390
+ aria-live="polite"
391
+ >
392
+ <v-progress-circular
393
+ :model-value="pullProgress"
394
+ :indeterminate="pullRefreshing"
395
+ color="primary"
396
+ size="22"
397
+ width="3"
398
+ />
399
+ <span class="shell-layout__pull-refresh-label">{{ pullRefreshLabel }}</span>
400
+ </div>
401
+
402
+ <v-navigation-drawer
403
+ v-model="drawerOpen"
404
+ border
405
+ class="bg-surface"
406
+ data-testid="jskit-shell-drawer"
407
+ :temporary="isCompactLayout"
408
+ :permanent="!isCompactLayout"
409
+ :width="248"
410
+ >
411
+ <slot name="menu" :surface="resolvedSurface">
412
+ <v-list nav density="comfortable" class="pt-2">
413
+ <v-list-subheader class="text-uppercase text-caption">{{ resolvedSurfaceLabel }}</v-list-subheader>
414
+ <ShellOutlet
415
+ target="shell-layout:primary-menu"
416
+ default
417
+ />
418
+ <v-divider class="my-2" />
419
+ <ShellOutlet target="shell-layout:secondary-menu" />
420
+ </v-list>
421
+ </slot>
422
+ </v-navigation-drawer>
423
+
424
+ <v-main class="bg-background">
425
+ <v-container fluid class="shell-layout__content">
426
+ <h1 v-if="title" class="shell-layout__title text-h5">{{ title }}</h1>
427
+ <p v-if="subtitle" class="shell-layout__subtitle text-body-2 text-medium-emphasis">{{ subtitle }}</p>
428
+ <ShellRouteTransition>
70
429
  <slot />
71
- </v-container>
72
- </v-main>
73
- </v-layout>
430
+ </ShellRouteTransition>
431
+ </v-container>
432
+ </v-main>
433
+
434
+ <v-bottom-navigation
435
+ v-if="isCompactLayout"
436
+ class="shell-layout__bottom-nav"
437
+ data-testid="jskit-shell-bottom-nav"
438
+ bg-color="surface"
439
+ color="primary"
440
+ density="comfortable"
441
+ grow
442
+ mandatory
443
+ >
444
+ <ShellOutlet target="shell-layout:primary-bottom-nav" />
445
+ </v-bottom-navigation>
446
+
447
+ <v-bottom-sheet
448
+ v-if="isCompactLayout"
449
+ :model-value="supportingContentOpen"
450
+ @update:model-value="setSupportingContentOpen"
451
+ >
452
+ <v-card rounded="t-xl" class="shell-layout__supporting-sheet" data-testid="jskit-shell-supporting-bottom-sheet">
453
+ <v-card-title class="shell-layout__supporting-title">
454
+ <span>{{ supportingContentTitle || 'Details' }}</span>
455
+ <v-btn variant="text" @click="closeSupportingContent">Close</v-btn>
456
+ </v-card-title>
457
+ <v-card-text>
458
+ <ShellOutlet target="shell-layout:supporting-bottom-sheet" />
459
+ </v-card-text>
460
+ </v-card>
461
+ </v-bottom-sheet>
462
+
463
+ <v-navigation-drawer
464
+ v-if="!isCompactLayout"
465
+ :model-value="supportingContentOpen"
466
+ border
467
+ temporary
468
+ location="right"
469
+ :width="384"
470
+ data-testid="jskit-shell-supporting-side-panel"
471
+ @update:model-value="setSupportingContentOpen"
472
+ >
473
+ <div class="shell-layout__supporting-side-panel">
474
+ <div class="shell-layout__supporting-title">
475
+ <strong>{{ supportingContentTitle || 'Details' }}</strong>
476
+ <v-btn variant="text" @click="closeSupportingContent">Close</v-btn>
477
+ </div>
478
+ <ShellOutlet target="shell-layout:supporting-side-panel" />
479
+ </div>
480
+ </v-navigation-drawer>
74
481
  </template>
75
482
 
76
483
  <style scoped>
77
- .shell-layout {
78
- min-height: 72vh;
484
+ .shell-layout__content {
485
+ padding: 0.75rem 1rem calc(1rem + env(safe-area-inset-bottom, 0px));
79
486
  }
80
487
 
81
- .shell-layout__content {
82
- padding: 0.75rem 1rem 1rem;
488
+ .shell-layout__top-left,
489
+ .shell-layout__top-right {
490
+ min-width: 0;
491
+ }
492
+
493
+ .shell-layout__top-right {
494
+ max-width: min(45vw, 18rem);
495
+ overflow: hidden;
496
+ }
497
+
498
+ .shell-layout__surface-label {
499
+ color: rgb(var(--v-theme-on-surface));
500
+ display: block;
501
+ font-size: 0.95rem;
502
+ font-weight: 650;
503
+ letter-spacing: -0.01em;
504
+ line-height: 1.2;
505
+ max-width: 12rem;
506
+ overflow: hidden;
507
+ text-overflow: ellipsis;
508
+ white-space: nowrap;
83
509
  }
84
510
 
85
511
  .shell-layout__title {
@@ -89,4 +515,79 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
89
515
  .shell-layout__subtitle {
90
516
  margin-bottom: 0.75rem;
91
517
  }
518
+
519
+ .shell-layout__bottom-nav {
520
+ border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
521
+ padding-bottom: env(safe-area-inset-bottom, 0px);
522
+ }
523
+
524
+ .shell-layout__supporting-sheet {
525
+ max-height: min(72vh, 40rem);
526
+ overflow: auto;
527
+ }
528
+
529
+ .shell-layout__supporting-side-panel {
530
+ display: flex;
531
+ flex-direction: column;
532
+ gap: 1rem;
533
+ padding: 1rem;
534
+ }
535
+
536
+ .shell-layout__supporting-title {
537
+ align-items: center;
538
+ display: flex;
539
+ gap: 0.75rem;
540
+ justify-content: space-between;
541
+ }
542
+
543
+ .shell-layout__supporting-title :deep(.v-btn) {
544
+ min-height: 48px;
545
+ }
546
+
547
+ .shell-layout__pull-refresh {
548
+ align-items: center;
549
+ background: rgb(var(--v-theme-surface));
550
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
551
+ border-radius: 999px;
552
+ box-shadow: var(--v-shadow-3);
553
+ display: flex;
554
+ gap: 0.5rem;
555
+ left: 50%;
556
+ opacity: min(1, calc(var(--shell-pull-refresh-distance) / 56));
557
+ padding: 0.45rem 0.75rem;
558
+ pointer-events: none;
559
+ position: fixed;
560
+ top: calc(env(safe-area-inset-top, 0px) + 3.75rem);
561
+ transform: translate3d(-50%, calc((var(--shell-pull-refresh-distance) - 72px) * 0.35), 0);
562
+ transition:
563
+ opacity 160ms ease,
564
+ transform 160ms ease;
565
+ z-index: 2700;
566
+ }
567
+
568
+ .shell-layout__pull-refresh--refreshing {
569
+ opacity: 1;
570
+ }
571
+
572
+ .shell-layout__pull-refresh-label {
573
+ font-size: 0.78rem;
574
+ font-weight: 600;
575
+ white-space: nowrap;
576
+ }
577
+
578
+ @media (max-width: 640px) {
579
+ .shell-layout__content {
580
+ padding-inline:
581
+ calc(1px + env(safe-area-inset-left, 0px))
582
+ calc(1px + env(safe-area-inset-right, 0px));
583
+ }
584
+
585
+ .shell-layout__surface-label {
586
+ max-width: 8rem;
587
+ }
588
+
589
+ .shell-layout__top-right {
590
+ max-width: 40vw;
591
+ }
592
+ }
92
593
  </style>
@@ -6,6 +6,7 @@ import {
6
6
  ref
7
7
  } from "vue";
8
8
  import { useRoute } from "vue-router";
9
+ import { useDisplay } from "vuetify";
9
10
  import { useWebPlacementContext, useWebPlacementRuntime } from "../placement/inject.js";
10
11
  import { resolveRuntimePathname } from "../placement/pathname.js";
11
12
  import {
@@ -25,10 +26,6 @@ const props = defineProps({
25
26
  context: {
26
27
  type: Object,
27
28
  default: () => ({})
28
- },
29
- defaultLinkComponentToken: {
30
- type: String,
31
- default: ""
32
29
  }
33
30
  });
34
31
 
@@ -39,6 +36,13 @@ try {
39
36
  route = null;
40
37
  }
41
38
 
39
+ let display = null;
40
+ try {
41
+ display = useDisplay();
42
+ } catch {
43
+ display = null;
44
+ }
45
+
42
46
  const placementRuntime = useWebPlacementRuntime();
43
47
  const { context: placementContext } = useWebPlacementContext();
44
48
  const revision = ref(
@@ -82,11 +86,37 @@ const resolvedTargetId = computed(() => {
82
86
  return String(props.target || "").trim();
83
87
  });
84
88
 
89
+ const resolvedLayoutClass = computed(() => {
90
+ const displayName = String(display?.name?.value || "").trim().toLowerCase();
91
+ if (displayName === "xs" || displayName === "sm") {
92
+ return "compact";
93
+ }
94
+ if (displayName === "md") {
95
+ return "medium";
96
+ }
97
+ if (displayName === "lg" || displayName === "xl" || displayName === "xxl") {
98
+ return "expanded";
99
+ }
100
+
101
+ const viewportWidth =
102
+ typeof window === "object" && window?.innerWidth
103
+ ? Number(window.innerWidth)
104
+ : 0;
105
+ if (viewportWidth > 0 && viewportWidth < 600) {
106
+ return "compact";
107
+ }
108
+ if (viewportWidth > 0 && viewportWidth < 1280) {
109
+ return "medium";
110
+ }
111
+ return "expanded";
112
+ });
113
+
85
114
  const placements = computed(() => {
86
115
  void revision.value;
87
116
  return placementRuntime.getPlacements({
88
117
  surface: resolvedSurface.value,
89
118
  target: resolvedTargetId.value,
119
+ layoutClass: resolvedLayoutClass.value,
90
120
  context: props.context
91
121
  });
92
122
  });