@jskit-ai/shell-web 0.1.63 → 0.1.65
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 +130 -11
- package/package.json +2 -2
- package/src/client/components/ShellLayout.vue +1 -5
- package/src/client/components/ShellOutlet.vue +34 -4
- package/src/client/components/ShellOutletMenuWidget.vue +1 -8
- package/src/client/placement/index.js +5 -0
- package/src/client/placement/runtime.js +149 -16
- package/src/client/placement/validators.js +36 -8
- package/src/client/providers/ShellWebClientProvider.js +41 -0
- package/templates/src/components/ShellLayout.vue +1 -5
- package/templates/src/pages/home/settings.vue +1 -4
- package/templates/src/placement.js +7 -6
- package/templates/src/placementTopology.js +108 -0
- package/test/outletMenuWidgetContract.test.js +2 -2
- package/test/placementRegistry.test.js +3 -3
- package/test/placementRuntime.test.js +107 -14
- package/test/settingsPlacementContract.test.js +29 -12
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/shell-web",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.65",
|
|
5
5
|
kind: "runtime",
|
|
6
6
|
description: "Web shell layout runtime with outlet-based placement contributions.",
|
|
7
7
|
dependsOn: [],
|
|
@@ -71,46 +71,157 @@ export default Object.freeze({
|
|
|
71
71
|
},
|
|
72
72
|
{
|
|
73
73
|
target: "shell-layout:primary-menu",
|
|
74
|
-
defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
75
74
|
surfaces: ["*"],
|
|
76
75
|
source: "src/client/components/ShellLayout.vue"
|
|
77
76
|
},
|
|
78
77
|
{
|
|
79
78
|
target: "shell-layout:secondary-menu",
|
|
80
|
-
defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
81
79
|
surfaces: ["*"],
|
|
82
80
|
source: "src/client/components/ShellLayout.vue"
|
|
83
81
|
},
|
|
84
82
|
{
|
|
85
83
|
target: "home-settings:primary-menu",
|
|
86
|
-
defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
87
84
|
surfaces: ["home"],
|
|
88
85
|
source: "templates/src/pages/home/settings.vue"
|
|
89
86
|
}
|
|
90
87
|
],
|
|
88
|
+
topology: {
|
|
89
|
+
placements: [
|
|
90
|
+
{
|
|
91
|
+
id: "shell.primary-nav",
|
|
92
|
+
description: "Primary top-level navigation for the current surface.",
|
|
93
|
+
surfaces: ["*"],
|
|
94
|
+
default: true,
|
|
95
|
+
variants: {
|
|
96
|
+
compact: {
|
|
97
|
+
outlet: "shell-layout:primary-menu",
|
|
98
|
+
renderers: {
|
|
99
|
+
link: "local.main.ui.surface-aware-menu-link-item"
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
medium: {
|
|
103
|
+
outlet: "shell-layout:primary-menu",
|
|
104
|
+
renderers: {
|
|
105
|
+
link: "local.main.ui.surface-aware-menu-link-item"
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
expanded: {
|
|
109
|
+
outlet: "shell-layout:primary-menu",
|
|
110
|
+
renderers: {
|
|
111
|
+
link: "local.main.ui.surface-aware-menu-link-item"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "shell.status",
|
|
118
|
+
description: "Surface status, connection, and utility indicators.",
|
|
119
|
+
surfaces: ["*"],
|
|
120
|
+
variants: {
|
|
121
|
+
compact: {
|
|
122
|
+
outlet: "shell-layout:top-right"
|
|
123
|
+
},
|
|
124
|
+
medium: {
|
|
125
|
+
outlet: "shell-layout:top-right"
|
|
126
|
+
},
|
|
127
|
+
expanded: {
|
|
128
|
+
outlet: "shell-layout:top-right"
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "shell.secondary-nav",
|
|
134
|
+
description: "Secondary navigation for lower-priority shell links.",
|
|
135
|
+
surfaces: ["*"],
|
|
136
|
+
variants: {
|
|
137
|
+
compact: {
|
|
138
|
+
outlet: "shell-layout:secondary-menu",
|
|
139
|
+
renderers: {
|
|
140
|
+
link: "local.main.ui.surface-aware-menu-link-item"
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
medium: {
|
|
144
|
+
outlet: "shell-layout:secondary-menu",
|
|
145
|
+
renderers: {
|
|
146
|
+
link: "local.main.ui.surface-aware-menu-link-item"
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
expanded: {
|
|
150
|
+
outlet: "shell-layout:secondary-menu",
|
|
151
|
+
renderers: {
|
|
152
|
+
link: "local.main.ui.surface-aware-menu-link-item"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: "shell.identity",
|
|
159
|
+
description: "Current user, workspace, and surface identity controls.",
|
|
160
|
+
surfaces: ["*"],
|
|
161
|
+
variants: {
|
|
162
|
+
compact: {
|
|
163
|
+
outlet: "shell-layout:top-left"
|
|
164
|
+
},
|
|
165
|
+
medium: {
|
|
166
|
+
outlet: "shell-layout:top-left"
|
|
167
|
+
},
|
|
168
|
+
expanded: {
|
|
169
|
+
outlet: "shell-layout:top-left"
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: "page.section-nav",
|
|
175
|
+
owner: "home-settings",
|
|
176
|
+
description: "Navigation between child pages in the home settings section.",
|
|
177
|
+
surfaces: ["home"],
|
|
178
|
+
variants: {
|
|
179
|
+
compact: {
|
|
180
|
+
outlet: "home-settings:primary-menu",
|
|
181
|
+
renderers: {
|
|
182
|
+
link: "local.main.ui.surface-aware-menu-link-item"
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
medium: {
|
|
186
|
+
outlet: "home-settings:primary-menu",
|
|
187
|
+
renderers: {
|
|
188
|
+
link: "local.main.ui.surface-aware-menu-link-item"
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
expanded: {
|
|
192
|
+
outlet: "home-settings:primary-menu",
|
|
193
|
+
renderers: {
|
|
194
|
+
link: "local.main.ui.surface-aware-menu-link-item"
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
},
|
|
91
201
|
contributions: [
|
|
92
202
|
{
|
|
93
203
|
id: "shell-web.home.menu.home",
|
|
94
|
-
target: "shell
|
|
204
|
+
target: "shell.primary-nav",
|
|
205
|
+
kind: "link",
|
|
95
206
|
surfaces: ["home"],
|
|
96
207
|
order: 50,
|
|
97
|
-
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
98
208
|
source: "templates/src/placement.js"
|
|
99
209
|
},
|
|
100
210
|
{
|
|
101
211
|
id: "shell-web.home.menu.settings",
|
|
102
|
-
target: "shell
|
|
212
|
+
target: "shell.primary-nav",
|
|
213
|
+
kind: "link",
|
|
103
214
|
surfaces: ["home"],
|
|
104
215
|
order: 100,
|
|
105
|
-
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
106
216
|
source: "templates/src/placement.js"
|
|
107
217
|
},
|
|
108
218
|
{
|
|
109
219
|
id: "shell-web.home.settings.general",
|
|
110
|
-
target: "
|
|
220
|
+
target: "page.section-nav",
|
|
221
|
+
owner: "home-settings",
|
|
222
|
+
kind: "link",
|
|
111
223
|
surfaces: ["home"],
|
|
112
224
|
order: 100,
|
|
113
|
-
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
114
225
|
source: "templates/src/placement.js"
|
|
115
226
|
}
|
|
116
227
|
]
|
|
@@ -122,7 +233,7 @@ export default Object.freeze({
|
|
|
122
233
|
runtime: {
|
|
123
234
|
"@mdi/js": "^7.4.47",
|
|
124
235
|
"@tanstack/vue-query": "^5.90.5",
|
|
125
|
-
"@jskit-ai/kernel": "0.1.
|
|
236
|
+
"@jskit-ai/kernel": "0.1.66",
|
|
126
237
|
"vuetify": "^4.0.0"
|
|
127
238
|
},
|
|
128
239
|
dev: {}
|
|
@@ -254,6 +365,14 @@ export default Object.freeze({
|
|
|
254
365
|
category: "shell-web",
|
|
255
366
|
id: "shell-web-placement-registry"
|
|
256
367
|
},
|
|
368
|
+
{
|
|
369
|
+
from: "templates/src/placementTopology.js",
|
|
370
|
+
to: "src/placementTopology.js",
|
|
371
|
+
ownership: "app",
|
|
372
|
+
reason: "Install app-owned semantic placement topology used by shell-web placement runtime.",
|
|
373
|
+
category: "shell-web",
|
|
374
|
+
id: "shell-web-placement-topology"
|
|
375
|
+
},
|
|
257
376
|
{
|
|
258
377
|
from: "templates/src/pages/home.vue",
|
|
259
378
|
toSurface: "home",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/shell-web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.65",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@mdi/js": "^7.4.47",
|
|
27
27
|
"@tanstack/vue-query": "^5.90.5",
|
|
28
|
-
"@jskit-ai/kernel": "0.1.
|
|
28
|
+
"@jskit-ai/kernel": "0.1.66",
|
|
29
29
|
"pinia": "^3.0.4",
|
|
30
30
|
"vuetify": "^4.0.0"
|
|
31
31
|
},
|
|
@@ -52,13 +52,9 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
|
|
|
52
52
|
<ShellOutlet
|
|
53
53
|
target="shell-layout:primary-menu"
|
|
54
54
|
default
|
|
55
|
-
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
56
55
|
/>
|
|
57
56
|
<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
|
-
/>
|
|
57
|
+
<ShellOutlet target="shell-layout:secondary-menu" />
|
|
62
58
|
</v-list>
|
|
63
59
|
</slot>
|
|
64
60
|
</v-navigation-drawer>
|
|
@@ -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
|
});
|
|
@@ -7,10 +7,6 @@ const props = defineProps({
|
|
|
7
7
|
type: String,
|
|
8
8
|
required: true
|
|
9
9
|
},
|
|
10
|
-
defaultLinkComponentToken: {
|
|
11
|
-
type: String,
|
|
12
|
-
default: ""
|
|
13
|
-
},
|
|
14
10
|
icon: {
|
|
15
11
|
type: String,
|
|
16
12
|
default: mdiCogOutline
|
|
@@ -48,10 +44,7 @@ const props = defineProps({
|
|
|
48
44
|
</template>
|
|
49
45
|
|
|
50
46
|
<v-list :min-width="props.minWidth" density="comfortable" class="py-1">
|
|
51
|
-
<ShellOutlet
|
|
52
|
-
:target="props.target"
|
|
53
|
-
:default-link-component-token="props.defaultLinkComponentToken"
|
|
54
|
-
/>
|
|
47
|
+
<ShellOutlet :target="props.target" />
|
|
55
48
|
</v-list>
|
|
56
49
|
</v-menu>
|
|
57
50
|
</template>
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { DEFAULT_DEBUG_DEPTH, explodePayload } from "./debug.js";
|
|
2
2
|
import { createListenerSubscription } from "@jskit-ai/kernel/shared/support/listenerSet";
|
|
3
3
|
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
|
+
import {
|
|
5
|
+
normalizePlacementLayoutClass,
|
|
6
|
+
normalizePlacementTopologyDefinition
|
|
7
|
+
} from "@jskit-ai/kernel/shared/support/shellLayoutTargets";
|
|
4
8
|
import {
|
|
5
9
|
isRenderableComponent,
|
|
6
10
|
normalizePlacementDefinition,
|
|
@@ -100,6 +104,40 @@ function normalizePlacementList(placements, context = {}) {
|
|
|
100
104
|
.map((entry) => entry.placement);
|
|
101
105
|
}
|
|
102
106
|
|
|
107
|
+
function normalizeTopologyList(topology, context = {}) {
|
|
108
|
+
if (Array.isArray(topology)) {
|
|
109
|
+
const normalized = normalizePlacementTopologyDefinition(
|
|
110
|
+
{ placements: topology },
|
|
111
|
+
{
|
|
112
|
+
context: context.source || "placement topology"
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
return normalizeTopologyList(normalized, context);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const candidates = ensureArray(topology);
|
|
119
|
+
const entries = [];
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
const normalized = normalizePlacementTopologyDefinition(candidate, {
|
|
122
|
+
context: context.source || "placement topology"
|
|
123
|
+
});
|
|
124
|
+
entries.push(...normalized.placements);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const byKey = new Map();
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
const key = `${entry.id}::${entry.owner || ""}`;
|
|
130
|
+
if (byKey.has(key)) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Duplicate semantic placement "${entry.id}"${entry.owner ? ` for owner "${entry.owner}"` : ""} in ${context.source || "placement topology"}.`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
byKey.set(key, entry);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return Object.freeze([...byKey.values()]);
|
|
139
|
+
}
|
|
140
|
+
|
|
103
141
|
function matchesSurface(placementSurfaces, requestedSurface) {
|
|
104
142
|
if (requestedSurface === WEB_PLACEMENT_SURFACE_ANY) {
|
|
105
143
|
return true;
|
|
@@ -108,6 +146,67 @@ function matchesSurface(placementSurfaces, requestedSurface) {
|
|
|
108
146
|
return surfaces.includes(WEB_PLACEMENT_SURFACE_ANY) || surfaces.includes(requestedSurface);
|
|
109
147
|
}
|
|
110
148
|
|
|
149
|
+
function resolveTopologyPlacement(topologyEntries = [], placement = {}, requestedSurface = WEB_PLACEMENT_SURFACE_ANY) {
|
|
150
|
+
const semanticTarget = String(placement.target || "").trim();
|
|
151
|
+
const owner = String(placement.owner || "").trim();
|
|
152
|
+
const matches = topologyEntries.filter((entry) => {
|
|
153
|
+
if (entry.id !== semanticTarget) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
if (owner && entry.owner !== owner) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
if (!owner && entry.owner) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
return matchesSurface(entry.surfaces, requestedSurface);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
if (matches.length === 1) {
|
|
166
|
+
return matches[0];
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveRenderablePlacement({
|
|
172
|
+
placement = {},
|
|
173
|
+
topologyEntries = [],
|
|
174
|
+
requestedSurface = WEB_PLACEMENT_SURFACE_ANY,
|
|
175
|
+
requestedTarget = "",
|
|
176
|
+
requestedLayoutClass = "compact"
|
|
177
|
+
} = {}) {
|
|
178
|
+
if (placement.targetType === "concrete") {
|
|
179
|
+
if (placement.target !== requestedTarget) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return placement;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const topologyPlacement = resolveTopologyPlacement(topologyEntries, placement, requestedSurface);
|
|
186
|
+
if (!topologyPlacement) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const variant = topologyPlacement.variants?.[requestedLayoutClass] || null;
|
|
191
|
+
if (!variant || variant.outlet !== requestedTarget) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const componentToken = String(placement.componentToken || variant.renderers?.[placement.kind] || "").trim();
|
|
196
|
+
if (!componentToken) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return Object.freeze({
|
|
201
|
+
...placement,
|
|
202
|
+
target: requestedTarget,
|
|
203
|
+
semanticTarget: placement.target,
|
|
204
|
+
topologyOwner: topologyPlacement.owner || "",
|
|
205
|
+
layoutClass: requestedLayoutClass,
|
|
206
|
+
componentToken
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
111
210
|
function resolveContextContributors(app, baseContext = {}, logger) {
|
|
112
211
|
const contributors = app.resolveTag("web-placement.context.client");
|
|
113
212
|
let merged = {};
|
|
@@ -232,6 +331,7 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
|
|
|
232
331
|
const listeners = new Set();
|
|
233
332
|
const subscribe = createListenerSubscription(listeners);
|
|
234
333
|
let placementDefinitions = Object.freeze([]);
|
|
334
|
+
let placementTopology = Object.freeze([]);
|
|
235
335
|
let sharedContext = Object.freeze({});
|
|
236
336
|
let revision = 0;
|
|
237
337
|
|
|
@@ -276,6 +376,22 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
|
|
|
276
376
|
});
|
|
277
377
|
}
|
|
278
378
|
|
|
379
|
+
function replacePlacementTopology(topology = [], { source = "app placement topology" } = {}) {
|
|
380
|
+
missingTokens.clear();
|
|
381
|
+
invalidComponentTokens.clear();
|
|
382
|
+
failedTokens.clear();
|
|
383
|
+
placementTopology = normalizeTopologyList(topology, { source });
|
|
384
|
+
debugLog("replacePlacementTopology", {
|
|
385
|
+
source,
|
|
386
|
+
count: placementTopology.length,
|
|
387
|
+
ids: placementTopology.map((entry) => entry.owner ? `${entry.id}#${entry.owner}` : entry.id)
|
|
388
|
+
});
|
|
389
|
+
notify({
|
|
390
|
+
type: "placement-topology.replaced",
|
|
391
|
+
source
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
279
395
|
function getContext() {
|
|
280
396
|
return sharedContext;
|
|
281
397
|
}
|
|
@@ -308,13 +424,17 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
|
|
|
308
424
|
return revision;
|
|
309
425
|
}
|
|
310
426
|
|
|
311
|
-
function getPlacements({ surface = WEB_PLACEMENT_SURFACE_ANY, target = "", context = {} } = {}) {
|
|
427
|
+
function getPlacements({ surface = WEB_PLACEMENT_SURFACE_ANY, target = "", layoutClass = "", context = {} } = {}) {
|
|
312
428
|
const normalizedTarget = normalizePlacementTarget(target, { strict: false });
|
|
313
429
|
if (!normalizedTarget) {
|
|
314
430
|
return Object.freeze([]);
|
|
315
431
|
}
|
|
316
432
|
|
|
317
433
|
const normalizedSurface = normalizeSurface(surface);
|
|
434
|
+
const normalizedLayoutClass =
|
|
435
|
+
normalizePlacementLayoutClass(layoutClass) ||
|
|
436
|
+
normalizePlacementLayoutClass(sharedContext?.layoutClass) ||
|
|
437
|
+
"compact";
|
|
318
438
|
const baseContext = isRecord(context) ? { ...context } : {};
|
|
319
439
|
const contextFromRuntime = isRecord(sharedContext) ? sharedContext : {};
|
|
320
440
|
const contextFromContributors = resolveContextContributors(
|
|
@@ -323,6 +443,7 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
|
|
|
323
443
|
app,
|
|
324
444
|
surface: normalizedSurface,
|
|
325
445
|
target: normalizedTarget,
|
|
446
|
+
layoutClass: normalizedLayoutClass,
|
|
326
447
|
context: {
|
|
327
448
|
...contextFromRuntime,
|
|
328
449
|
...baseContext
|
|
@@ -336,44 +457,54 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
|
|
|
336
457
|
...baseContext,
|
|
337
458
|
app,
|
|
338
459
|
surface: normalizedSurface,
|
|
339
|
-
target: normalizedTarget
|
|
460
|
+
target: normalizedTarget,
|
|
461
|
+
layoutClass: normalizedLayoutClass
|
|
340
462
|
};
|
|
341
463
|
|
|
342
464
|
debugLog("getPlacements:start", {
|
|
343
465
|
surface: normalizedSurface,
|
|
344
466
|
target: normalizedTarget,
|
|
467
|
+
layoutClass: normalizedLayoutClass,
|
|
345
468
|
contextKeys: Object.keys(baseContext),
|
|
346
469
|
sharedContextKeys: Object.keys(contextFromRuntime),
|
|
347
|
-
placementCount: placementDefinitions.length
|
|
470
|
+
placementCount: placementDefinitions.length,
|
|
471
|
+
topologyCount: placementTopology.length
|
|
348
472
|
});
|
|
349
473
|
|
|
350
474
|
const matches = [];
|
|
351
475
|
for (const placement of placementDefinitions) {
|
|
352
|
-
|
|
476
|
+
const renderablePlacement = resolveRenderablePlacement({
|
|
477
|
+
placement,
|
|
478
|
+
topologyEntries: placementTopology,
|
|
479
|
+
requestedSurface: normalizedSurface,
|
|
480
|
+
requestedTarget: normalizedTarget,
|
|
481
|
+
requestedLayoutClass: normalizedLayoutClass
|
|
482
|
+
});
|
|
483
|
+
if (!renderablePlacement) {
|
|
353
484
|
continue;
|
|
354
485
|
}
|
|
355
|
-
const placementSurfaces = Array.isArray(
|
|
356
|
-
?
|
|
486
|
+
const placementSurfaces = Array.isArray(renderablePlacement.surfaces)
|
|
487
|
+
? renderablePlacement.surfaces
|
|
357
488
|
: [WEB_PLACEMENT_SURFACE_ANY];
|
|
358
489
|
|
|
359
490
|
if (!matchesSurface(placementSurfaces, normalizedSurface)) {
|
|
360
491
|
debugLog("getPlacements:skip-surfaces", {
|
|
361
|
-
placementId:
|
|
492
|
+
placementId: renderablePlacement.id,
|
|
362
493
|
placementSurfaces,
|
|
363
494
|
requestedSurface: normalizedSurface
|
|
364
495
|
});
|
|
365
496
|
continue;
|
|
366
497
|
}
|
|
367
|
-
if (!shouldIncludePlacement(
|
|
498
|
+
if (!shouldIncludePlacement(renderablePlacement, placementContext, runtimeLogger)) {
|
|
368
499
|
debugLog("getPlacements:skip-when", {
|
|
369
|
-
placementId:
|
|
500
|
+
placementId: renderablePlacement.id
|
|
370
501
|
});
|
|
371
502
|
continue;
|
|
372
503
|
}
|
|
373
504
|
|
|
374
505
|
const component = resolvePlacementComponent(
|
|
375
506
|
app,
|
|
376
|
-
|
|
507
|
+
renderablePlacement,
|
|
377
508
|
runtimeLogger,
|
|
378
509
|
missingTokens,
|
|
379
510
|
invalidComponentTokens,
|
|
@@ -381,22 +512,22 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
|
|
|
381
512
|
);
|
|
382
513
|
if (!component) {
|
|
383
514
|
debugLog("getPlacements:skip-component", {
|
|
384
|
-
placementId:
|
|
385
|
-
componentToken:
|
|
515
|
+
placementId: renderablePlacement.id,
|
|
516
|
+
componentToken: renderablePlacement.componentToken
|
|
386
517
|
});
|
|
387
518
|
continue;
|
|
388
519
|
}
|
|
389
520
|
|
|
390
521
|
debugLog("getPlacements:include", {
|
|
391
|
-
placementId:
|
|
392
|
-
componentToken:
|
|
522
|
+
placementId: renderablePlacement.id,
|
|
523
|
+
componentToken: renderablePlacement.componentToken,
|
|
393
524
|
placementSurfaces,
|
|
394
|
-
order:
|
|
525
|
+
order: renderablePlacement.order
|
|
395
526
|
});
|
|
396
527
|
|
|
397
528
|
matches.push(
|
|
398
529
|
Object.freeze({
|
|
399
|
-
...
|
|
530
|
+
...renderablePlacement,
|
|
400
531
|
component
|
|
401
532
|
})
|
|
402
533
|
);
|
|
@@ -405,6 +536,7 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
|
|
|
405
536
|
debugLog("getPlacements:done", {
|
|
406
537
|
surface: normalizedSurface,
|
|
407
538
|
target: normalizedTarget,
|
|
539
|
+
layoutClass: normalizedLayoutClass,
|
|
408
540
|
resultIds: matches.map((entry) => entry.id)
|
|
409
541
|
});
|
|
410
542
|
|
|
@@ -413,6 +545,7 @@ function createWebPlacementRuntime({ app, logger = null } = {}) {
|
|
|
413
545
|
|
|
414
546
|
return Object.freeze({
|
|
415
547
|
replacePlacements,
|
|
548
|
+
replacePlacementTopology,
|
|
416
549
|
getPlacements,
|
|
417
550
|
getContext,
|
|
418
551
|
setContext,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { isRecord, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
normalizePlacementKind,
|
|
4
|
+
normalizePlacementTopologyDefinition,
|
|
5
|
+
normalizeSemanticPlacementId,
|
|
6
|
+
resolvePlacementTargetReference
|
|
7
|
+
} from "@jskit-ai/kernel/shared/support/shellLayoutTargets";
|
|
3
8
|
|
|
4
9
|
const WEB_PLACEMENT_SURFACE_ANY = "*";
|
|
5
10
|
|
|
@@ -57,13 +62,13 @@ function toInteger(value, fallback = 1000) {
|
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
function normalizePlacementTarget(value, { strict = false, source = "placement" } = {}) {
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
return
|
|
65
|
+
const reference = resolvePlacementTargetReference(String(value || "").toLowerCase());
|
|
66
|
+
if (reference?.id) {
|
|
67
|
+
return reference.id;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
if (strict) {
|
|
66
|
-
throw new TypeError(`${source} requires target in "host:position" format.`);
|
|
71
|
+
throw new TypeError(`${source} requires semantic target in "area.slot" format or internal concrete target in "host:position" format.`);
|
|
67
72
|
}
|
|
68
73
|
return "";
|
|
69
74
|
}
|
|
@@ -132,8 +137,19 @@ function normalizePlacementDefinition(value, { strict = false, source = "placeme
|
|
|
132
137
|
return null;
|
|
133
138
|
}
|
|
134
139
|
|
|
140
|
+
const targetReference = resolvePlacementTargetReference(target);
|
|
141
|
+
const internal = value.internal === true;
|
|
142
|
+
if (targetReference?.type === "concrete" && internal !== true) {
|
|
143
|
+
if (strict) {
|
|
144
|
+
throw new TypeError(`${source} "${id}" targets concrete outlet "${target}" without internal: true.`);
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const owner = normalizeText(value.owner).toLowerCase();
|
|
150
|
+
const kind = normalizePlacementKind(value.kind) || (normalizeText(value.componentToken) ? "component" : "link");
|
|
135
151
|
const componentToken = normalizeText(value.componentToken);
|
|
136
|
-
if (!componentToken) {
|
|
152
|
+
if (kind === "component" && !componentToken) {
|
|
137
153
|
if (strict) {
|
|
138
154
|
throw new TypeError(`${source} "${id}" requires componentToken.`);
|
|
139
155
|
}
|
|
@@ -150,12 +166,16 @@ function normalizePlacementDefinition(value, { strict = false, source = "placeme
|
|
|
150
166
|
return Object.freeze({
|
|
151
167
|
id,
|
|
152
168
|
target,
|
|
169
|
+
targetType: targetReference?.type || "",
|
|
170
|
+
owner,
|
|
171
|
+
kind,
|
|
153
172
|
surfaces,
|
|
154
173
|
order: toInteger(value.order, 1000),
|
|
155
174
|
componentToken,
|
|
156
175
|
props,
|
|
157
176
|
when,
|
|
158
|
-
enabled: value.enabled !== false
|
|
177
|
+
enabled: value.enabled !== false,
|
|
178
|
+
internal
|
|
159
179
|
});
|
|
160
180
|
}
|
|
161
181
|
|
|
@@ -166,6 +186,12 @@ function definePlacement(value = {}) {
|
|
|
166
186
|
});
|
|
167
187
|
}
|
|
168
188
|
|
|
189
|
+
function definePlacementTopology(value = {}) {
|
|
190
|
+
return normalizePlacementTopologyDefinition(value, {
|
|
191
|
+
context: "placement topology"
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
169
195
|
export {
|
|
170
196
|
isRecord,
|
|
171
197
|
isRenderableComponent,
|
|
@@ -174,5 +200,7 @@ export {
|
|
|
174
200
|
normalizePlacementTarget,
|
|
175
201
|
normalizePlacementSurfaces,
|
|
176
202
|
normalizePlacementDefinition,
|
|
177
|
-
definePlacement
|
|
203
|
+
definePlacement,
|
|
204
|
+
definePlacementTopology,
|
|
205
|
+
normalizeSemanticPlacementId
|
|
178
206
|
};
|
|
@@ -30,6 +30,7 @@ import { resolveBootstrapErrorStatusCode } from "../bootstrap/bootstrapErrorStat
|
|
|
30
30
|
|
|
31
31
|
// Keep this constant for diagnostics, but keep import() below as a literal string so Vite can statically analyze it.
|
|
32
32
|
const APP_PLACEMENT_MODULE_SPECIFIER = "/src/placement.js";
|
|
33
|
+
const APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER = "/src/placementTopology.js";
|
|
33
34
|
const APP_ERROR_MODULE_SPECIFIER = "/src/error.js";
|
|
34
35
|
|
|
35
36
|
function createShellWebQueryClient() {
|
|
@@ -88,6 +89,42 @@ async function loadAppPlacementDefinitions(logger) {
|
|
|
88
89
|
return [];
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
async function loadAppPlacementTopology(logger) {
|
|
93
|
+
try {
|
|
94
|
+
const moduleNamespace = await import("/src/placementTopology.js");
|
|
95
|
+
const exported = moduleNamespace?.default;
|
|
96
|
+
const resolved = typeof exported === "function" ? exported() : exported;
|
|
97
|
+
if (Array.isArray(resolved)) {
|
|
98
|
+
return resolved;
|
|
99
|
+
}
|
|
100
|
+
if (resolved && typeof resolved === "object") {
|
|
101
|
+
return [resolved];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
logger.warn(
|
|
105
|
+
{
|
|
106
|
+
module: APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER,
|
|
107
|
+
exportedType: typeof exported
|
|
108
|
+
},
|
|
109
|
+
"App placement topology module default export did not resolve to an object or array; using empty topology."
|
|
110
|
+
);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (isMissingDynamicModule(error, APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER)) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
logger.warn(
|
|
117
|
+
{
|
|
118
|
+
module: APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER,
|
|
119
|
+
error: String(error?.message || error || "unknown error")
|
|
120
|
+
},
|
|
121
|
+
"Failed to load app placement topology module; using empty topology."
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
91
128
|
function createErrorConfigToolkit(errorRuntime) {
|
|
92
129
|
return Object.freeze({
|
|
93
130
|
createDefaultErrorPolicy,
|
|
@@ -297,6 +334,10 @@ class ShellWebClientProvider {
|
|
|
297
334
|
const logger = createSharedProviderLogger(isRecord(app) ? app : null);
|
|
298
335
|
const placementRuntime = app.make("runtime.web-placement.client");
|
|
299
336
|
if (placementRuntime && typeof placementRuntime.replacePlacements === "function") {
|
|
337
|
+
const placementTopology = await loadAppPlacementTopology(logger);
|
|
338
|
+
if (typeof placementRuntime.replacePlacementTopology === "function") {
|
|
339
|
+
placementRuntime.replacePlacementTopology(placementTopology, { source: APP_PLACEMENT_TOPOLOGY_MODULE_SPECIFIER });
|
|
340
|
+
}
|
|
300
341
|
const placements = await loadAppPlacementDefinitions(logger);
|
|
301
342
|
placementRuntime.replacePlacements(placements, { source: APP_PLACEMENT_MODULE_SPECIFIER });
|
|
302
343
|
const appConfig = getClientAppConfig();
|
|
@@ -52,13 +52,9 @@ const { drawerOpen, toggleDrawer, resolvedSurface, resolvedSurfaceLabel } = useS
|
|
|
52
52
|
<ShellOutlet
|
|
53
53
|
target="shell-layout:primary-menu"
|
|
54
54
|
default
|
|
55
|
-
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
56
55
|
/>
|
|
57
56
|
<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
|
-
/>
|
|
57
|
+
<ShellOutlet target="shell-layout:secondary-menu" />
|
|
62
58
|
</v-list>
|
|
63
59
|
</slot>
|
|
64
60
|
</v-navigation-drawer>
|
|
@@ -15,10 +15,7 @@ import { RouterView } from "vue-router";
|
|
|
15
15
|
<v-row no-gutters>
|
|
16
16
|
<v-col cols="12" md="3" lg="2" class="pr-md-4 mb-4 mb-md-0">
|
|
17
17
|
<v-list nav density="comfortable" rounded="lg" border>
|
|
18
|
-
<ShellOutlet
|
|
19
|
-
target="home-settings:primary-menu"
|
|
20
|
-
default-link-component-token="local.main.ui.surface-aware-menu-link-item"
|
|
21
|
-
/>
|
|
18
|
+
<ShellOutlet target="home-settings:primary-menu" />
|
|
22
19
|
</v-list>
|
|
23
20
|
</v-col>
|
|
24
21
|
|
|
@@ -13,10 +13,10 @@ export default function getPlacements() {
|
|
|
13
13
|
|
|
14
14
|
addPlacement({
|
|
15
15
|
id: "shell-web.home.menu.home",
|
|
16
|
-
target: "shell
|
|
16
|
+
target: "shell.primary-nav",
|
|
17
|
+
kind: "link",
|
|
17
18
|
surfaces: ["home"],
|
|
18
19
|
order: 50,
|
|
19
|
-
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
20
20
|
props: {
|
|
21
21
|
label: "Home",
|
|
22
22
|
surface: "home",
|
|
@@ -28,10 +28,10 @@ addPlacement({
|
|
|
28
28
|
|
|
29
29
|
addPlacement({
|
|
30
30
|
id: "shell-web.home.menu.settings",
|
|
31
|
-
target: "shell
|
|
31
|
+
target: "shell.primary-nav",
|
|
32
|
+
kind: "link",
|
|
32
33
|
surfaces: ["home"],
|
|
33
34
|
order: 100,
|
|
34
|
-
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
35
35
|
props: {
|
|
36
36
|
label: "Settings",
|
|
37
37
|
surface: "home",
|
|
@@ -42,10 +42,11 @@ addPlacement({
|
|
|
42
42
|
|
|
43
43
|
addPlacement({
|
|
44
44
|
id: "shell-web.home.settings.general",
|
|
45
|
-
target: "
|
|
45
|
+
target: "page.section-nav",
|
|
46
|
+
owner: "home-settings",
|
|
47
|
+
kind: "link",
|
|
46
48
|
surfaces: ["home"],
|
|
47
49
|
order: 100,
|
|
48
|
-
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
49
50
|
props: {
|
|
50
51
|
label: "General",
|
|
51
52
|
surface: "home",
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const placements = [];
|
|
2
|
+
|
|
3
|
+
function addPlacementTopology(value = {}) {
|
|
4
|
+
placements.push(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export { addPlacementTopology };
|
|
8
|
+
export default { placements };
|
|
9
|
+
|
|
10
|
+
const menuLinkRenderers = Object.freeze({
|
|
11
|
+
link: "local.main.ui.surface-aware-menu-link-item"
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
addPlacementTopology({
|
|
15
|
+
id: "shell.primary-nav",
|
|
16
|
+
description: "Primary top-level navigation for the current surface.",
|
|
17
|
+
surfaces: ["*"],
|
|
18
|
+
default: true,
|
|
19
|
+
variants: {
|
|
20
|
+
compact: {
|
|
21
|
+
outlet: "shell-layout:primary-menu",
|
|
22
|
+
renderers: menuLinkRenderers
|
|
23
|
+
},
|
|
24
|
+
medium: {
|
|
25
|
+
outlet: "shell-layout:primary-menu",
|
|
26
|
+
renderers: menuLinkRenderers
|
|
27
|
+
},
|
|
28
|
+
expanded: {
|
|
29
|
+
outlet: "shell-layout:primary-menu",
|
|
30
|
+
renderers: menuLinkRenderers
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
addPlacementTopology({
|
|
36
|
+
id: "shell.secondary-nav",
|
|
37
|
+
description: "Secondary navigation for the current surface.",
|
|
38
|
+
surfaces: ["*"],
|
|
39
|
+
variants: {
|
|
40
|
+
compact: {
|
|
41
|
+
outlet: "shell-layout:secondary-menu",
|
|
42
|
+
renderers: menuLinkRenderers
|
|
43
|
+
},
|
|
44
|
+
medium: {
|
|
45
|
+
outlet: "shell-layout:secondary-menu",
|
|
46
|
+
renderers: menuLinkRenderers
|
|
47
|
+
},
|
|
48
|
+
expanded: {
|
|
49
|
+
outlet: "shell-layout:secondary-menu",
|
|
50
|
+
renderers: menuLinkRenderers
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
addPlacementTopology({
|
|
56
|
+
id: "shell.identity",
|
|
57
|
+
description: "Current surface identity and switcher controls.",
|
|
58
|
+
surfaces: ["*"],
|
|
59
|
+
variants: {
|
|
60
|
+
compact: {
|
|
61
|
+
outlet: "shell-layout:top-left"
|
|
62
|
+
},
|
|
63
|
+
medium: {
|
|
64
|
+
outlet: "shell-layout:top-left"
|
|
65
|
+
},
|
|
66
|
+
expanded: {
|
|
67
|
+
outlet: "shell-layout:top-left"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
addPlacementTopology({
|
|
73
|
+
id: "shell.status",
|
|
74
|
+
description: "Surface status, connection, and utility indicators.",
|
|
75
|
+
surfaces: ["*"],
|
|
76
|
+
variants: {
|
|
77
|
+
compact: {
|
|
78
|
+
outlet: "shell-layout:top-right"
|
|
79
|
+
},
|
|
80
|
+
medium: {
|
|
81
|
+
outlet: "shell-layout:top-right"
|
|
82
|
+
},
|
|
83
|
+
expanded: {
|
|
84
|
+
outlet: "shell-layout:top-right"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
addPlacementTopology({
|
|
90
|
+
id: "page.section-nav",
|
|
91
|
+
owner: "home-settings",
|
|
92
|
+
description: "Navigation between child pages in the home settings section.",
|
|
93
|
+
surfaces: ["home"],
|
|
94
|
+
variants: {
|
|
95
|
+
compact: {
|
|
96
|
+
outlet: "home-settings:primary-menu",
|
|
97
|
+
renderers: menuLinkRenderers
|
|
98
|
+
},
|
|
99
|
+
medium: {
|
|
100
|
+
outlet: "home-settings:primary-menu",
|
|
101
|
+
renderers: menuLinkRenderers
|
|
102
|
+
},
|
|
103
|
+
expanded: {
|
|
104
|
+
outlet: "home-settings:primary-menu",
|
|
105
|
+
renderers: menuLinkRenderers
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
@@ -14,9 +14,9 @@ test("shell-web outlet menu widget exposes a configurable nested outlet", async
|
|
|
14
14
|
);
|
|
15
15
|
|
|
16
16
|
assert.match(source, /import \{ mdiCogOutline \} from "@mdi\/js";/);
|
|
17
|
-
assert.match(source, /defaultLinkComponentToken: \{/);
|
|
18
17
|
assert.match(source, /:target="props\.target"/);
|
|
19
|
-
assert.
|
|
18
|
+
assert.doesNotMatch(source, /defaultLinkComponentToken/);
|
|
19
|
+
assert.doesNotMatch(source, /default-link-component-token/);
|
|
20
20
|
assert.match(source, /default: mdiCogOutline/);
|
|
21
21
|
assert.doesNotMatch(source, /mdi-[a-z0-9-]+/);
|
|
22
22
|
});
|
|
@@ -7,13 +7,13 @@ test("placement registry stores unique entries and builds immutable array", () =
|
|
|
7
7
|
|
|
8
8
|
const firstAdded = registry.addPlacement({
|
|
9
9
|
id: "example.profile",
|
|
10
|
-
target: "shell
|
|
10
|
+
target: "shell.status",
|
|
11
11
|
surfaces: ["*"],
|
|
12
12
|
componentToken: "example.profile.component"
|
|
13
13
|
});
|
|
14
14
|
const duplicateAdded = registry.addPlacement({
|
|
15
15
|
id: "example.profile",
|
|
16
|
-
target: "shell
|
|
16
|
+
target: "shell.status",
|
|
17
17
|
surfaces: ["*"],
|
|
18
18
|
componentToken: "example.profile.component.duplicate"
|
|
19
19
|
});
|
|
@@ -33,7 +33,7 @@ test("placement registry accepts explicit non-global surface ids", () => {
|
|
|
33
33
|
|
|
34
34
|
const added = registry.addPlacement({
|
|
35
35
|
id: "example.admin",
|
|
36
|
-
target: "shell
|
|
36
|
+
target: "shell.status",
|
|
37
37
|
surfaces: ["admin"],
|
|
38
38
|
componentToken: "example.admin.component"
|
|
39
39
|
});
|
|
@@ -32,6 +32,82 @@ function createPlacementContext() {
|
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function semanticTopologyEntry({
|
|
36
|
+
id = "shell.primary-nav",
|
|
37
|
+
owner = "",
|
|
38
|
+
surfaces = ["*"],
|
|
39
|
+
compact = "shell-layout:primary-menu",
|
|
40
|
+
medium = "shell-layout:primary-menu",
|
|
41
|
+
expanded = "shell-layout:primary-menu",
|
|
42
|
+
compactRenderer = "component.menu",
|
|
43
|
+
mediumRenderer = "component.menu",
|
|
44
|
+
expandedRenderer = "component.menu"
|
|
45
|
+
} = {}) {
|
|
46
|
+
const createVariant = (outlet, renderer) => ({
|
|
47
|
+
outlet,
|
|
48
|
+
renderers: renderer ? { link: renderer } : {}
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
owner,
|
|
53
|
+
surfaces,
|
|
54
|
+
variants: {
|
|
55
|
+
compact: createVariant(compact, compactRenderer),
|
|
56
|
+
medium: createVariant(medium, mediumRenderer),
|
|
57
|
+
expanded: createVariant(expanded, expandedRenderer)
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test("web placement runtime resolves semantic targets through topology variants", () => {
|
|
63
|
+
const app = createAppStub({
|
|
64
|
+
tokens: {
|
|
65
|
+
"component.bottom": () => null,
|
|
66
|
+
"component.menu": () => null
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const runtime = createWebPlacementRuntime({ app });
|
|
71
|
+
runtime.replacePlacementTopology([
|
|
72
|
+
semanticTopologyEntry({
|
|
73
|
+
id: "shell.primary-nav",
|
|
74
|
+
compact: "shell-layout:bottom-nav",
|
|
75
|
+
medium: "shell-layout:primary-menu",
|
|
76
|
+
expanded: "shell-layout:primary-menu",
|
|
77
|
+
compactRenderer: "component.bottom",
|
|
78
|
+
mediumRenderer: "component.menu",
|
|
79
|
+
expandedRenderer: "component.menu"
|
|
80
|
+
})
|
|
81
|
+
]);
|
|
82
|
+
runtime.replacePlacements([
|
|
83
|
+
definePlacement({
|
|
84
|
+
id: "test.home",
|
|
85
|
+
target: "shell.primary-nav",
|
|
86
|
+
kind: "link",
|
|
87
|
+
surfaces: ["app"],
|
|
88
|
+
order: 10
|
|
89
|
+
})
|
|
90
|
+
]);
|
|
91
|
+
runtime.setContext(createPlacementContext());
|
|
92
|
+
|
|
93
|
+
const compactEntries = runtime.getPlacements({
|
|
94
|
+
surface: "app",
|
|
95
|
+
target: "shell-layout:bottom-nav",
|
|
96
|
+
layoutClass: "compact"
|
|
97
|
+
});
|
|
98
|
+
assert.deepEqual(compactEntries.map((entry) => entry.id), ["test.home"]);
|
|
99
|
+
assert.equal(compactEntries[0].semanticTarget, "shell.primary-nav");
|
|
100
|
+
assert.equal(compactEntries[0].componentToken, "component.bottom");
|
|
101
|
+
|
|
102
|
+
const mediumEntries = runtime.getPlacements({
|
|
103
|
+
surface: "app",
|
|
104
|
+
target: "shell-layout:primary-menu",
|
|
105
|
+
layoutClass: "medium"
|
|
106
|
+
});
|
|
107
|
+
assert.deepEqual(mediumEntries.map((entry) => entry.id), ["test.home"]);
|
|
108
|
+
assert.equal(mediumEntries[0].componentToken, "component.menu");
|
|
109
|
+
});
|
|
110
|
+
|
|
35
111
|
test("web placement runtime filters by surface/host/position, resolves component tokens, and sorts by order", () => {
|
|
36
112
|
const app = createAppStub({
|
|
37
113
|
tokens: {
|
|
@@ -48,21 +124,24 @@ test("web placement runtime filters by surface/host/position, resolves component
|
|
|
48
124
|
target: "shell-layout:primary-menu",
|
|
49
125
|
surfaces: ["app"],
|
|
50
126
|
order: 30,
|
|
51
|
-
componentToken: "component.menu"
|
|
127
|
+
componentToken: "component.menu",
|
|
128
|
+
internal: true
|
|
52
129
|
}),
|
|
53
130
|
definePlacement({
|
|
54
131
|
id: "test.profile",
|
|
55
132
|
target: "shell-layout:top-right",
|
|
56
133
|
surfaces: ["*"],
|
|
57
134
|
order: 20,
|
|
58
|
-
componentToken: "component.profile"
|
|
135
|
+
componentToken: "component.profile",
|
|
136
|
+
internal: true
|
|
59
137
|
}),
|
|
60
138
|
definePlacement({
|
|
61
139
|
id: "test.alerts",
|
|
62
140
|
target: "shell-layout:top-right",
|
|
63
141
|
surfaces: ["app"],
|
|
64
142
|
order: 10,
|
|
65
|
-
componentToken: "component.alerts"
|
|
143
|
+
componentToken: "component.alerts",
|
|
144
|
+
internal: true
|
|
66
145
|
})
|
|
67
146
|
]);
|
|
68
147
|
runtime.setContext(createPlacementContext());
|
|
@@ -93,14 +172,16 @@ test("web placement runtime preserves source order when placements share the sam
|
|
|
93
172
|
target: "home-settings:primary-menu",
|
|
94
173
|
surfaces: ["app"],
|
|
95
174
|
order: 155,
|
|
96
|
-
componentToken: "component.beta"
|
|
175
|
+
componentToken: "component.beta",
|
|
176
|
+
internal: true
|
|
97
177
|
}),
|
|
98
178
|
definePlacement({
|
|
99
179
|
id: "test.alpha",
|
|
100
180
|
target: "home-settings:primary-menu",
|
|
101
181
|
surfaces: ["app"],
|
|
102
182
|
order: 155,
|
|
103
|
-
componentToken: "component.alpha"
|
|
183
|
+
componentToken: "component.alpha",
|
|
184
|
+
internal: true
|
|
104
185
|
})
|
|
105
186
|
]);
|
|
106
187
|
runtime.setContext(createPlacementContext());
|
|
@@ -129,6 +210,7 @@ test("web placement runtime applies context contributors and placement when() pr
|
|
|
129
210
|
target: "auth-profile-menu:primary-menu",
|
|
130
211
|
surfaces: ["*"],
|
|
131
212
|
componentToken: "component.guest",
|
|
213
|
+
internal: true,
|
|
132
214
|
when: ({ auth }) => !Boolean(auth?.authenticated)
|
|
133
215
|
}),
|
|
134
216
|
definePlacement({
|
|
@@ -136,6 +218,7 @@ test("web placement runtime applies context contributors and placement when() pr
|
|
|
136
218
|
target: "auth-profile-menu:primary-menu",
|
|
137
219
|
surfaces: ["*"],
|
|
138
220
|
componentToken: "component.authenticated",
|
|
221
|
+
internal: true,
|
|
139
222
|
when: ({ auth }) => Boolean(auth?.authenticated)
|
|
140
223
|
})
|
|
141
224
|
]);
|
|
@@ -165,6 +248,7 @@ test("web placement runtime uses runtime context and local context overrides con
|
|
|
165
248
|
target: "auth-profile-menu:primary-menu",
|
|
166
249
|
surfaces: ["*"],
|
|
167
250
|
componentToken: "component.allowed",
|
|
251
|
+
internal: true,
|
|
168
252
|
when: ({ auth }) => Boolean(auth?.authenticated)
|
|
169
253
|
})
|
|
170
254
|
]);
|
|
@@ -224,13 +308,15 @@ test("web placement runtime rejects duplicate placement ids", () => {
|
|
|
224
308
|
id: "dup.entry",
|
|
225
309
|
target: "shell-layout:top-right",
|
|
226
310
|
surfaces: ["*"],
|
|
227
|
-
componentToken: "component.a"
|
|
311
|
+
componentToken: "component.a",
|
|
312
|
+
internal: true
|
|
228
313
|
}),
|
|
229
314
|
definePlacement({
|
|
230
315
|
id: "dup.entry",
|
|
231
316
|
target: "shell-layout:primary-menu",
|
|
232
317
|
surfaces: ["*"],
|
|
233
|
-
componentToken: "component.b"
|
|
318
|
+
componentToken: "component.b",
|
|
319
|
+
internal: true
|
|
234
320
|
})
|
|
235
321
|
]);
|
|
236
322
|
}, /Duplicate placement id/);
|
|
@@ -271,13 +357,15 @@ test("web placement runtime skips throwing component tokens and logs resolution
|
|
|
271
357
|
id: "bad",
|
|
272
358
|
target: "shell-layout:top-right",
|
|
273
359
|
surfaces: ["*"],
|
|
274
|
-
componentToken: "component.bad"
|
|
360
|
+
componentToken: "component.bad",
|
|
361
|
+
internal: true
|
|
275
362
|
}),
|
|
276
363
|
definePlacement({
|
|
277
364
|
id: "good",
|
|
278
365
|
target: "shell-layout:top-right",
|
|
279
366
|
surfaces: ["*"],
|
|
280
|
-
componentToken: "component.good"
|
|
367
|
+
componentToken: "component.good",
|
|
368
|
+
internal: true
|
|
281
369
|
})
|
|
282
370
|
]);
|
|
283
371
|
|
|
@@ -323,7 +411,8 @@ test("web placement runtime clears failed token cache when placements are replac
|
|
|
323
411
|
id: "toggle",
|
|
324
412
|
target: "shell-layout:top-right",
|
|
325
413
|
surfaces: ["*"],
|
|
326
|
-
componentToken: "component.toggle"
|
|
414
|
+
componentToken: "component.toggle",
|
|
415
|
+
internal: true
|
|
327
416
|
})
|
|
328
417
|
]);
|
|
329
418
|
|
|
@@ -339,7 +428,8 @@ test("web placement runtime clears failed token cache when placements are replac
|
|
|
339
428
|
id: "toggle",
|
|
340
429
|
target: "shell-layout:top-right",
|
|
341
430
|
surfaces: ["*"],
|
|
342
|
-
componentToken: "component.toggle"
|
|
431
|
+
componentToken: "component.toggle",
|
|
432
|
+
internal: true
|
|
343
433
|
})
|
|
344
434
|
]);
|
|
345
435
|
|
|
@@ -362,21 +452,24 @@ test("web placement runtime follows explicit surface targeting without role indi
|
|
|
362
452
|
target: "shell-layout:top-right",
|
|
363
453
|
surfaces: ["*"],
|
|
364
454
|
order: 10,
|
|
365
|
-
componentToken: "component.global"
|
|
455
|
+
componentToken: "component.global",
|
|
456
|
+
internal: true
|
|
366
457
|
}),
|
|
367
458
|
definePlacement({
|
|
368
459
|
id: "app.link",
|
|
369
460
|
target: "shell-layout:top-right",
|
|
370
461
|
surfaces: ["app"],
|
|
371
462
|
order: 20,
|
|
372
|
-
componentToken: "component.app"
|
|
463
|
+
componentToken: "component.app",
|
|
464
|
+
internal: true
|
|
373
465
|
}),
|
|
374
466
|
definePlacement({
|
|
375
467
|
id: "admin.link",
|
|
376
468
|
target: "shell-layout:top-right",
|
|
377
469
|
surfaces: ["admin"],
|
|
378
470
|
order: 30,
|
|
379
|
-
componentToken: "component.admin"
|
|
471
|
+
componentToken: "component.admin",
|
|
472
|
+
internal: true
|
|
380
473
|
})
|
|
381
474
|
]);
|
|
382
475
|
runtime.setContext(createPlacementContext());
|
|
@@ -24,6 +24,18 @@ function readContributions(target = "") {
|
|
|
24
24
|
: [];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function readTopology(id = "", owner = "") {
|
|
28
|
+
const placements = descriptor?.metadata?.ui?.placements?.topology?.placements;
|
|
29
|
+
const normalizedId = String(id || "").trim();
|
|
30
|
+
const normalizedOwner = String(owner || "").trim();
|
|
31
|
+
return Array.isArray(placements)
|
|
32
|
+
? placements.filter((entry) =>
|
|
33
|
+
String(entry?.id || "").trim() === normalizedId &&
|
|
34
|
+
String(entry?.owner || "").trim() === normalizedOwner
|
|
35
|
+
)
|
|
36
|
+
: [];
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
function findFileMutation(id) {
|
|
28
40
|
const files = descriptor?.mutations?.files;
|
|
29
41
|
return Array.isArray(files)
|
|
@@ -35,7 +47,7 @@ test("shell-web home settings template exposes surface-derived settings outlets"
|
|
|
35
47
|
const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "pages", "home", "settings.vue"), "utf8");
|
|
36
48
|
|
|
37
49
|
assert.match(source, /target="home-settings:primary-menu"/);
|
|
38
|
-
assert.
|
|
50
|
+
assert.doesNotMatch(source, /default-link-component-token/);
|
|
39
51
|
assert.match(source, /<RouterView \/>/);
|
|
40
52
|
});
|
|
41
53
|
|
|
@@ -64,7 +76,8 @@ test("shell-web placement template seeds default Home and Settings drawer naviga
|
|
|
64
76
|
const source = await readFile(path.join(PACKAGE_DIR, "templates", "src", "placement.js"), "utf8");
|
|
65
77
|
|
|
66
78
|
assert.match(source, /id: "shell-web\.home\.menu\.home"/);
|
|
67
|
-
assert.match(source, /target: "shell
|
|
79
|
+
assert.match(source, /target: "shell\.primary-nav"/);
|
|
80
|
+
assert.match(source, /kind: "link"/);
|
|
68
81
|
assert.match(source, /surfaces: \["home"\]/);
|
|
69
82
|
assert.match(source, /label: "Home"/);
|
|
70
83
|
assert.match(source, /unscopedSuffix: "\/"/);
|
|
@@ -72,7 +85,8 @@ test("shell-web placement template seeds default Home and Settings drawer naviga
|
|
|
72
85
|
assert.match(source, /label: "Settings"/);
|
|
73
86
|
assert.match(source, /unscopedSuffix: "\/settings"/);
|
|
74
87
|
assert.match(source, /id: "shell-web\.home\.settings\.general"/);
|
|
75
|
-
assert.match(source, /target: "
|
|
88
|
+
assert.match(source, /target: "page\.section-nav"/);
|
|
89
|
+
assert.match(source, /owner: "home-settings"/);
|
|
76
90
|
assert.match(source, /label: "General"/);
|
|
77
91
|
assert.match(source, /unscopedSuffix: "\/settings\/general"/);
|
|
78
92
|
assert.doesNotMatch(source, /to: "\.\/general"/);
|
|
@@ -84,7 +98,6 @@ test("shell-web descriptor metadata advertises home settings outlets, default dr
|
|
|
84
98
|
[
|
|
85
99
|
{
|
|
86
100
|
target: "home-settings:primary-menu",
|
|
87
|
-
defaultLinkComponentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
88
101
|
surfaces: ["home"],
|
|
89
102
|
source: "templates/src/pages/home/settings.vue"
|
|
90
103
|
}
|
|
@@ -92,41 +105,45 @@ test("shell-web descriptor metadata advertises home settings outlets, default dr
|
|
|
92
105
|
);
|
|
93
106
|
|
|
94
107
|
assert.deepEqual(
|
|
95
|
-
readContributions("shell
|
|
108
|
+
readContributions("shell.primary-nav"),
|
|
96
109
|
[
|
|
97
110
|
{
|
|
98
111
|
id: "shell-web.home.menu.home",
|
|
99
|
-
target: "shell
|
|
112
|
+
target: "shell.primary-nav",
|
|
113
|
+
kind: "link",
|
|
100
114
|
surfaces: ["home"],
|
|
101
115
|
order: 50,
|
|
102
|
-
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
103
116
|
source: "templates/src/placement.js"
|
|
104
117
|
},
|
|
105
118
|
{
|
|
106
119
|
id: "shell-web.home.menu.settings",
|
|
107
|
-
target: "shell
|
|
120
|
+
target: "shell.primary-nav",
|
|
121
|
+
kind: "link",
|
|
108
122
|
surfaces: ["home"],
|
|
109
123
|
order: 100,
|
|
110
|
-
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
111
124
|
source: "templates/src/placement.js"
|
|
112
125
|
}
|
|
113
126
|
]
|
|
114
127
|
);
|
|
115
128
|
|
|
116
129
|
assert.deepEqual(
|
|
117
|
-
readContributions("
|
|
130
|
+
readContributions("page.section-nav"),
|
|
118
131
|
[
|
|
119
132
|
{
|
|
120
133
|
id: "shell-web.home.settings.general",
|
|
121
|
-
target: "
|
|
134
|
+
target: "page.section-nav",
|
|
135
|
+
owner: "home-settings",
|
|
136
|
+
kind: "link",
|
|
122
137
|
surfaces: ["home"],
|
|
123
138
|
order: 100,
|
|
124
|
-
componentToken: "local.main.ui.surface-aware-menu-link-item",
|
|
125
139
|
source: "templates/src/placement.js"
|
|
126
140
|
}
|
|
127
141
|
]
|
|
128
142
|
);
|
|
129
143
|
|
|
144
|
+
assert.equal(readTopology("shell.primary-nav").length, 1);
|
|
145
|
+
assert.equal(readTopology("page.section-nav", "home-settings").length, 1);
|
|
146
|
+
|
|
130
147
|
assert.deepEqual(findFileMutation("shell-web-page-home-settings-shell"), {
|
|
131
148
|
from: "templates/src/pages/home/settings.vue",
|
|
132
149
|
toSurface: "home",
|