@jskit-ai/shell-web 0.1.4
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/package.descriptor.mjs +165 -0
- package/package.json +23 -0
- package/src/client/components/ShellErrorHost.vue +208 -0
- package/src/client/components/ShellLayout.vue +191 -0
- package/src/client/components/ShellOutlet.vue +95 -0
- package/src/client/components/useShellLayout.js +93 -0
- package/src/client/error/index.js +2 -0
- package/src/client/error/inject.js +142 -0
- package/src/client/error/normalize.js +75 -0
- package/src/client/error/policy.js +50 -0
- package/src/client/error/presenters.js +89 -0
- package/src/client/error/runtime.js +418 -0
- package/src/client/error/store.js +176 -0
- package/src/client/error/tokens.js +14 -0
- package/src/client/index.js +17 -0
- package/src/client/navigation/linkResolver.js +117 -0
- package/src/client/placement/debug.js +52 -0
- package/src/client/placement/index.js +26 -0
- package/src/client/placement/inject.js +104 -0
- package/src/client/placement/pathname.js +14 -0
- package/src/client/placement/registry.js +41 -0
- package/src/client/placement/runtime.js +435 -0
- package/src/client/placement/surfaceContext.js +290 -0
- package/src/client/placement/tokens.js +29 -0
- package/src/client/placement/validators.js +210 -0
- package/src/client/providers/ShellWebClientProvider.js +352 -0
- package/templates/src/App.vue +11 -0
- package/templates/src/components/ShellLayout.vue +247 -0
- package/templates/src/error.js +13 -0
- package/templates/src/pages/console/index.vue +24 -0
- package/templates/src/pages/console.vue +20 -0
- package/templates/src/pages/home/index.vue +54 -0
- package/templates/src/pages/home.vue +20 -0
- package/templates/src/placement.js +12 -0
- package/test/errorRuntime.test.js +191 -0
- package/test/errorStore.test.js +26 -0
- package/test/linkResolver.test.js +112 -0
- package/test/placementRegistry.test.js +45 -0
- package/test/placementRuntime.test.js +374 -0
- package/test/provider.test.js +163 -0
- package/test/surfaceContext.test.js +184 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
export default Object.freeze({
|
|
2
|
+
packageVersion: 1,
|
|
3
|
+
packageId: "@jskit-ai/shell-web",
|
|
4
|
+
version: "0.1.4",
|
|
5
|
+
description: "Web shell layout runtime with outlet-based placement contributions.",
|
|
6
|
+
dependsOn: [],
|
|
7
|
+
capabilities: {
|
|
8
|
+
provides: [
|
|
9
|
+
"runtime.web-placement",
|
|
10
|
+
"runtime.web-error"
|
|
11
|
+
],
|
|
12
|
+
requires: []
|
|
13
|
+
},
|
|
14
|
+
runtime: {
|
|
15
|
+
server: {
|
|
16
|
+
providers: []
|
|
17
|
+
},
|
|
18
|
+
client: {
|
|
19
|
+
providers: [
|
|
20
|
+
{
|
|
21
|
+
entrypoint: "src/client/providers/ShellWebClientProvider.js",
|
|
22
|
+
export: "ShellWebClientProvider"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
metadata: {
|
|
28
|
+
apiSummary: {
|
|
29
|
+
surfaces: [
|
|
30
|
+
{
|
|
31
|
+
subpath: "./client",
|
|
32
|
+
summary: "Exports shell layout/outlet/error-host components and ShellWebClientProvider."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
subpath: "./client/placement",
|
|
36
|
+
summary: "Exports placement registry, placement context access, runtime token, and surface path helpers."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
subpath: "./client/error",
|
|
40
|
+
summary: "Exports default error policy and runtime error reporter hook."
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
containerTokens: {
|
|
44
|
+
server: [],
|
|
45
|
+
client: [
|
|
46
|
+
"runtime.web-placement.client",
|
|
47
|
+
"runtime.web-error.client",
|
|
48
|
+
"runtime.web-error.presentation-store.client"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
ui: {
|
|
53
|
+
placements: {
|
|
54
|
+
outlets: [
|
|
55
|
+
{
|
|
56
|
+
host: "shell-layout",
|
|
57
|
+
position: "top-left",
|
|
58
|
+
surfaces: ["*"],
|
|
59
|
+
source: "src/client/components/ShellLayout.vue"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
host: "shell-layout",
|
|
63
|
+
position: "top-right",
|
|
64
|
+
surfaces: ["*"],
|
|
65
|
+
source: "src/client/components/ShellLayout.vue"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
host: "shell-layout",
|
|
69
|
+
position: "primary-menu",
|
|
70
|
+
surfaces: ["*"],
|
|
71
|
+
source: "src/client/components/ShellLayout.vue"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
host: "shell-layout",
|
|
75
|
+
position: "secondary-menu",
|
|
76
|
+
surfaces: ["*"],
|
|
77
|
+
source: "src/client/components/ShellLayout.vue"
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
contributions: []
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
mutations: {
|
|
85
|
+
dependencies: {
|
|
86
|
+
runtime: {
|
|
87
|
+
"@tanstack/vue-query": "^5.90.5",
|
|
88
|
+
"@jskit-ai/kernel": "0.1.4",
|
|
89
|
+
"vuetify": "^4.0.0"
|
|
90
|
+
},
|
|
91
|
+
dev: {}
|
|
92
|
+
},
|
|
93
|
+
packageJson: {
|
|
94
|
+
scripts: {
|
|
95
|
+
"dev:all": "vite",
|
|
96
|
+
"dev:home": "VITE_SURFACE=home vite",
|
|
97
|
+
"dev:console": "VITE_SURFACE=console vite"
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
procfile: {},
|
|
101
|
+
text: [],
|
|
102
|
+
files: [
|
|
103
|
+
{
|
|
104
|
+
from: "templates/src/App.vue",
|
|
105
|
+
to: "src/App.vue",
|
|
106
|
+
reason: "Install full-width shell app root with shell-web error host and edge-to-edge layout.",
|
|
107
|
+
category: "shell-web",
|
|
108
|
+
id: "shell-web-app-root"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
from: "templates/src/components/ShellLayout.vue",
|
|
112
|
+
to: "src/components/ShellLayout.vue",
|
|
113
|
+
reason: "Install app-owned shell layout component so apps can customize structure and slots.",
|
|
114
|
+
category: "shell-web",
|
|
115
|
+
id: "shell-web-component-shell-layout"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
from: "templates/src/error.js",
|
|
119
|
+
to: "src/error.js",
|
|
120
|
+
reason: "Install app-owned error runtime policy and presenter config scaffold.",
|
|
121
|
+
category: "shell-web",
|
|
122
|
+
id: "shell-web-error-config"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
from: "templates/src/placement.js",
|
|
126
|
+
to: "src/placement.js",
|
|
127
|
+
reason: "Install app-owned placement registry scaffold used by shell-web placement runtime.",
|
|
128
|
+
category: "shell-web",
|
|
129
|
+
id: "shell-web-placement-registry"
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
from: "templates/src/pages/home.vue",
|
|
133
|
+
toSurface: "home",
|
|
134
|
+
toSurfaceRoot: true,
|
|
135
|
+
reason: "Install shell-driven home wrapper page.",
|
|
136
|
+
category: "shell-web",
|
|
137
|
+
id: "shell-web-page-home-wrapper"
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
from: "templates/src/pages/home/index.vue",
|
|
141
|
+
toSurface: "home",
|
|
142
|
+
toSurfacePath: "index.vue",
|
|
143
|
+
reason: "Install shell-driven home surface starter page.",
|
|
144
|
+
category: "shell-web",
|
|
145
|
+
id: "shell-web-page-home"
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
from: "templates/src/pages/console.vue",
|
|
149
|
+
toSurface: "console",
|
|
150
|
+
toSurfaceRoot: true,
|
|
151
|
+
reason: "Install shell-driven console wrapper page.",
|
|
152
|
+
category: "shell-web",
|
|
153
|
+
id: "shell-web-page-console-wrapper"
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
from: "templates/src/pages/console/index.vue",
|
|
157
|
+
toSurface: "console",
|
|
158
|
+
toSurfacePath: "index.vue",
|
|
159
|
+
reason: "Install shell-driven console page starter.",
|
|
160
|
+
category: "shell-web",
|
|
161
|
+
id: "shell-web-page-console"
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jskit-ai/shell-web",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
"./client": "./src/client/index.js",
|
|
10
|
+
"./client/error": "./src/client/error/index.js",
|
|
11
|
+
"./client/placement": "./src/client/placement/index.js",
|
|
12
|
+
"./client/navigation/linkResolver": "./src/client/navigation/linkResolver.js",
|
|
13
|
+
"./client/components/ShellLayout": "./src/client/components/ShellLayout.vue",
|
|
14
|
+
"./client/components/ShellOutlet": "./src/client/components/ShellOutlet.vue",
|
|
15
|
+
"./client/components/ShellErrorHost": "./src/client/components/ShellErrorHost.vue",
|
|
16
|
+
"./client/providers/ShellWebClientProvider": "./src/client/providers/ShellWebClientProvider.js"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@tanstack/vue-query": "^5.90.5",
|
|
20
|
+
"@jskit-ai/kernel": "0.1.4",
|
|
21
|
+
"vuetify": "^4.0.0"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import {
|
|
4
|
+
useShellWebErrorPresentationState,
|
|
5
|
+
useShellWebErrorRuntime
|
|
6
|
+
} from "../error/inject.js";
|
|
7
|
+
|
|
8
|
+
const runtime = useShellWebErrorRuntime();
|
|
9
|
+
const {
|
|
10
|
+
state,
|
|
11
|
+
store
|
|
12
|
+
} = useShellWebErrorPresentationState();
|
|
13
|
+
|
|
14
|
+
const snackbarEntry = computed(() => state.value.channels.snackbar[0] || null);
|
|
15
|
+
const bannerEntries = computed(() => state.value.channels.banner || []);
|
|
16
|
+
const dialogEntry = computed(() => state.value.channels.dialog[0] || null);
|
|
17
|
+
|
|
18
|
+
function resolveSeverityColor(severity = "error") {
|
|
19
|
+
const normalized = String(severity || "error").trim().toLowerCase();
|
|
20
|
+
if (normalized === "info") {
|
|
21
|
+
return "info";
|
|
22
|
+
}
|
|
23
|
+
if (normalized === "success") {
|
|
24
|
+
return "success";
|
|
25
|
+
}
|
|
26
|
+
if (normalized === "warning") {
|
|
27
|
+
return "warning";
|
|
28
|
+
}
|
|
29
|
+
return "error";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveTimeout(entry) {
|
|
33
|
+
if (!entry) {
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
36
|
+
if (entry.persist) {
|
|
37
|
+
return -1;
|
|
38
|
+
}
|
|
39
|
+
return 5000;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function dismiss(entry) {
|
|
43
|
+
if (!entry || !entry.channel || !entry.id) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
store.dismiss(entry.channel, entry.id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runAction(entry) {
|
|
51
|
+
if (!entry || !entry.action || typeof entry.action.handler !== "function") {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
entry.action.handler(entry);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
runtime.report({
|
|
59
|
+
source: "shell-web.error-host.action",
|
|
60
|
+
message: "Error action failed.",
|
|
61
|
+
cause: error,
|
|
62
|
+
severity: "error",
|
|
63
|
+
channel: "dialog"
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (entry.action.dismissOnRun !== false) {
|
|
68
|
+
dismiss(entry);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onSnackbarModelValue(nextValue) {
|
|
73
|
+
if (nextValue === false && snackbarEntry.value) {
|
|
74
|
+
dismiss(snackbarEntry.value);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function onDialogModelValue(nextValue) {
|
|
79
|
+
if (nextValue === false && dialogEntry.value && dialogEntry.value.persist !== true) {
|
|
80
|
+
dismiss(dialogEntry.value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<template>
|
|
86
|
+
<div class="shell-error-host" aria-live="polite">
|
|
87
|
+
<div v-if="bannerEntries.length > 0" class="shell-error-host__banners">
|
|
88
|
+
<div class="shell-error-host__banner-stack">
|
|
89
|
+
<v-alert
|
|
90
|
+
v-for="entry in bannerEntries"
|
|
91
|
+
:key="entry.id"
|
|
92
|
+
:type="resolveSeverityColor(entry.severity)"
|
|
93
|
+
variant="elevated"
|
|
94
|
+
density="comfortable"
|
|
95
|
+
rounded="lg"
|
|
96
|
+
border="start"
|
|
97
|
+
class="shell-error-host__banner"
|
|
98
|
+
closable
|
|
99
|
+
@click:close="dismiss(entry)"
|
|
100
|
+
>
|
|
101
|
+
<div class="d-flex align-center ga-3 flex-wrap">
|
|
102
|
+
<span>{{ entry.message }}</span>
|
|
103
|
+
<v-spacer />
|
|
104
|
+
<v-btn
|
|
105
|
+
v-if="entry.action"
|
|
106
|
+
variant="text"
|
|
107
|
+
size="small"
|
|
108
|
+
class="text-none"
|
|
109
|
+
@click="runAction(entry)"
|
|
110
|
+
>
|
|
111
|
+
{{ entry.action.label }}
|
|
112
|
+
</v-btn>
|
|
113
|
+
</div>
|
|
114
|
+
</v-alert>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<v-snackbar
|
|
119
|
+
:model-value="Boolean(snackbarEntry)"
|
|
120
|
+
location="bottom end"
|
|
121
|
+
:timeout="resolveTimeout(snackbarEntry)"
|
|
122
|
+
:color="resolveSeverityColor(snackbarEntry?.severity)"
|
|
123
|
+
@update:model-value="onSnackbarModelValue"
|
|
124
|
+
>
|
|
125
|
+
<span v-if="snackbarEntry">{{ snackbarEntry.message }}</span>
|
|
126
|
+
|
|
127
|
+
<template #actions>
|
|
128
|
+
<v-btn
|
|
129
|
+
v-if="snackbarEntry?.action"
|
|
130
|
+
variant="text"
|
|
131
|
+
size="small"
|
|
132
|
+
@click="runAction(snackbarEntry)"
|
|
133
|
+
>
|
|
134
|
+
{{ snackbarEntry.action.label }}
|
|
135
|
+
</v-btn>
|
|
136
|
+
<v-btn
|
|
137
|
+
v-if="snackbarEntry"
|
|
138
|
+
variant="text"
|
|
139
|
+
size="small"
|
|
140
|
+
@click="dismiss(snackbarEntry)"
|
|
141
|
+
>
|
|
142
|
+
Dismiss
|
|
143
|
+
</v-btn>
|
|
144
|
+
</template>
|
|
145
|
+
</v-snackbar>
|
|
146
|
+
|
|
147
|
+
<v-dialog
|
|
148
|
+
:model-value="Boolean(dialogEntry)"
|
|
149
|
+
max-width="560"
|
|
150
|
+
:persistent="Boolean(dialogEntry?.persist)"
|
|
151
|
+
@update:model-value="onDialogModelValue"
|
|
152
|
+
>
|
|
153
|
+
<v-card v-if="dialogEntry">
|
|
154
|
+
<v-card-title class="text-subtitle-1">Attention required</v-card-title>
|
|
155
|
+
<v-card-text>{{ dialogEntry.message }}</v-card-text>
|
|
156
|
+
<v-card-actions>
|
|
157
|
+
<v-spacer />
|
|
158
|
+
<v-btn
|
|
159
|
+
v-if="dialogEntry.action"
|
|
160
|
+
variant="text"
|
|
161
|
+
@click="runAction(dialogEntry)"
|
|
162
|
+
>
|
|
163
|
+
{{ dialogEntry.action.label }}
|
|
164
|
+
</v-btn>
|
|
165
|
+
<v-btn
|
|
166
|
+
color="primary"
|
|
167
|
+
variant="tonal"
|
|
168
|
+
@click="dismiss(dialogEntry)"
|
|
169
|
+
>
|
|
170
|
+
Close
|
|
171
|
+
</v-btn>
|
|
172
|
+
</v-card-actions>
|
|
173
|
+
</v-card>
|
|
174
|
+
</v-dialog>
|
|
175
|
+
</div>
|
|
176
|
+
</template>
|
|
177
|
+
|
|
178
|
+
<style scoped>
|
|
179
|
+
.shell-error-host__banners {
|
|
180
|
+
position: fixed;
|
|
181
|
+
top: calc(env(safe-area-inset-top, 0px) + var(--shell-error-banner-offset, 64px));
|
|
182
|
+
left: 0;
|
|
183
|
+
right: 0;
|
|
184
|
+
z-index: 2600;
|
|
185
|
+
pointer-events: none;
|
|
186
|
+
padding: 10px 12px;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.shell-error-host__banner-stack {
|
|
190
|
+
margin: 0 auto;
|
|
191
|
+
width: min(1120px, 100%);
|
|
192
|
+
display: flex;
|
|
193
|
+
flex-direction: column;
|
|
194
|
+
gap: 8px;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.shell-error-host__banner {
|
|
198
|
+
pointer-events: auto;
|
|
199
|
+
box-shadow: var(--v-shadow-4);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
@media (max-width: 600px) {
|
|
203
|
+
.shell-error-host__banners {
|
|
204
|
+
top: calc(env(safe-area-inset-top, 0px) + var(--shell-error-banner-offset-mobile, 56px));
|
|
205
|
+
padding: 8px;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
</style>
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { useRoute } from "vue-router";
|
|
4
|
+
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface";
|
|
5
|
+
import { useWebPlacementContext } from "../placement/inject.js";
|
|
6
|
+
import {
|
|
7
|
+
readPlacementSurfaceConfig,
|
|
8
|
+
resolveSurfaceDefinitionFromPlacementContext,
|
|
9
|
+
resolveSurfaceIdFromPlacementPathname
|
|
10
|
+
} from "../placement/surfaceContext.js";
|
|
11
|
+
import { useShellLayout } from "./useShellLayout.js";
|
|
12
|
+
import ShellOutlet from "./ShellOutlet.vue";
|
|
13
|
+
|
|
14
|
+
const props = defineProps({
|
|
15
|
+
surface: {
|
|
16
|
+
type: String,
|
|
17
|
+
default: ""
|
|
18
|
+
},
|
|
19
|
+
surfaceLabel: {
|
|
20
|
+
type: String,
|
|
21
|
+
default: ""
|
|
22
|
+
},
|
|
23
|
+
title: {
|
|
24
|
+
type: String,
|
|
25
|
+
default: ""
|
|
26
|
+
},
|
|
27
|
+
subtitle: {
|
|
28
|
+
type: String,
|
|
29
|
+
default: ""
|
|
30
|
+
},
|
|
31
|
+
topLeftActions: {
|
|
32
|
+
type: Array,
|
|
33
|
+
default: () => []
|
|
34
|
+
},
|
|
35
|
+
topRightActions: {
|
|
36
|
+
type: Array,
|
|
37
|
+
default: () => []
|
|
38
|
+
},
|
|
39
|
+
menuItems: {
|
|
40
|
+
type: Array,
|
|
41
|
+
default: () => []
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let route = null;
|
|
46
|
+
try {
|
|
47
|
+
route = useRoute();
|
|
48
|
+
} catch {
|
|
49
|
+
route = null;
|
|
50
|
+
}
|
|
51
|
+
const { context: placementContext } = useWebPlacementContext();
|
|
52
|
+
|
|
53
|
+
function toSurfaceLabel(surfaceId = "") {
|
|
54
|
+
const normalizedSurfaceId = String(surfaceId || "").trim().toLowerCase();
|
|
55
|
+
if (!normalizedSurfaceId) {
|
|
56
|
+
return "Surface";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return normalizedSurfaceId
|
|
60
|
+
.split(/[^a-z0-9]+/g)
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.map((segment) => `${segment.slice(0, 1).toUpperCase()}${segment.slice(1)}`)
|
|
63
|
+
.join(" ");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const resolvedSurface = computed(() => {
|
|
67
|
+
const explicitSurface = normalizeSurfaceId(props.surface);
|
|
68
|
+
if (explicitSurface) {
|
|
69
|
+
return explicitSurface;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const pathname =
|
|
73
|
+
String(route?.path || "").trim() ||
|
|
74
|
+
(typeof window === "object" && window?.location?.pathname ? String(window.location.pathname).trim() : "/");
|
|
75
|
+
const contextValue = placementContext?.value || null;
|
|
76
|
+
const resolvedSurfaceFromPath = resolveSurfaceIdFromPlacementPathname(contextValue, pathname);
|
|
77
|
+
if (resolvedSurfaceFromPath) {
|
|
78
|
+
return resolvedSurfaceFromPath;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const surfaceConfig = readPlacementSurfaceConfig(contextValue);
|
|
82
|
+
if (surfaceConfig.defaultSurfaceId) {
|
|
83
|
+
return surfaceConfig.defaultSurfaceId;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return "surface";
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const resolvedSurfaceLabel = computed(() => {
|
|
90
|
+
const explicitLabel = String(props.surfaceLabel || "").trim();
|
|
91
|
+
if (explicitLabel) {
|
|
92
|
+
return explicitLabel;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const surfaceDefinition = resolveSurfaceDefinitionFromPlacementContext(
|
|
96
|
+
placementContext?.value || null,
|
|
97
|
+
resolvedSurface.value
|
|
98
|
+
);
|
|
99
|
+
const configuredLabel = String(surfaceDefinition?.label || "").trim();
|
|
100
|
+
if (configuredLabel) {
|
|
101
|
+
return configuredLabel;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return toSurfaceLabel(resolvedSurface.value);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const { drawerOpen, resolvedTopLeftActions, resolvedTopRightActions, resolvedMenuItems, toggleDrawer } = useShellLayout({
|
|
108
|
+
topLeftActions: computed(() => props.topLeftActions),
|
|
109
|
+
topRightActions: computed(() => props.topRightActions),
|
|
110
|
+
menuItems: computed(() => props.menuItems)
|
|
111
|
+
});
|
|
112
|
+
</script>
|
|
113
|
+
|
|
114
|
+
<template>
|
|
115
|
+
<v-layout class="shell-layout border rounded-lg overflow-hidden">
|
|
116
|
+
<v-app-bar border density="comfortable" elevation="0" class="bg-surface">
|
|
117
|
+
<v-app-bar-nav-icon aria-label="Toggle navigation menu" @click="toggleDrawer" />
|
|
118
|
+
|
|
119
|
+
<slot name="top-left" :actions="resolvedTopLeftActions" :surface="resolvedSurface">
|
|
120
|
+
<div class="d-flex align-center ga-2">
|
|
121
|
+
<v-btn
|
|
122
|
+
v-for="action in resolvedTopLeftActions"
|
|
123
|
+
:key="`top-left-${action.label}`"
|
|
124
|
+
:to="action.to || undefined"
|
|
125
|
+
:variant="action.variant"
|
|
126
|
+
:color="action.color"
|
|
127
|
+
size="small"
|
|
128
|
+
class="text-none"
|
|
129
|
+
>
|
|
130
|
+
{{ action.label }}
|
|
131
|
+
</v-btn>
|
|
132
|
+
<v-chip color="primary" size="small" label>{{ resolvedSurfaceLabel }}</v-chip>
|
|
133
|
+
<ShellOutlet host="shell-layout" position="top-left" />
|
|
134
|
+
</div>
|
|
135
|
+
</slot>
|
|
136
|
+
|
|
137
|
+
<v-spacer />
|
|
138
|
+
|
|
139
|
+
<slot name="top-right" :actions="resolvedTopRightActions" :surface="resolvedSurface">
|
|
140
|
+
<div class="d-flex align-center ga-2">
|
|
141
|
+
<v-btn
|
|
142
|
+
v-for="action in resolvedTopRightActions"
|
|
143
|
+
:key="`top-right-${action.label}`"
|
|
144
|
+
:to="action.to || undefined"
|
|
145
|
+
:variant="action.variant"
|
|
146
|
+
:color="action.color"
|
|
147
|
+
size="small"
|
|
148
|
+
class="text-none"
|
|
149
|
+
>
|
|
150
|
+
{{ action.label }}
|
|
151
|
+
</v-btn>
|
|
152
|
+
<ShellOutlet host="shell-layout" position="top-right" />
|
|
153
|
+
</div>
|
|
154
|
+
</slot>
|
|
155
|
+
</v-app-bar>
|
|
156
|
+
|
|
157
|
+
<v-navigation-drawer v-model="drawerOpen" border class="bg-surface" :width="248">
|
|
158
|
+
<slot name="menu" :items="resolvedMenuItems" :surface="resolvedSurface">
|
|
159
|
+
<v-list nav density="comfortable" class="pt-2">
|
|
160
|
+
<v-list-subheader class="text-uppercase text-caption">{{ resolvedSurfaceLabel }}</v-list-subheader>
|
|
161
|
+
<v-list-item
|
|
162
|
+
v-for="item in resolvedMenuItems"
|
|
163
|
+
:key="`menu-${item.label}`"
|
|
164
|
+
:title="item.label"
|
|
165
|
+
:to="item.to"
|
|
166
|
+
:prepend-icon="item.icon"
|
|
167
|
+
rounded="lg"
|
|
168
|
+
class="mb-1"
|
|
169
|
+
/>
|
|
170
|
+
<ShellOutlet host="shell-layout" position="primary-menu" />
|
|
171
|
+
<v-divider class="my-2" />
|
|
172
|
+
<ShellOutlet host="shell-layout" position="secondary-menu" />
|
|
173
|
+
</v-list>
|
|
174
|
+
</slot>
|
|
175
|
+
</v-navigation-drawer>
|
|
176
|
+
|
|
177
|
+
<v-main class="bg-background">
|
|
178
|
+
<v-container fluid class="pa-4">
|
|
179
|
+
<h1 class="text-h5 mb-2">{{ title }}</h1>
|
|
180
|
+
<p class="text-body-2 text-medium-emphasis mb-4">{{ subtitle }}</p>
|
|
181
|
+
<slot />
|
|
182
|
+
</v-container>
|
|
183
|
+
</v-main>
|
|
184
|
+
</v-layout>
|
|
185
|
+
</template>
|
|
186
|
+
|
|
187
|
+
<style scoped>
|
|
188
|
+
.shell-layout {
|
|
189
|
+
min-height: 72vh;
|
|
190
|
+
}
|
|
191
|
+
</style>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import {
|
|
3
|
+
computed,
|
|
4
|
+
onBeforeUnmount,
|
|
5
|
+
onMounted,
|
|
6
|
+
ref
|
|
7
|
+
} from "vue";
|
|
8
|
+
import { useRoute } from "vue-router";
|
|
9
|
+
import { useWebPlacementContext, useWebPlacementRuntime } from "../placement/inject.js";
|
|
10
|
+
import { resolveRuntimePathname } from "../placement/pathname.js";
|
|
11
|
+
import {
|
|
12
|
+
readPlacementSurfaceConfig,
|
|
13
|
+
resolveSurfaceIdFromPlacementPathname
|
|
14
|
+
} from "../placement/surfaceContext.js";
|
|
15
|
+
|
|
16
|
+
const props = defineProps({
|
|
17
|
+
host: {
|
|
18
|
+
type: String,
|
|
19
|
+
default: ""
|
|
20
|
+
},
|
|
21
|
+
position: {
|
|
22
|
+
type: String,
|
|
23
|
+
default: ""
|
|
24
|
+
},
|
|
25
|
+
context: {
|
|
26
|
+
type: Object,
|
|
27
|
+
default: () => ({})
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let route = null;
|
|
32
|
+
try {
|
|
33
|
+
route = useRoute();
|
|
34
|
+
} catch {
|
|
35
|
+
route = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const placementRuntime = useWebPlacementRuntime();
|
|
39
|
+
const { context: placementContext } = useWebPlacementContext();
|
|
40
|
+
const revision = ref(
|
|
41
|
+
typeof placementRuntime.getRevision === "function" ? placementRuntime.getRevision() : 0
|
|
42
|
+
);
|
|
43
|
+
let unsubscribe = null;
|
|
44
|
+
|
|
45
|
+
onMounted(() => {
|
|
46
|
+
if (typeof placementRuntime.subscribe !== "function") {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
unsubscribe = placementRuntime.subscribe((event) => {
|
|
50
|
+
const next = Number(event?.revision);
|
|
51
|
+
revision.value = Number.isInteger(next) ? next : revision.value + 1;
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
onBeforeUnmount(() => {
|
|
56
|
+
if (typeof unsubscribe === "function") {
|
|
57
|
+
unsubscribe();
|
|
58
|
+
unsubscribe = null;
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const resolvedSurface = computed(() => {
|
|
63
|
+
const contextValue = placementContext?.value || null;
|
|
64
|
+
const pathname = resolveRuntimePathname(route?.path);
|
|
65
|
+
const surfaceFromPathname = resolveSurfaceIdFromPlacementPathname(contextValue, pathname);
|
|
66
|
+
if (surfaceFromPathname) {
|
|
67
|
+
return surfaceFromPathname;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const surfaceConfig = readPlacementSurfaceConfig(contextValue);
|
|
71
|
+
if (surfaceConfig.defaultSurfaceId) {
|
|
72
|
+
return surfaceConfig.defaultSurfaceId;
|
|
73
|
+
}
|
|
74
|
+
return "*";
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const placements = computed(() => {
|
|
78
|
+
void revision.value;
|
|
79
|
+
return placementRuntime.getPlacements({
|
|
80
|
+
surface: resolvedSurface.value,
|
|
81
|
+
host: props.host,
|
|
82
|
+
position: props.position,
|
|
83
|
+
context: props.context
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<template>
|
|
89
|
+
<component
|
|
90
|
+
:is="entry.component"
|
|
91
|
+
v-for="entry in placements"
|
|
92
|
+
:key="entry.id"
|
|
93
|
+
v-bind="entry.props"
|
|
94
|
+
/>
|
|
95
|
+
</template>
|