Plugin studio: an Explorer tree for the rail, imitating VS Code
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
The studio's rail was hand-rolled vertical tabs stretched into a tree. It is now an Explorer tree modelled on the VS Code Explorer, keyboarded per the WAI-ARIA tree pattern: - CAP 5 -> CAP 1 sections with sticky headers; one-line rows with the name and a muted element tag; twisty on the left; indent guides. - Composites fold; their parts hang beneath, one level at a time, each part opening the plugin's one canonical entry. - A filter box on top shows matches together with the path to them. - One view action, Collapse all. - Auto-reveal: opening a plugin by click, URL hash or remembered tab scrolls its row into view and opens its section. - Arrow keys walk the tree, Right/Left open, close and move between parent and child, Enter opens, a letter jumps to the next name. - The slim rail becomes an activity bar with one icon per section. - Fold state is remembered per page. The tree lives in plugin-designs/plugin-studio/src/StudioTree.vue and fills VerticalTabsLayout's new #rail slot, so the layout still owns the collapse toggle, mobile drawer and slim strip. The alias, depth and branch row fields added to the layout earlier today are removed again: nothing else used them. The studio host now has a default height (85vh, min 480px), as doxa-maps-spa does. Pasting the published embed snippet into a plain page mounted a complete studio 0px tall. A page's own height or --plugin-studio-height still wins. The studio gates that click tabs by text now also match tree rows. Rebuilt the plugin-studio bundle and re-baked its SRI.
This commit is contained in:
parent
a9bad3ccf2
commit
f63d75ed80
12 changed files with 484 additions and 159 deletions
|
|
@ -68,9 +68,9 @@ async function measure(page, label, activateTab) {
|
||||||
if (activateTab) {
|
if (activateTab) {
|
||||||
// Heavy tabs lazy-mount on activation: before the click nothing exists and every query returns
|
// Heavy tabs lazy-mount on activation: before the click nothing exists and every query returns
|
||||||
// 0, which reads as "no filters here" rather than "not mounted yet".
|
// 0, which reads as "no filters here" rather than "not mounted yet".
|
||||||
const idx = await page.evaluate(`(${WALK})('.vtl-tab').findIndex(t=>${activateTab}.test(t.textContent||''))`)
|
const idx = await page.evaluate(`(${WALK})('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)').findIndex(t=>${activateTab}.test(t.textContent||''))`)
|
||||||
if (idx < 0) return { label, error: 'no tab matched ' + activateTab }
|
if (idx < 0) return { label, error: 'no tab matched ' + activateTab }
|
||||||
await page.evaluate(`(${WALK})('.vtl-tab')[${idx}].click()`)
|
await page.evaluate(`(${WALK})('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')[${idx}].click()`)
|
||||||
await page.waitForTimeout(9000)
|
await page.waitForTimeout(9000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -628,7 +628,7 @@ async function runShellBestEffort() {
|
||||||
// already proved (dashboard-plus-grid / "Big Picture" / "Dashboard"), rather than assuming a
|
// already proved (dashboard-plus-grid / "Big Picture" / "Dashboard"), rather than assuming a
|
||||||
// fixed label or index — resilient to the three-section restructure landing mid-flight.
|
// fixed label or index — resilient to the three-section restructure landing mid-flight.
|
||||||
const tab = await page.evaluate(`(() => {
|
const tab = await page.evaluate(`(() => {
|
||||||
const cands = __walk('.vtl-tab');
|
const cands = __walk('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)');
|
||||||
const hit = cands.find(t => /dashboard.plus.grid|big picture|dashboard/i.test((t.textContent||'')));
|
const hit = cands.find(t => /dashboard.plus.grid|big picture|dashboard/i.test((t.textContent||'')));
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
hit.click();
|
hit.click();
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,9 @@ async function hostRun(browser, label, url, activate) {
|
||||||
// Heavy tabs are lazy-mounted on activation, so nothing exists until a tab is clicked. A flat
|
// Heavy tabs are lazy-mounted on activation, so nothing exists until a tab is clicked. A flat
|
||||||
// query before this point returns 0 triggers and reads as "no dropdowns here" — which is how
|
// query before this point returns 0 triggers and reads as "no dropdowns here" — which is how
|
||||||
// a host can silently drop out of coverage while the gate still reports success.
|
// a host can silently drop out of coverage while the gate still reports success.
|
||||||
const idx = await page.evaluate(`(${DEEP})('.vtl-tab').findIndex(t=>${activate}.test(t.textContent||''))`)
|
const idx = await page.evaluate(`(${DEEP})('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)').findIndex(t=>${activate}.test(t.textContent||''))`)
|
||||||
if (idx < 0) return { label, error: 'no tab matched ' + activate }
|
if (idx < 0) return { label, error: 'no tab matched ' + activate }
|
||||||
await page.evaluate(`(${DEEP})('.vtl-tab')[${idx}].click()`)
|
await page.evaluate(`(${DEEP})('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')[${idx}].click()`)
|
||||||
await page.waitForTimeout(9000)
|
await page.waitForTimeout(9000)
|
||||||
}
|
}
|
||||||
const n = await page.evaluate(`(${DEEP})('.filter-dropdown__trigger').length`)
|
const n = await page.evaluate(`(${DEEP})('.filter-dropdown__trigger').length`)
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ try {
|
||||||
await page.evaluate(DEEP)
|
await page.evaluate(DEEP)
|
||||||
const state = await page.evaluate(() => ({
|
const state = await page.evaluate(() => ({
|
||||||
present: !!document.querySelector('plugin-studio'),
|
present: !!document.querySelector('plugin-studio'),
|
||||||
tabs: window.__q('.vtl-tab').length,
|
tabs: window.__q('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)').length,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// ── assertion 2: the element mounts AND its tag is defined ───────────────────
|
// ── assertion 2: the element mounts AND its tag is defined ───────────────────
|
||||||
|
|
@ -148,6 +148,10 @@ if (state.tabs === 0) {
|
||||||
// and renders nothing scored identically to one rendering 1,397 nodes. That is the precise
|
// and renders nothing scored identically to one rendering 1,397 nodes. That is the precise
|
||||||
// "mounts is not works" trap this file's own header warns about, encoded into the check anyway.
|
// "mounts is not works" trap this file's own header warns about, encoded into the check anyway.
|
||||||
// The fix is to walk INTO the mounted element (through its shadow root) and require real depth.
|
// The fix is to walk INTO the mounted element (through its shadow root) and require real depth.
|
||||||
|
// ★ "TABS" INCLUDES THE STUDIO'S TREE ROWS (2026-09-24). plugin-studio's rail is an Explorer tree
|
||||||
|
// (src/StudioTree.vue), not VerticalTabsLayout tabs, so every selector here matches BOTH shapes:
|
||||||
|
// '.vtl-tab' (nested shells like doxa-maps-spa) and the tree's treeitems minus section headers (a
|
||||||
|
// section header only folds; it opens no plugin, so sampling one would report a false "no content").
|
||||||
const SAMPLE_TABS = Number(process.env.SMOKE_TABS || 4)
|
const SAMPLE_TABS = Number(process.env.SMOKE_TABS || 4)
|
||||||
const MIN_DEPTH = Number(process.env.SMOKE_MIN_DEPTH || 5)
|
const MIN_DEPTH = Number(process.env.SMOKE_MIN_DEPTH || 5)
|
||||||
|
|
||||||
|
|
@ -161,7 +165,7 @@ for (let i = 0; i < Math.min(SAMPLE_TABS, state.tabs); i++) {
|
||||||
const results = []
|
const results = []
|
||||||
for (const i of idxs) {
|
for (const i of idxs) {
|
||||||
await page.evaluate(DEEP)
|
await page.evaluate(DEEP)
|
||||||
await page.evaluate((n) => { const t = window.__q('.vtl-tab')[n]; if (t) t.click() }, i)
|
await page.evaluate((n) => { const t = window.__q('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')[n]; if (t) t.click() }, i)
|
||||||
// POLL until the pane has real content rather than sleeping a fixed amount. A fixed wait forces an
|
// POLL until the pane has real content rather than sleeping a fixed amount. A fixed wait forces an
|
||||||
// ugly trade: long enough for the slowest tab makes the gate too slow to run on every edit, and
|
// ugly trade: long enough for the slowest tab makes the gate too slow to run on every edit, and
|
||||||
// short enough to be fast marks a slow-mounting tab BROKEN. That false positive is the worse
|
// short enough to be fast marks a slow-mounting tab BROKEN. That false positive is the worse
|
||||||
|
|
@ -183,7 +187,7 @@ for (const i of idxs) {
|
||||||
}
|
}
|
||||||
await page.evaluate(DEEP)
|
await page.evaluate(DEEP)
|
||||||
results.push(await page.evaluate((n) => {
|
results.push(await page.evaluate((n) => {
|
||||||
const tab = window.__q('.vtl-tab')[n]
|
const tab = window.__q('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')[n]
|
||||||
const label = (tab && tab.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24)
|
const label = (tab && tab.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 24)
|
||||||
const p = window.__q('.ps-pane').find(x => !x.hasAttribute('hidden'))
|
const p = window.__q('.ps-pane').find(x => !x.hasAttribute('hidden'))
|
||||||
if (!p) return { label, none: true }
|
if (!p) return { label, none: true }
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ const run = async () => {
|
||||||
// query returns 0 — which reads as "no bug" if you stop here. This is the step both previous
|
// query returns 0 — which reads as "no bug" if you stop here. This is the step both previous
|
||||||
// investigations skipped.
|
// investigations skipped.
|
||||||
const tab = await page.evaluate(`(() => { ${WALK}
|
const tab = await page.evaluate(`(() => { ${WALK}
|
||||||
for (const t of __walk('.vtl-tab')) {
|
for (const t of __walk('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')) {
|
||||||
if ((t.textContent || '').toLowerCase().includes('dashboard-plus-grid')) { t.click(); return t.textContent.trim() }
|
if ((t.textContent || '').toLowerCase().includes('dashboard-plus-grid')) { t.click(); return t.textContent.trim() }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
@ -194,7 +194,7 @@ const run = async () => {
|
||||||
await page2.goto(BASE + SHELL, { waitUntil: 'load' })
|
await page2.goto(BASE + SHELL, { waitUntil: 'load' })
|
||||||
await page2.waitForTimeout(6000)
|
await page2.waitForTimeout(6000)
|
||||||
const mapTab = await page2.evaluate(`(() => { ${WALK}
|
const mapTab = await page2.evaluate(`(() => { ${WALK}
|
||||||
for (const t of __walk('.vtl-tab')) {
|
for (const t of __walk('.vtl-tab, .st-row[role=treeitem]:not(.st-sec)')) {
|
||||||
if ((t.textContent || '').toLowerCase().includes('doxa-research-map')) { t.click(); return t.textContent.trim() }
|
if ((t.textContent || '').toLowerCase().includes('doxa-research-map')) { t.click(); return t.textContent.trim() }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,6 @@
|
||||||
(and optional `open:false`) renders a COLLAPSIBLE section (click the heading — chevron rotates); a
|
(and optional `open:false`) renders a COLLAPSIBLE section (click the heading — chevron rotates); a
|
||||||
plain group is an always-open labelled section (the "Subscriptions" style). A folder heading also
|
plain group is an always-open labelled section (the "Subscriptions" style). A folder heading also
|
||||||
shows the group's `badge` + `count` when given.
|
shows the group's `badge` + `count` when given.
|
||||||
Two optional row fields let a rail show a TREE whose nodes repeat elsewhere:
|
|
||||||
depth : indent level (0/omitted = flush) — draws the row nested under the row above it
|
|
||||||
selects : the `key` of the canonical row this one stands for. An ALIAS row has its own unique
|
|
||||||
`key` (for v-for) but clicking it selects `selects`, it highlights whenever that key is
|
|
||||||
active, and it is skipped by keyboard nav so arrow keys visit each canonical row once.
|
|
||||||
branch : an id that makes this row FOLDABLE — a chevron beside it shows/hides the rows below.
|
|
||||||
Closed by default; picking the row also opens it.
|
|
||||||
ancestors : the `branch` ids above this row; it shows only while ALL of them are open.
|
|
||||||
Selection is controlled via v-model:active (the active item's `key`). The host owns pane content
|
Selection is controlled via v-model:active (the active item's `key`). The host owns pane content
|
||||||
and mount logic — this component only paints the SHELL (top bar + rail + a content frame) and
|
and mount logic — this component only paints the SHELL (top bar + rail + a content frame) and
|
||||||
exposes slots so each app keeps its unique chrome + its own mount machinery:
|
exposes slots so each app keeps its unique chrome + its own mount machinery:
|
||||||
|
|
@ -39,6 +31,9 @@
|
||||||
#meta — right side of the top bar (e.g. a count, a language switcher)
|
#meta — right side of the top bar (e.g. a count, a language switcher)
|
||||||
#toolbar — a full-width strip under the top bar, above the content (e.g. an action bar)
|
#toolbar — a full-width strip under the top bar, above the content (e.g. an action bar)
|
||||||
#default — the content pane(s); the host renders its own panes and toggles them on `active`
|
#default — the content pane(s); the host renders its own panes and toggles them on `active`
|
||||||
|
#rail — (optional) replaces the rail body below the collapse toggle, for a host whose
|
||||||
|
navigation is not a list of tabs. Slot props: collapsed (desktop slim state), expand(),
|
||||||
|
pick(key). plugin-studio uses it for its Explorer tree.
|
||||||
|
|
||||||
★ NESTED-ELEMENT HOSTING (opt-in, LAZY). A row/item may instead ask THIS layout to host a nested
|
★ NESTED-ELEMENT HOSTING (opt-in, LAZY). A row/item may instead ask THIS layout to host a nested
|
||||||
custom element for its tab — so a shell can compose other element-plugins as its tabs. Add to a
|
custom element for its tab — so a shell can compose other element-plugins as its tabs. Add to a
|
||||||
|
|
@ -128,7 +123,8 @@
|
||||||
an off-canvas drawer parked at translateX(-100%) and slid in by the `nav-open` class. Because it
|
an off-canvas drawer parked at translateX(-100%) and slid in by the `nav-open` class. Because it
|
||||||
is never display:none, a mobile-closed drawer can no longer strand the rail after a resize back
|
is never display:none, a mobile-closed drawer can no longer strand the rail after a resize back
|
||||||
to desktop (the old cross-breakpoint bug). `aria-hidden` mirrors real visibility for a11y. -->
|
to desktop (the old cross-breakpoint bug). `aria-hidden` mirrors real visibility for a11y. -->
|
||||||
<nav class="vtl-nav" :aria-label="navLabel" :aria-hidden="String(!railShown)" role="tablist" aria-orientation="vertical">
|
<nav class="vtl-nav" :aria-label="navLabel" :aria-hidden="String(!railShown)"
|
||||||
|
:role="$slots.rail ? null : 'tablist'" :aria-orientation="$slots.rail ? null : 'vertical'">
|
||||||
<!-- Rail toggle: always visible, collapses the rail (slim icon rail on desktop; off-canvas
|
<!-- Rail toggle: always visible, collapses the rail (slim icon rail on desktop; off-canvas
|
||||||
drawer on mobile). The chevron points the way the click will move the rail. -->
|
drawer on mobile). The chevron points the way the click will move the rail. -->
|
||||||
<button
|
<button
|
||||||
|
|
@ -140,8 +136,13 @@
|
||||||
<span class="vtl-collapse-tx">Collapse</span>
|
<span class="vtl-collapse-tx">Collapse</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- #rail: a host that needs MORE than tabs (e.g. plugin-studio's tree) supplies the whole rail
|
||||||
|
body. The layout keeps the frame — collapse toggle, drawer, slim strip — and hands the slot
|
||||||
|
what it needs to cooperate with it. The nav drops role=tablist, since the slot brings its
|
||||||
|
own role (a tree is not a tablist). -->
|
||||||
|
<slot v-if="$slots.rail" name="rail" :collapsed="railCollapsed && !narrow" :expand="expandRail" :pick="pick" />
|
||||||
<!-- GROUPED shape: labelled sections (always-open) or collapsible folders. -->
|
<!-- GROUPED shape: labelled sections (always-open) or collapsible folders. -->
|
||||||
<template v-if="groups && groups.length">
|
<template v-else-if="groups && groups.length">
|
||||||
<div class="vtl-grp" v-for="grp in groups" :key="grp.key">
|
<div class="vtl-grp" v-for="grp in groups" :key="grp.key">
|
||||||
<!-- FOLDER heading = a button that toggles open/closed (chevron rotates). -->
|
<!-- FOLDER heading = a button that toggles open/closed (chevron rotates). -->
|
||||||
<button
|
<button
|
||||||
|
|
@ -164,24 +165,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="vtl-rows" v-show="!grp.folder || !isFolderClosed(grp)">
|
<div class="vtl-rows" v-show="!grp.folder || !isFolderClosed(grp)">
|
||||||
<div class="vtl-rowwrap" role="presentation" v-for="row in grp.rows" :key="row.key"
|
|
||||||
v-show="rowShown(row)">
|
|
||||||
<!-- BRANCH toggle: folds this row's sub-rows (see `branch` / `ancestors`). A sibling of the
|
|
||||||
tab, never inside it — a button may not contain a button. -->
|
|
||||||
<button v-if="row.branch" type="button" class="vtl-twisty"
|
|
||||||
:aria-expanded="String(isBranchOpen(row.branch))"
|
|
||||||
:aria-label="(isBranchOpen(row.branch) ? 'Hide' : 'Show') + ' what ' + row.label + ' is made of'"
|
|
||||||
@click="toggleBranch(row.branch)">
|
|
||||||
<span class="vtl-chev" :class="{ open: isBranchOpen(row.branch) }" aria-hidden="true" v-html="iconSvg('chevron')"></span>
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button" role="tab"
|
v-for="row in grp.rows" :key="row.key" type="button" role="tab"
|
||||||
class="vtl-tab" :class="{ active: target(row) === active, alias: !!row.selects, 'has-branch': !!row.branch }"
|
class="vtl-tab" :class="{ active: row.key === active }"
|
||||||
:data-depth="row.depth || null" :style="row.depth ? { '--vtl-depth': row.depth } : null"
|
:aria-selected="String(row.key === active)"
|
||||||
:aria-selected="String(target(row) === active)"
|
:tabindex="row.key === active ? 0 : -1"
|
||||||
:tabindex="!row.selects && row.key === active ? 0 : -1"
|
:ref="el => registerTab(row.key, el)"
|
||||||
:ref="el => { if (!row.selects) registerTab(row.key, el) }"
|
@click="pick(row.key)" @keydown="onKey($event)">
|
||||||
@click="pick(target(row), row)" @keydown="onKey($event)">
|
|
||||||
<span class="vtl-tab-ic" aria-hidden="true" v-if="row.icon" v-html="iconSvg(row.icon)"></span>
|
<span class="vtl-tab-ic" aria-hidden="true" v-if="row.icon" v-html="iconSvg(row.icon)"></span>
|
||||||
<span class="vtl-tab-emoji" aria-hidden="true" v-else-if="row.emoji">{{ row.emoji }}</span>
|
<span class="vtl-tab-emoji" aria-hidden="true" v-else-if="row.emoji">{{ row.emoji }}</span>
|
||||||
<!-- Slim-rail fallback (desktop collapsed only): a row with NEITHER icon NOR emoji would
|
<!-- Slim-rail fallback (desktop collapsed only): a row with NEITHER icon NOR emoji would
|
||||||
|
|
@ -193,7 +183,6 @@
|
||||||
<span class="vtl-tab-sub" v-if="row.sub"><span v-html="row.sub"></span></span>
|
<span class="vtl-tab-sub" v-if="row.sub"><span v-html="row.sub"></span></span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -380,13 +369,6 @@ const VTL_SHADOW_CSS = `
|
||||||
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block) and the two copies must stay
|
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block) and the two copies must stay
|
||||||
byte-identical — edit both or neither. */
|
byte-identical — edit both or neither. */
|
||||||
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; min-height:48px; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 12px; cursor:pointer; border-radius:var(--vtl-radius,8px); font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
|
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; min-height:48px; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 12px; cursor:pointer; border-radius:var(--vtl-radius,8px); font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
|
||||||
.vtl-tab[data-depth] { padding-left:calc(12px + var(--vtl-depth) * 16px); }
|
|
||||||
.vtl-tab[data-depth]::before { content:''; flex:0 0 auto; align-self:stretch; width:0; margin-right:-3px; border-left:2px solid var(--vtl-accent); opacity:.45; }
|
|
||||||
.vtl-rowwrap { position:relative; }
|
|
||||||
.vtl-twisty { position:absolute; z-index:1; right:4px; top:50%; transform:translateY(-50%); width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; padding:0; border:0; border-radius:8px; background:none; color:var(--vtl-muted); cursor:pointer; }
|
|
||||||
.vtl-twisty:hover { background:var(--vtl-accented); color:#3b463d; }
|
|
||||||
.vtl-twisty:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
|
||||||
.vtl-tab.has-branch { padding-right:36px; }
|
|
||||||
.vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; }
|
.vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; }
|
||||||
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||||
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
|
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
|
||||||
|
|
@ -481,8 +463,6 @@ const VTL_SHADOW_CSS = `
|
||||||
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
||||||
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
|
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
|
||||||
.vtl.rail-collapsed .vtl-tab-fallback { display:inline-flex; }
|
.vtl.rail-collapsed .vtl-tab-fallback { display:inline-flex; }
|
||||||
.vtl.rail-collapsed .vtl-tab[data-depth]::before { display:none; }
|
|
||||||
.vtl.rail-collapsed .vtl-twisty { display:none; }
|
|
||||||
.vtl.rail-collapsed .vtl-tier { padding-left:196px; }
|
.vtl.rail-collapsed .vtl-tier { padding-left:196px; }
|
||||||
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:none; }
|
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:none; }
|
||||||
.vtl.rail-collapsed .vtl-grp + .vtl-grp { margin-top:6px; padding-top:6px; }
|
.vtl.rail-collapsed .vtl-grp + .vtl-grp { margin-top:6px; padding-top:6px; }
|
||||||
|
|
@ -507,9 +487,6 @@ const VTL_SHADOW_CSS = `
|
||||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
|
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
|
||||||
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
|
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
|
||||||
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
|
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
|
||||||
.vtl.rail-collapsed .vtl-tab[data-depth] { padding-left:calc(12px + var(--vtl-depth) * 16px); }
|
|
||||||
.vtl.rail-collapsed .vtl-tab[data-depth]::before { display:block; }
|
|
||||||
.vtl.rail-collapsed .vtl-twisty { display:inline-flex; }
|
|
||||||
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
|
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
|
||||||
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
|
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
|
||||||
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
|
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
|
||||||
|
|
@ -550,7 +527,7 @@ export default defineComponent({
|
||||||
// tab (so their pane is rendered + element mounted); hostState/hostError track each pane's load.
|
// tab (so their pane is rendered + element mounted); hostState/hostError track each pane's load.
|
||||||
// A row not in activatedKeys is never rendered — the lazy contract (no visit → no cost).
|
// A row not in activatedKeys is never rendered — the lazy contract (no visit → no cost).
|
||||||
return {
|
return {
|
||||||
navOpen: this.initialNavOpen, railCollapsed: this.initialRailCollapsed, _tabEls: {}, folderOverride: {}, branchOpen: {},
|
navOpen: this.initialNavOpen, railCollapsed: this.initialRailCollapsed, _tabEls: {}, folderOverride: {},
|
||||||
// `narrow` mirrors the (max-width:760px) media query as REACTIVE state, kept current by a resize
|
// `narrow` mirrors the (max-width:760px) media query as REACTIVE state, kept current by a resize
|
||||||
// listener. It lets us reconcile the two boolean state machines (navOpen = mobile drawer,
|
// listener. It lets us reconcile the two boolean state machines (navOpen = mobile drawer,
|
||||||
// railCollapsed = desktop slim rail) at the breakpoint and drive rail visibility correctly.
|
// railCollapsed = desktop slim rail) at the breakpoint and drive rail visibility correctly.
|
||||||
|
|
@ -573,9 +550,7 @@ export default defineComponent({
|
||||||
return this.items
|
return this.items
|
||||||
},
|
},
|
||||||
// Flat ordered list of keys — drives keyboard nav.
|
// Flat ordered list of keys — drives keyboard nav.
|
||||||
// An ALIAS row (row.selects set) repeats another row — it is skipped here, so arrow keys walk
|
orderedKeys() { return this.allRows.map(r => r.key) },
|
||||||
// each plugin once instead of stopping on every copy.
|
|
||||||
orderedKeys() { return this.allRows.filter(r => !r.selects).map(r => r.key) },
|
|
||||||
// Rows that ask THIS layout to host a nested custom element (have an `element` tag).
|
// Rows that ask THIS layout to host a nested custom element (have an `element` tag).
|
||||||
elementRows() { return this.allRows.filter(r => r && r.element) },
|
elementRows() { return this.allRows.filter(r => r && r.element) },
|
||||||
// Element rows that have been activated at least once — only these get a rendered/mounted pane
|
// Element rows that have been activated at least once — only these get a rendered/mounted pane
|
||||||
|
|
@ -638,6 +613,9 @@ export default defineComponent({
|
||||||
this.navOpen = true
|
this.navOpen = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Open the desktop rail if it is in its slim state (a #rail host calls this when its slim-strip
|
||||||
|
// icons are clicked: "show me that section" means the full rail). No-op when already open.
|
||||||
|
expandRail() { if (!this.narrow && this.railCollapsed) this.railCollapsed = false },
|
||||||
toggleRail() {
|
toggleRail() {
|
||||||
if (this.narrow) this.navOpen = !this.navOpen
|
if (this.narrow) this.navOpen = !this.navOpen
|
||||||
else this.railCollapsed = !this.railCollapsed
|
else this.railCollapsed = !this.railCollapsed
|
||||||
|
|
@ -709,17 +687,7 @@ export default defineComponent({
|
||||||
const wasOpen = cur != null ? cur : (grp && grp.open === true)
|
const wasOpen = cur != null ? cur : (grp && grp.open === true)
|
||||||
this.folderOverride = { ...this.folderOverride, [key]: !wasOpen }
|
this.folderOverride = { ...this.folderOverride, [key]: !wasOpen }
|
||||||
},
|
},
|
||||||
// The key a row selects: its own, or — for an ALIAS row — the canonical row it points at.
|
pick(key) {
|
||||||
target(row) { return row.selects || row.key },
|
|
||||||
// BRANCHES — a row with `branch` folds the rows whose `ancestors` include it. Closed by default,
|
|
||||||
// so a deep composition tree costs one line until someone asks to see inside it.
|
|
||||||
isBranchOpen(id) { return !!this.branchOpen[id] },
|
|
||||||
toggleBranch(id) { this.branchOpen = { ...this.branchOpen, [id]: !this.branchOpen[id] } },
|
|
||||||
rowShown(row) { return !row.ancestors || row.ancestors.every(id => this.branchOpen[id]) },
|
|
||||||
pick(key, row) {
|
|
||||||
// Choosing a row that owns a branch also OPENS it (never closes it): you asked for that plugin,
|
|
||||||
// so show what it is made of. Only the chevron closes a branch.
|
|
||||||
if (row && row.branch && !this.branchOpen[row.branch]) this.toggleBranch(row.branch)
|
|
||||||
this.$emit('update:active', key)
|
this.$emit('update:active', key)
|
||||||
// On mobile, picking a tab closes the off-canvas drawer. On desktop navOpen must stay true (it gates
|
// On mobile, picking a tab closes the off-canvas drawer. On desktop navOpen must stay true (it gates
|
||||||
// the desktop rail's visibility via railShown), so we only touch it when narrow.
|
// the desktop rail's visibility via railShown), so we only touch it when narrow.
|
||||||
|
|
@ -847,13 +815,6 @@ export default defineComponent({
|
||||||
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block) and the two copies must stay
|
THIS RULE EXISTS TWICE (VTL_SHADOW_CSS and the scoped style block) and the two copies must stay
|
||||||
byte-identical — edit both or neither. */
|
byte-identical — edit both or neither. */
|
||||||
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; min-height:48px; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 12px; cursor:pointer; border-radius:var(--vtl-radius,8px); font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
|
.vtl-tab { display:flex; align-items:center; gap:11px; width:100%; min-height:48px; text-align:left; border:0; background:none; color:var(--vtl-muted); padding:8px 12px; cursor:pointer; border-radius:var(--vtl-radius,8px); font:inherit; margin:0; transition:background .13s ease,color .13s ease; }
|
||||||
.vtl-tab[data-depth] { padding-left:calc(12px + var(--vtl-depth) * 16px); }
|
|
||||||
.vtl-tab[data-depth]::before { content:''; flex:0 0 auto; align-self:stretch; width:0; margin-right:-3px; border-left:2px solid var(--vtl-accent); opacity:.45; }
|
|
||||||
.vtl-rowwrap { position:relative; }
|
|
||||||
.vtl-twisty { position:absolute; z-index:1; right:4px; top:50%; transform:translateY(-50%); width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; padding:0; border:0; border-radius:8px; background:none; color:var(--vtl-muted); cursor:pointer; }
|
|
||||||
.vtl-twisty:hover { background:var(--vtl-accented); color:#3b463d; }
|
|
||||||
.vtl-twisty:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
|
||||||
.vtl-tab.has-branch { padding-right:36px; }
|
|
||||||
.vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; }
|
.vtl-tab:hover { background:var(--vtl-accented); color:#3b463d; }
|
||||||
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
.vtl-tab:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||||
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
|
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
|
||||||
|
|
@ -948,8 +909,6 @@ export default defineComponent({
|
||||||
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
.vtl.rail-collapsed .vtl-tab-text { display:none; }
|
||||||
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
|
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
|
||||||
.vtl.rail-collapsed .vtl-tab-fallback { display:inline-flex; }
|
.vtl.rail-collapsed .vtl-tab-fallback { display:inline-flex; }
|
||||||
.vtl.rail-collapsed .vtl-tab[data-depth]::before { display:none; }
|
|
||||||
.vtl.rail-collapsed .vtl-twisty { display:none; }
|
|
||||||
.vtl.rail-collapsed .vtl-tier { padding-left:196px; }
|
.vtl.rail-collapsed .vtl-tier { padding-left:196px; }
|
||||||
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:none; }
|
.vtl.rail-collapsed .vtl-tl, .vtl.rail-collapsed .vtl-td, .vtl.rail-collapsed .vtl-cap, .vtl.rail-collapsed .vtl-chev { display:none; }
|
||||||
.vtl.rail-collapsed .vtl-grp + .vtl-grp { margin-top:6px; padding-top:6px; }
|
.vtl.rail-collapsed .vtl-grp + .vtl-grp { margin-top:6px; padding-top:6px; }
|
||||||
|
|
@ -974,9 +933,6 @@ export default defineComponent({
|
||||||
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
|
.vtl.rail-collapsed .vtl-collapse-ic { transform:rotate(180deg); }
|
||||||
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
|
.vtl.rail-collapsed .vtl-collapse-tx { display:inline; }
|
||||||
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
|
.vtl.rail-collapsed .vtl-tab { padding-left:10px; gap:11px; }
|
||||||
.vtl.rail-collapsed .vtl-tab[data-depth] { padding-left:calc(12px + var(--vtl-depth) * 16px); }
|
|
||||||
.vtl.rail-collapsed .vtl-tab[data-depth]::before { display:block; }
|
|
||||||
.vtl.rail-collapsed .vtl-twisty { display:inline-flex; }
|
|
||||||
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
|
.vtl.rail-collapsed .vtl-tab-text { display:flex; }
|
||||||
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
|
.vtl.rail-collapsed .vtl-tab-fallback { display:none; }
|
||||||
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
|
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,15 @@
|
||||||
:title="heading"
|
:title="heading"
|
||||||
nav-label="Plugin elements · by capability 5 → 1"
|
nav-label="Plugin elements · by capability 5 → 1"
|
||||||
:header="true"
|
:header="true"
|
||||||
:groups="navGroups"
|
|
||||||
:active="activeKey"
|
:active="activeKey"
|
||||||
@update:active="select">
|
@update:active="select">
|
||||||
|
|
||||||
|
<!-- #rail: the Explorer tree (src/StudioTree.vue) — capability sections, each composite folding
|
||||||
|
to show what it is made of. The layout keeps the rail frame (collapse, drawer, slim strip). -->
|
||||||
|
<template #rail="{ collapsed, expand, pick }">
|
||||||
|
<StudioTree :sections="treeSections" :active="activeKey" :collapsed="collapsed" :expand="expand" :pick="pick" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- #brand: the studio's logo mark (a plug/puzzle glyph — "plugin studio"), left of the title.
|
<!-- #brand: the studio's logo mark (a plug/puzzle glyph — "plugin studio"), left of the title.
|
||||||
Self-drawn SVG, green accent to match the admin shell; no external asset (offline safe). -->
|
Self-drawn SVG, green accent to match the admin shell; no external asset (offline safe). -->
|
||||||
<template #brand>
|
<template #brand>
|
||||||
|
|
@ -152,12 +157,15 @@
|
||||||
import { defineComponent, inject } from 'vue'
|
import { defineComponent, inject } from 'vue'
|
||||||
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||||
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
|
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
|
||||||
|
import StudioTree from './src/StudioTree.vue'
|
||||||
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
|
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage } from '@map/layouts/app-wrapper-material/languages.js'
|
||||||
|
|
||||||
// Only the studio's CONTENT-AREA styles remain here; the shell (top bar + rail + body) is now the
|
// Only the studio's CONTENT-AREA styles remain here; the shell (top bar + rail + body) is now the
|
||||||
// shared VerticalTabsLayout (which self-injects its own CSS). Kept as an injected string so the studio
|
// shared VerticalTabsLayout (which self-injects its own CSS). Kept as an injected string so the studio
|
||||||
// content is styled even when this component is nested as a child element (see useShadowStyles).
|
// content is styled even when this component is nested as a child element (see useShadowStyles).
|
||||||
const PS_SHADOW_CSS = `
|
const PS_SHADOW_CSS = `
|
||||||
|
:host { display:block; position:relative; height:var(--plugin-studio-height, 85vh); min-height:480px; }
|
||||||
|
:host([hidden]) { display:none; }
|
||||||
.ps-logo { display:inline-flex; align-items:center; }
|
.ps-logo { display:inline-flex; align-items:center; }
|
||||||
.ps-logo svg { display:block; }
|
.ps-logo svg { display:block; }
|
||||||
.ps-count { color:#6d766e; }
|
.ps-count { color:#6d766e; }
|
||||||
|
|
@ -247,6 +255,8 @@ const capFor = (t) => (PT[t] != null ? 5 - PT[t] : 1)
|
||||||
// Rail row icon per capability: LOCAL inline SVG names (app-wrapper-material/icons.js), not emoji.
|
// Rail row icon per capability: LOCAL inline SVG names (app-wrapper-material/icons.js), not emoji.
|
||||||
// These were emoji until 2026-09-24, and on a box with no emoji font (measured: this one) every rail
|
// These were emoji until 2026-09-24, and on a box with no emoji font (measured: this one) every rail
|
||||||
// row led with an empty tofu box — the first thing a developer saw on every plugin.
|
// row led with an empty tofu box — the first thing a developer saw on every plugin.
|
||||||
|
// Short tier names for the rail's section headers (the full CAP_LABEL is their tooltip).
|
||||||
|
const CAP_SHORT = { 5: 'App shell', 4: 'Composite', 3: 'Multi-view', 2: 'Preset', 1: 'Single' }
|
||||||
const CAP_ICON = { 5: 'monitor', 4: 'tree', 3: 'layers', 2: 'grid', 1: 'embed' }
|
const CAP_ICON = { 5: 'monitor', 4: 'tree', 3: 'layers', 2: 'grid', 1: 'embed' }
|
||||||
|
|
||||||
// MAP ELEMENTS. These own a live WebGL context, so they are the rows the studio DESTROYS on
|
// MAP ELEMENTS. These own a live WebGL context, so they are the rows the studio DESTROYS on
|
||||||
|
|
@ -342,7 +352,7 @@ function whenTagDefined(tag, ms) {
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'PluginStudio',
|
name: 'PluginStudio',
|
||||||
components: { VerticalTabsLayout },
|
components: { VerticalTabsLayout, StudioTree },
|
||||||
props: {
|
props: {
|
||||||
treeBase: { type: String, default: '' },
|
treeBase: { type: String, default: '' },
|
||||||
profileConfig: { type: [String, Object], default: '' },
|
profileConfig: { type: [String, Object], default: '' },
|
||||||
|
|
@ -409,64 +419,48 @@ export default defineComponent({
|
||||||
.filter(c => byCap[c] && byCap[c].length)
|
.filter(c => byCap[c] && byCap[c].length)
|
||||||
.map(c => ({ cap: c, label: CAP_LABEL[c] || ('capability ' + c), rows: byCap[c] }))
|
.map(c => ({ cap: c, label: CAP_LABEL[c] || ('capability ' + c), rows: byCap[c] }))
|
||||||
},
|
},
|
||||||
// The GROUPED rail model the shared VerticalTabsLayout paints: ONE list, highest capability to
|
// The RAIL MODEL the Explorer tree (src/StudioTree.vue) paints: ONE list, highest capability to
|
||||||
// lowest (Driver, 2026-09-24: "everything in studio organized by highest capability to lowest";
|
// lowest (Driver, 2026-09-24: "everything in studio organized by highest capability to lowest",
|
||||||
// "the composability in the capability sidebar vertical tabs … to understand the interoperability
|
// "the composability in the capability sidebar … to understand the interoperability and the
|
||||||
// and the various elements that go into each plugin").
|
// various elements that go into each plugin", "it needs to be collapsible").
|
||||||
//
|
//
|
||||||
// CAP 5 → CAP 1 sections, collapsible, open by default. Every plugin has exactly ONE canonical
|
// CAP 5 → CAP 1 sections. Every plugin has exactly ONE canonical node (depth 0) in its own tier,
|
||||||
// row, in its own tier. Directly under a COMPOSITE's row sits its composition tree: every element
|
// and a composite's parts hang under it (depth 1+), ordered by capability at each level, folded
|
||||||
// nested into it, indented by depth, ordered by capability at each level. So reading down the
|
// until opened.
|
||||||
// rail never jumps back up the ladder — the tree lives with the plugin it explains.
|
|
||||||
//
|
//
|
||||||
// ★ HOW THE REPEATS STAY ONE IDENTITY. Every tree row is an ALIAS (VerticalTabsLayout `selects`):
|
// ★ HOW THE REPEATS STAY ONE IDENTITY. A part node carries `target` = the canonical row key, so
|
||||||
// people-groups-grid under dashboard-plus-grid selects the SAME canonical row as its own CAP 2
|
// people-groups-grid under dashboard-plus-grid opens the SAME pane as its own CAP 2 entry and
|
||||||
// entry and highlights both, and arrow keys skip aliases. afe6abe removed the first rail folders
|
// highlights both. afe6abe removed the first rail folders because six independent nodes for one
|
||||||
// because six independent nodes for one plugin left no single name to report it by; an alias has
|
// plugin left no single name to report it by; a node that only TARGETS the canonical row has no
|
||||||
// no separate identity, so nesting and the one-name rule coexist. The section count counts
|
// identity of its own, so nesting and the one-name rule coexist.
|
||||||
// canonical plugins only.
|
treeSections() {
|
||||||
navGroups() {
|
|
||||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
||||||
const treeOf = new Map(this.compositionTrees.map(t => [t.nodes[0].selects, t]))
|
const treeOf = new Map(this.compositionTrees.map(t => [t.nodes[0].selects, t]))
|
||||||
// ★ FOLDED BY DEFAULT (Driver, 2026-09-24: "it needs to be collapsible, otherwise it is too
|
return this.groups.map(g => {
|
||||||
// hard to know how to navigate so many nested plugins"). Every node with parts is a BRANCH with
|
const nodes = []
|
||||||
// its own chevron, closed until opened — so the rail first reads as a plain list of plugins, and
|
for (const r of g.rows) {
|
||||||
// you drill into one composite, then one level of it, at a time. Picking a composite opens it.
|
const t = treeOf.get(r.key)
|
||||||
// Branch ids are namespaced by the composite's row key: the same child (dashboard-plus-grid)
|
nodes.push({
|
||||||
// folds independently under doxa-maps-spa and in its own CAP 4 entry.
|
id: r.key, parent: '', depth: 0, label: r.label, desc: r.tag, icon: r.icon, target: r.key,
|
||||||
const parts = (n) => n + (n === 1 ? ' part' : ' parts')
|
kids: t ? t.nodes[0].kids : 0, title: r.label + ' · <' + r.tag + '> · CAP ' + r.cap + ' ' + (CAP_LABEL[r.cap] || '')
|
||||||
const rowsFor = (r) => {
|
})
|
||||||
const t = treeOf.get(r.key)
|
if (!t) continue
|
||||||
const bid = (id) => 'b:' + r.key + ':' + id
|
const nid = (id) => (id === 0 ? r.key : r.key + '>' + id)
|
||||||
const out = [{
|
for (const n of t.nodes.slice(1)) {
|
||||||
key: r.key, label: r.label, icon: r.icon,
|
nodes.push({
|
||||||
branch: t ? bid(0) : undefined,
|
id: nid(n.id), parent: nid(n.ancestors[n.ancestors.length - 1]), depth: n.depth,
|
||||||
sub: '<code><' + esc(r.tag) + '></code>' + (t ? ' · ' + parts(t.nodes[0].kids) : '')
|
label: n.label, part: !!n.part, target: n.selects || '',
|
||||||
}]
|
icon: n.part ? 'dots' : (CAP_ICON[n.cap] || 'embed'),
|
||||||
if (t) {
|
desc: n.part ? 'no standalone page' : 'cap ' + n.cap + ' · ' + n.tag,
|
||||||
t.nodes.slice(1).forEach((n) => out.push({
|
kids: n.kids || 0,
|
||||||
key: 'tree-' + r.key + '-' + n.id,
|
title: n.part ? n.label + ' · part of ' + r.label + ', not published on its own'
|
||||||
selects: n.selects || ('none:' + r.key),
|
: n.label + ' · <' + n.tag + '> · CAP ' + n.cap + ' ' + (CAP_LABEL[n.cap] || '')
|
||||||
depth: n.depth,
|
})
|
||||||
label: n.label,
|
}
|
||||||
badge: n.part ? 'part' : '',
|
|
||||||
branch: n.kids ? bid(n.id) : undefined,
|
|
||||||
ancestors: n.ancestors.map(bid),
|
|
||||||
sub: n.part ? 'no standalone page'
|
|
||||||
: 'CAP ' + n.cap + ' · <code><' + esc(n.tag) + '></code>' + (n.kids ? ' · ' + parts(n.kids) : '')
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
return out
|
// Short section names: the header shares a 256px rail with its CAP badge and count, and the
|
||||||
}
|
// full "App shell · nested-mfe-spa" truncated to "APP SHELL · NEST…". The long name is the tooltip.
|
||||||
return this.groups.map(g => ({
|
return { id: 'cap-' + g.cap, cap: g.cap, label: CAP_SHORT[g.cap] || g.label, title: g.label, icon: CAP_ICON[g.cap] || 'embed', nodes }
|
||||||
key: 'cap-' + g.cap,
|
})
|
||||||
badge: 'CAP ' + g.cap,
|
|
||||||
label: g.label,
|
|
||||||
count: g.rows.length,
|
|
||||||
folder: true,
|
|
||||||
open: true,
|
|
||||||
rows: g.rows.flatMap(rowsFor)
|
|
||||||
}))
|
|
||||||
},
|
},
|
||||||
// ★ ONE TREE PER COMPOSITE, built ENTIRELY from the manifest's `composes` edges — no bundle names
|
// ★ ONE TREE PER COMPOSITE, built ENTIRELY from the manifest's `composes` edges — no bundle names
|
||||||
// or child lists live in this file, so a new composite appears here with zero edits.
|
// or child lists live in this file, so a new composite appears here with zero edits.
|
||||||
|
|
@ -797,6 +791,13 @@ export default defineComponent({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* ★ THE HOST HAS A HEIGHT OF ITS OWN (same rule as doxa-maps-spa). A custom element is display:inline
|
||||||
|
by default and the shell inside it is absolutely positioned, so pasting the published embed snippet
|
||||||
|
into a page rendered a fully mounted studio 0px tall — measured 2026-09-24. A default, not a lock:
|
||||||
|
the page's own style="height:…" or --plugin-studio-height wins, which is how the leaf page fills
|
||||||
|
the viewport. Mirrored in PS_SHADOW_CSS above. */
|
||||||
|
:host { display: block; position: relative; height: var(--plugin-studio-height, 85vh); min-height: 480px; }
|
||||||
|
:host([hidden]) { display: none; }
|
||||||
/* The shell (top bar + rail + body) is the shared VerticalTabsLayout. Only the studio's own
|
/* The shell (top bar + rail + body) is the shared VerticalTabsLayout. Only the studio's own
|
||||||
content-area styles remain here; mirror of PS_SHADOW_CSS above. Keep the two in sync.
|
content-area styles remain here; mirror of PS_SHADOW_CSS above. Keep the two in sync.
|
||||||
Reskinned to the dx-life admin tokens (neutral surfaces, green #92b195 accent) to match the shell. */
|
Reskinned to the dx-life admin tokens (neutral surfaces, green #92b195 accent) to match the shell. */
|
||||||
|
|
|
||||||
373
plugin-designs/plugin-studio/src/StudioTree.vue
Normal file
373
plugin-designs/plugin-studio/src/StudioTree.vue
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
<!--
|
||||||
|
StudioTree.vue — the plugin studio's rail: an EXPLORER TREE, imitating VS Code's Explorer.
|
||||||
|
|
||||||
|
★ WHY IMITATE, NOT INVENT (Kingdom-agent directive, 2026-09-24): the studio nests every plugin, and
|
||||||
|
composites nest composites — a hierarchy too deep and too repetitive to hand-roll as tabs. The
|
||||||
|
leader for exactly this problem is the VS Code Explorer (researched against VS Code's views UX
|
||||||
|
guidelines, its tree settings, Storybook's sidebar and the WAI-ARIA APG Tree View pattern; notes in
|
||||||
|
_qa-cycle/studio-organize/RESEARCH-STUDIO-VERTICAL-TABS-IMITATE-THE-LEADER.md). What is copied:
|
||||||
|
- collapsible SECTIONS with sticky uppercase headers (VS Code views) → CAP 5 … CAP 1
|
||||||
|
- twisty on the LEFT, space reserved on leaves so labels line up
|
||||||
|
- one-line rows: label + a muted DESCRIPTION → the element tag
|
||||||
|
- indent guides, always on (renderIndentGuides "always")
|
||||||
|
- a FILTER box on top (VS Code tree find, Storybook search): matches plus their parents
|
||||||
|
- one view action only — Collapse all ("be careful not to add too many actions")
|
||||||
|
- AUTO-REVEAL: opening a plugin by click, URL hash or restored tab expands and scrolls to it
|
||||||
|
- the ACTIVITY BAR when the rail is slim: one icon per section, click → rail opens at it
|
||||||
|
- APG keyboard: ↓/↑ move, → open / first child, ← close / parent, Home/End, Enter activates,
|
||||||
|
type a letter to jump
|
||||||
|
Composites start FOLDED (Driver: "too hard to navigate so many nested plugins"); the open/closed
|
||||||
|
state is remembered per page, as VS Code remembers expansion.
|
||||||
|
|
||||||
|
★ ONE TREE, SECTIONS AS LEVEL-1 NODES. VS Code's section headers are separate pane buttons; here they
|
||||||
|
are treeitems at aria-level 1 so arrow keys walk the whole rail in one pass instead of stopping at
|
||||||
|
every section border. Plugins are level 2+, a composite's parts one level deeper each.
|
||||||
|
|
||||||
|
★ A REPEATED CHILD IS NOT A SECOND PLUGIN. A part row (people-groups-grid under dashboard-plus-grid)
|
||||||
|
TARGETS the one canonical row: choosing it opens the same pane and highlights every copy. That is
|
||||||
|
what keeps "name the one element that is broken" possible with repeats on screen.
|
||||||
|
|
||||||
|
Rendered as a CHILD of the <plugin-studio> custom element, inside its shadow root, through
|
||||||
|
VerticalTabsLayout's #rail slot. Child-SFC <style> is dropped by Vue in a custom element, so the CSS
|
||||||
|
is injected with useShadowStyles; the <style> block below is the same CSS for dev — keep in sync.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<!-- SLIM RAIL = the activity bar: one icon per capability section in the strip that stays visible. -->
|
||||||
|
<div v-if="collapsed" class="st-activity">
|
||||||
|
<button v-for="s in sections" :key="s.id" type="button" class="st-act"
|
||||||
|
:class="{ active: s.id === activeSectionId }"
|
||||||
|
:aria-label="'Open CAP ' + s.cap + ', ' + s.label"
|
||||||
|
@click="openSectionFromActivity(s.id)">
|
||||||
|
<span class="st-act-ic" aria-hidden="true" v-html="svg(s.icon)"></span>
|
||||||
|
<span class="st-act-n" aria-hidden="true">{{ s.cap }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="st">
|
||||||
|
<div class="st-bar">
|
||||||
|
<label class="st-find">
|
||||||
|
<span class="st-find-ic" aria-hidden="true" v-html="svg('search')"></span>
|
||||||
|
<input ref="find" type="search" class="st-find-in" v-model="query" placeholder="Filter plugins"
|
||||||
|
aria-label="Filter plugins" @keydown="onFindKey" />
|
||||||
|
</label>
|
||||||
|
<button type="button" class="st-tool" aria-label="Collapse all" title="Collapse all" @click="collapseAll">
|
||||||
|
<span aria-hidden="true" v-html="collapseAllSvg"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="st-tree" role="tree" aria-label="Plugins by capability" @keydown="onTreeKey">
|
||||||
|
<div v-for="item in visible" :key="item.id"
|
||||||
|
:ref="el => setRow(item.id, el)"
|
||||||
|
role="treeitem"
|
||||||
|
:aria-level="item.level"
|
||||||
|
:aria-expanded="item.expandable ? String(isOpen(item)) : null"
|
||||||
|
:aria-selected="item.section ? null : String(item.target === active)"
|
||||||
|
:tabindex="item.id === focusId ? 0 : -1"
|
||||||
|
:class="rowClass(item)"
|
||||||
|
:style="{ '--st-depth': item.depth }"
|
||||||
|
:title="item.title || null"
|
||||||
|
@click="onRowClick(item)"
|
||||||
|
@focus="focusId = item.id">
|
||||||
|
<template v-if="item.section">
|
||||||
|
<span class="st-twisty" aria-hidden="true" v-html="chev"></span>
|
||||||
|
<span class="st-cap">CAP {{ item.cap }}</span>
|
||||||
|
<span class="st-sec-label">{{ item.label }}</span>
|
||||||
|
<span class="st-count">{{ item.count }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span v-for="g in item.depth" :key="g" class="st-guide" :style="{ '--st-g': g - 1 }" aria-hidden="true"></span>
|
||||||
|
<span v-if="item.expandable" class="st-twisty" aria-hidden="true" v-html="chev" @click.stop="toggle(item)"></span>
|
||||||
|
<span v-else class="st-twisty-sp" aria-hidden="true"></span>
|
||||||
|
<span class="st-ic" aria-hidden="true" v-html="svg(item.icon)"></span>
|
||||||
|
<span class="st-label">{{ item.label }}</span>
|
||||||
|
<span class="st-part" v-if="item.part">part</span>
|
||||||
|
<span class="st-desc">{{ item.desc }}</span>
|
||||||
|
<span class="st-count" v-if="item.expandable">{{ item.kids }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p class="st-empty" v-if="query && !visible.length">No plugin matches “{{ query }}”.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
|
||||||
|
import { icon as localIcon } from '@map/layouts/app-wrapper-material/icons.js'
|
||||||
|
|
||||||
|
const ST_CSS = '' +
|
||||||
|
'.st { display:flex; flex-direction:column; margin:0 -12px; }' +
|
||||||
|
'.st-bar { position:sticky; top:0; z-index:3; display:flex; align-items:center; gap:6px; padding:8px 12px 8px; background:#ffffff; }' +
|
||||||
|
'.st-find { flex:1 1 auto; min-width:0; display:flex; align-items:center; gap:6px; height:30px; padding:0 8px; border:1px solid #d1d4cd; border-radius:8px; background:#f3f3f1; color:#6d766e; }' +
|
||||||
|
'.st-find:focus-within { border-color:#92b195; background:#ffffff; }' +
|
||||||
|
'.st-find-ic { display:inline-flex; } .st-find-ic svg { width:14px; height:14px; }' +
|
||||||
|
'.st-find-in { flex:1 1 auto; min-width:0; border:0; outline:0; background:none; font:inherit; font-size:13px; color:#3b463d; }' +
|
||||||
|
'.st-tool { flex:0 0 auto; width:30px; height:30px; display:inline-flex; align-items:center; justify-content:center; padding:0; border:0; border-radius:8px; background:none; color:#6d766e; cursor:pointer; }' +
|
||||||
|
'.st-tool:hover { background:#f3f3f1; color:#3b463d; } .st-tool:focus-visible { outline:2px solid #92b195; outline-offset:-2px; }' +
|
||||||
|
'.st-tool svg { width:16px; height:16px; }' +
|
||||||
|
'.st-tree { display:flex; flex-direction:column; padding:0 0 8px; outline:0; }' +
|
||||||
|
'.st-row { position:relative; display:flex; align-items:center; gap:6px; height:28px; box-sizing:border-box; padding:0 10px 0 calc(8px + var(--st-depth) * 14px); font-size:13px; color:#3b463d; cursor:pointer; white-space:nowrap; user-select:none; outline:0; }' +
|
||||||
|
'.st-row:hover { background:#f3f3f1; }' +
|
||||||
|
'.st-row:focus-visible { box-shadow:inset 0 0 0 1px #92b195; }' +
|
||||||
|
'.st-row.active { background:rgba(146,177,149,.24); }' +
|
||||||
|
'.st-row.active::after { content:""; position:absolute; left:0; top:4px; bottom:4px; width:3px; border-radius:0 3px 3px 0; background:#92b195; }' +
|
||||||
|
'.st-row.alias.active { background:rgba(146,177,149,.12); }' +
|
||||||
|
'.st-sec { position:sticky; top:46px; z-index:2; height:30px; margin-top:6px; background:#ffffff; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:#6d766e; border-top:1px solid #eceee9; }' +
|
||||||
|
'.st-sec:first-child { margin-top:0; border-top:0; }' +
|
||||||
|
'.st-sec:hover { background:#ffffff; color:#3b463d; }' +
|
||||||
|
'.st-cap { flex:0 0 auto; font:700 10px/1 system-ui,sans-serif; letter-spacing:.3px; padding:3px 6px; border-radius:999px; background:#92b195; color:#1d2a1f; }' +
|
||||||
|
'.st-sec-label { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }' +
|
||||||
|
'.st-twisty, .st-twisty-sp { flex:0 0 16px; width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; color:#6d766e; }' +
|
||||||
|
'.st-twisty svg { width:14px; height:14px; transition:transform .12s ease; }' +
|
||||||
|
'.st-row[aria-expanded="true"] > .st-twisty svg { transform:rotate(90deg); }' +
|
||||||
|
'.st-ic { flex:0 0 16px; display:inline-flex; color:#6d766e; } .st-ic svg { width:16px; height:16px; }' +
|
||||||
|
'.st-row.active .st-ic { color:#5f8a63; }' +
|
||||||
|
'.st-label { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }' +
|
||||||
|
// flex-basis 0 on the DESCRIPTION: it only gets what the name leaves over, so the name is never
|
||||||
|
// ellipsised to make room for the tag (VS Code truncates the description first, too).
|
||||||
|
'.st-desc { flex:1 1 0; min-width:0; overflow:hidden; text-overflow:ellipsis; font:11px/1 ui-monospace,Menlo,monospace; color:#8a928b; }' +
|
||||||
|
'.st-part { flex:0 0 auto; font:600 9px/1 system-ui,sans-serif; text-transform:uppercase; letter-spacing:.3px; padding:2px 4px; border:1px solid #92b195; border-radius:4px; color:#5f8a63; }' +
|
||||||
|
'.st-count { flex:0 0 auto; margin-left:auto; min-width:18px; text-align:right; font-size:11px; font-weight:500; letter-spacing:0; color:#6d766e; }' +
|
||||||
|
'.st-guide { position:absolute; top:0; bottom:0; left:calc(15px + var(--st-g) * 14px); border-left:1px solid #dfe2db; pointer-events:none; }' +
|
||||||
|
'.st-row:hover > .st-guide { border-left-color:#c4c8c0; }' +
|
||||||
|
'.st-empty { margin:12px; font-size:13px; color:#6d766e; }' +
|
||||||
|
'.st-activity { display:flex; flex-direction:column; gap:4px; }' +
|
||||||
|
'.st-act { position:relative; display:flex; align-items:center; justify-content:center; width:48px; height:44px; margin-left:172px; padding:0; border:0; border-radius:10px; background:none; color:#6d766e; cursor:pointer; }' +
|
||||||
|
'.st-act:hover, .st-act.active { background:#f3f3f1; color:#3b463d; }' +
|
||||||
|
'.st-act:focus-visible { outline:2px solid #92b195; outline-offset:-2px; }' +
|
||||||
|
'.st-act-ic { display:inline-flex; } .st-act-ic svg { width:20px; height:20px; }' +
|
||||||
|
'.st-act-n { position:absolute; right:6px; bottom:5px; font:700 9px/1 system-ui,sans-serif; color:#5f8a63; }'
|
||||||
|
|
||||||
|
const COLLAPSE_ALL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="3"/><line x1="8.5" y1="12" x2="15.5" y2="12"/></svg>'
|
||||||
|
const CHEV = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>'
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'StudioTree',
|
||||||
|
props: {
|
||||||
|
// [{ id, cap, label, icon, nodes: [{ id, parent, depth, label, desc, icon, target, kids, part, title }] }]
|
||||||
|
// nodes are in display order (a parent before its children), depth 0 = a plugin in its section.
|
||||||
|
sections: { type: Array, default: () => [] },
|
||||||
|
active: { type: String, default: '' },
|
||||||
|
collapsed: { type: Boolean, default: false },
|
||||||
|
pick: { type: Function, default: null },
|
||||||
|
expand: { type: Function, default: null }
|
||||||
|
},
|
||||||
|
emits: ['select'],
|
||||||
|
setup() { useShadowStyles(ST_CSS, 'studio-tree') },
|
||||||
|
data() {
|
||||||
|
const saved = this.readState()
|
||||||
|
return {
|
||||||
|
query: '',
|
||||||
|
focusId: '',
|
||||||
|
// Sections default OPEN, nodes default CLOSED; only deviations are stored.
|
||||||
|
secClosed: saved.sec || {},
|
||||||
|
nodeOpen: saved.node || {},
|
||||||
|
chev: CHEV,
|
||||||
|
collapseAllSvg: COLLAPSE_ALL
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
byId() {
|
||||||
|
const m = new Map()
|
||||||
|
for (const s of this.sections) for (const n of s.nodes) m.set(n.id, n)
|
||||||
|
return m
|
||||||
|
},
|
||||||
|
q() { return this.query.trim().toLowerCase() },
|
||||||
|
// Filter mode (VS Code tree find): a node stays if it matches, or if something under it matches —
|
||||||
|
// so a match is always shown WITH the path to it. Everything is expanded while filtering.
|
||||||
|
kept() {
|
||||||
|
if (!this.q) return null
|
||||||
|
const keep = new Set()
|
||||||
|
for (const s of this.sections) {
|
||||||
|
for (const n of s.nodes) {
|
||||||
|
const hit = n.label.toLowerCase().includes(this.q) || String(n.desc || '').toLowerCase().includes(this.q)
|
||||||
|
if (!hit) continue
|
||||||
|
keep.add(n.id)
|
||||||
|
let p = n.parent
|
||||||
|
while (p) { keep.add(p); p = this.byId.get(p) && this.byId.get(p).parent }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keep
|
||||||
|
},
|
||||||
|
// The flat, ordered list of rows actually on screen — what arrow keys walk.
|
||||||
|
visible() {
|
||||||
|
const out = []
|
||||||
|
for (const s of this.sections) {
|
||||||
|
const nodes = this.kept ? s.nodes.filter(n => this.kept.has(n.id)) : s.nodes
|
||||||
|
if (this.kept && !nodes.length) continue
|
||||||
|
out.push({
|
||||||
|
id: 'sec:' + s.id, section: true, sectionId: s.id, level: 1, depth: 0, expandable: true,
|
||||||
|
cap: s.cap, label: s.label, title: s.title || '', count: s.nodes.filter(n => n.depth === 0).length
|
||||||
|
})
|
||||||
|
if (!this.kept && this.secClosed[s.id]) continue
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (!this.kept && !this.ancestorsOpen(n)) continue
|
||||||
|
out.push({ ...n, sectionId: s.id, level: n.depth + 2, expandable: !!n.kids, alias: n.depth > 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
activeSectionId() {
|
||||||
|
for (const s of this.sections) if (s.nodes.some(n => n.depth === 0 && n.target === this.active)) return s.id
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
// AUTO-REVEAL (explorer.autoReveal): whenever the active plugin changes — a click, a URL hash, a
|
||||||
|
// restored last tab — make its canonical row visible and scroll it into view.
|
||||||
|
active: { handler() { this.reveal() }, immediate: true },
|
||||||
|
sections() { this.reveal() },
|
||||||
|
query(v) { if (v && this.visible.length) this.focusId = this.visible[0].id }
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
svg(name) { return name ? localIcon(name) : '' },
|
||||||
|
storageKey() { return 'dx-studio-tree:' + ((typeof location !== 'undefined' && location.pathname) || '') },
|
||||||
|
// Guarded: storage THROWS in private mode / under policy, and a throw here would blank the studio.
|
||||||
|
readState() { try { return JSON.parse(window.localStorage.getItem(this.storageKey()) || '{}') || {} } catch (_) { return {} } },
|
||||||
|
saveState() { try { window.localStorage.setItem(this.storageKey(), JSON.stringify({ sec: this.secClosed, node: this.nodeOpen })) } catch (_) { /* storage unavailable */ } },
|
||||||
|
ancestorsOpen(n) {
|
||||||
|
let p = n.parent
|
||||||
|
while (p) { if (!this.nodeOpen[p]) return false; p = this.byId.get(p) && this.byId.get(p).parent }
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
isOpen(item) {
|
||||||
|
if (this.kept) return true
|
||||||
|
return item.section ? !this.secClosed[item.sectionId] : !!this.nodeOpen[item.id]
|
||||||
|
},
|
||||||
|
setOpen(item, open) {
|
||||||
|
if (item.section) this.secClosed = { ...this.secClosed, [item.sectionId]: !open }
|
||||||
|
else this.nodeOpen = { ...this.nodeOpen, [item.id]: open }
|
||||||
|
this.saveState()
|
||||||
|
},
|
||||||
|
toggle(item) { if (item.expandable) this.setOpen(item, !this.isOpen(item)) },
|
||||||
|
collapseAll() {
|
||||||
|
this.nodeOpen = {}
|
||||||
|
this.saveState()
|
||||||
|
const a = this.visible.find(i => !i.section && i.target === this.active && i.depth === 0)
|
||||||
|
if (a) this.focusId = a.id
|
||||||
|
},
|
||||||
|
rowClass(item) {
|
||||||
|
return {
|
||||||
|
'st-row': true,
|
||||||
|
'st-sec': !!item.section,
|
||||||
|
active: !item.section && item.target === this.active,
|
||||||
|
alias: !!item.alias
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setRow(id, el) { if (!this._rows) this._rows = {}; if (el) this._rows[id] = el; else delete this._rows[id] },
|
||||||
|
// Choosing a plugin opens it in the pane (through the layout's pick, which also closes the mobile
|
||||||
|
// drawer) and unfolds its parts — you asked for that plugin, so show what it is made of. Only the
|
||||||
|
// twisty or ← folds. A part with no page of its own opens the composite it lives in.
|
||||||
|
activate(item) {
|
||||||
|
if (item.section) { this.toggle(item); return }
|
||||||
|
if (item.expandable && !this.isOpen(item)) this.setOpen(item, true)
|
||||||
|
if (!item.target) return
|
||||||
|
if (this.pick) this.pick(item.target); else this.$emit('select', item.target)
|
||||||
|
},
|
||||||
|
onRowClick(item) { this.focusId = item.id; this.activate(item) },
|
||||||
|
focusRow(id) {
|
||||||
|
if (!id) return
|
||||||
|
this.focusId = id
|
||||||
|
this.$nextTick(() => { const el = this._rows && this._rows[id]; if (el) { el.focus({ preventScroll: true }); el.scrollIntoView({ block: 'nearest' }) } })
|
||||||
|
},
|
||||||
|
// APG Tree View keyboard model.
|
||||||
|
onTreeKey(e) {
|
||||||
|
const list = this.visible
|
||||||
|
if (!list.length) return
|
||||||
|
const i = Math.max(0, list.findIndex(x => x.id === this.focusId))
|
||||||
|
const cur = list[i]
|
||||||
|
const parentRowId = (it) => it.section ? '' : (it.parent || ('sec:' + it.sectionId))
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown': this.focusRow((list[i + 1] || cur).id); break
|
||||||
|
case 'ArrowUp': this.focusRow((list[i - 1] || cur).id); break
|
||||||
|
case 'Home': this.focusRow(list[0].id); break
|
||||||
|
case 'End': this.focusRow(list[list.length - 1].id); break
|
||||||
|
case 'ArrowRight':
|
||||||
|
if (!cur.expandable) break
|
||||||
|
if (!this.isOpen(cur)) this.setOpen(cur, true)
|
||||||
|
else if (list[i + 1] && list[i + 1].level > cur.level) this.focusRow(list[i + 1].id)
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
if (cur.expandable && this.isOpen(cur) && !this.kept) this.setOpen(cur, false)
|
||||||
|
else this.focusRow(parentRowId(cur))
|
||||||
|
break
|
||||||
|
case 'Enter': case ' ': this.activate(cur); break
|
||||||
|
default:
|
||||||
|
// Type-ahead: jump to the next row whose name starts with the typed character.
|
||||||
|
if (e.key.length === 1 && /\S/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||||
|
const ch = e.key.toLowerCase()
|
||||||
|
const order = list.slice(i + 1).concat(list.slice(0, i + 1))
|
||||||
|
const hit = order.find(x => String(x.label).toLowerCase().startsWith(ch))
|
||||||
|
if (hit) this.focusRow(hit.id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
},
|
||||||
|
onFindKey(e) {
|
||||||
|
if (e.key === 'Escape') { this.query = ''; e.preventDefault() }
|
||||||
|
else if (e.key === 'ArrowDown' && this.visible.length) { this.focusRow(this.visible[0].id); e.preventDefault() }
|
||||||
|
},
|
||||||
|
reveal() {
|
||||||
|
if (!this.active || !this.sections.length) return
|
||||||
|
let row = null
|
||||||
|
for (const s of this.sections) {
|
||||||
|
row = s.nodes.find(n => n.depth === 0 && n.target === this.active)
|
||||||
|
if (row) { if (this.secClosed[s.id]) { this.secClosed = { ...this.secClosed, [s.id]: false }; this.saveState() } break }
|
||||||
|
}
|
||||||
|
if (!row) return
|
||||||
|
if (!this.focusId || !this.byId.has(this.focusId) || this.byId.get(this.focusId).target !== this.active) this.focusId = row.id
|
||||||
|
this.$nextTick(() => { const el = this._rows && this._rows[row.id]; if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }) })
|
||||||
|
},
|
||||||
|
openSectionFromActivity(id) {
|
||||||
|
if (this.secClosed[id]) { this.secClosed = { ...this.secClosed, [id]: false }; this.saveState() }
|
||||||
|
if (this.expand) this.expand()
|
||||||
|
this.$nextTick(() => this.focusRow('sec:' + id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Mirror of ST_CSS above (dev / light-DOM readability). Keep the two in sync. */
|
||||||
|
.st { display:flex; flex-direction:column; margin:0 -12px; }
|
||||||
|
.st-bar { position:sticky; top:0; z-index:3; display:flex; align-items:center; gap:6px; padding:8px 12px 8px; background:#ffffff; }
|
||||||
|
.st-find { flex:1 1 auto; min-width:0; display:flex; align-items:center; gap:6px; height:30px; padding:0 8px; border:1px solid #d1d4cd; border-radius:8px; background:#f3f3f1; color:#6d766e; }
|
||||||
|
.st-find:focus-within { border-color:#92b195; background:#ffffff; }
|
||||||
|
.st-find-ic { display:inline-flex; } .st-find-ic svg { width:14px; height:14px; }
|
||||||
|
.st-find-in { flex:1 1 auto; min-width:0; border:0; outline:0; background:none; font:inherit; font-size:13px; color:#3b463d; }
|
||||||
|
.st-tool { flex:0 0 auto; width:30px; height:30px; display:inline-flex; align-items:center; justify-content:center; padding:0; border:0; border-radius:8px; background:none; color:#6d766e; cursor:pointer; }
|
||||||
|
.st-tool:hover { background:#f3f3f1; color:#3b463d; } .st-tool:focus-visible { outline:2px solid #92b195; outline-offset:-2px; }
|
||||||
|
.st-tool svg { width:16px; height:16px; }
|
||||||
|
.st-tree { display:flex; flex-direction:column; padding:0 0 8px; outline:0; }
|
||||||
|
.st-row { position:relative; display:flex; align-items:center; gap:6px; height:28px; box-sizing:border-box; padding:0 10px 0 calc(8px + var(--st-depth) * 14px); font-size:13px; color:#3b463d; cursor:pointer; white-space:nowrap; user-select:none; outline:0; }
|
||||||
|
.st-row:hover { background:#f3f3f1; }
|
||||||
|
.st-row:focus-visible { box-shadow:inset 0 0 0 1px #92b195; }
|
||||||
|
.st-row.active { background:rgba(146,177,149,.24); }
|
||||||
|
.st-row.active::after { content:""; position:absolute; left:0; top:4px; bottom:4px; width:3px; border-radius:0 3px 3px 0; background:#92b195; }
|
||||||
|
.st-row.alias.active { background:rgba(146,177,149,.12); }
|
||||||
|
.st-sec { position:sticky; top:46px; z-index:2; height:30px; margin-top:6px; background:#ffffff; font-size:11px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:#6d766e; border-top:1px solid #eceee9; }
|
||||||
|
.st-sec:first-child { margin-top:0; border-top:0; }
|
||||||
|
.st-sec:hover { background:#ffffff; color:#3b463d; }
|
||||||
|
.st-cap { flex:0 0 auto; font:700 10px/1 system-ui,sans-serif; letter-spacing:.3px; padding:3px 6px; border-radius:999px; background:#92b195; color:#1d2a1f; }
|
||||||
|
.st-sec-label { flex:1 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.st-twisty, .st-twisty-sp { flex:0 0 16px; width:16px; height:16px; display:inline-flex; align-items:center; justify-content:center; color:#6d766e; }
|
||||||
|
.st-twisty svg { width:14px; height:14px; transition:transform .12s ease; }
|
||||||
|
.st-row[aria-expanded="true"] > .st-twisty svg { transform:rotate(90deg); }
|
||||||
|
.st-ic { flex:0 0 16px; display:inline-flex; color:#6d766e; } .st-ic svg { width:16px; height:16px; }
|
||||||
|
.st-row.active .st-ic { color:#5f8a63; }
|
||||||
|
.st-label { flex:0 1 auto; min-width:0; overflow:hidden; text-overflow:ellipsis; }
|
||||||
|
.st-desc { flex:1 1 0; min-width:0; overflow:hidden; text-overflow:ellipsis; font:11px/1 ui-monospace,Menlo,monospace; color:#8a928b; }
|
||||||
|
.st-part { flex:0 0 auto; font:600 9px/1 system-ui,sans-serif; text-transform:uppercase; letter-spacing:.3px; padding:2px 4px; border:1px solid #92b195; border-radius:4px; color:#5f8a63; }
|
||||||
|
.st-count { flex:0 0 auto; margin-left:auto; min-width:18px; text-align:right; font-size:11px; font-weight:500; letter-spacing:0; color:#6d766e; }
|
||||||
|
.st-guide { position:absolute; top:0; bottom:0; left:calc(15px + var(--st-g) * 14px); border-left:1px solid #dfe2db; pointer-events:none; }
|
||||||
|
.st-row:hover > .st-guide { border-left-color:#c4c8c0; }
|
||||||
|
.st-empty { margin:12px; font-size:13px; color:#6d766e; }
|
||||||
|
.st-activity { display:flex; flex-direction:column; gap:4px; }
|
||||||
|
.st-act { position:relative; display:flex; align-items:center; justify-content:center; width:48px; height:44px; margin-left:172px; padding:0; border:0; border-radius:10px; background:none; color:#6d766e; cursor:pointer; }
|
||||||
|
.st-act:hover, .st-act.active { background:#f3f3f1; color:#3b463d; }
|
||||||
|
.st-act:focus-visible { outline:2px solid #92b195; outline-offset:-2px; }
|
||||||
|
.st-act-ic { display:inline-flex; } .st-act-ic svg { width:20px; height:20px; }
|
||||||
|
.st-act-n { position:absolute; right:6px; bottom:5px; font:700 9px/1 system-ui,sans-serif; color:#5f8a63; }
|
||||||
|
</style>
|
||||||
4
public/doxa-maps/manifest.json
generated
4
public/doxa-maps/manifest.json
generated
|
|
@ -799,7 +799,7 @@
|
||||||
"bundle": "plugin-studio",
|
"bundle": "plugin-studio",
|
||||||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||||
"output": "doxa-maps/plugin-studio/plugin-studio.js",
|
"output": "doxa-maps/plugin-studio/plugin-studio.js",
|
||||||
"integrity": "sha384-6Dmz25GPZmlHiB0aEzeuaYQvFfvy+1IMNy8Tsk9VflO2lH2543P/Q2W6WvetrU9o",
|
"integrity": "sha384-N70Sfwq4XTc7BHosJDT1r9/qUrzr1NZ5DiLb3QUYST8vY7+quBQzytqcpOs4Rp1E",
|
||||||
"elements": [
|
"elements": [
|
||||||
"plugin-studio"
|
"plugin-studio"
|
||||||
],
|
],
|
||||||
|
|
@ -827,7 +827,7 @@
|
||||||
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
"scriptUrl": "./plugin-studio/plugin-studio.js",
|
||||||
"embed": {
|
"embed": {
|
||||||
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
"iframe": "<iframe src=\"./plugin-studio/plugin-studio/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
|
||||||
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-6Dmz25GPZmlHiB0aEzeuaYQvFfvy+1IMNy8Tsk9VflO2lH2543P/Q2W6WvetrU9o\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./plugin-studio/plugin-studio.js\" integrity=\"sha384-N70Sfwq4XTc7BHosJDT1r9/qUrzr1NZ5DiLb3QUYST8vY7+quBQzytqcpOs4Rp1E\" crossorigin=\"anonymous\"></script>\n<plugin-studio profile-config='{\"profile\":\"plugin-studio\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></plugin-studio>"
|
||||||
},
|
},
|
||||||
"instances": [
|
"instances": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
35
public/doxa-maps/plugin-studio/plugin-studio.js
generated
35
public/doxa-maps/plugin-studio/plugin-studio.js
generated
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
||||||
|
|
||||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||||
<script src="../plugin-studio.js" integrity="sha384-6Dmz25GPZmlHiB0aEzeuaYQvFfvy+1IMNy8Tsk9VflO2lH2543P/Q2W6WvetrU9o" crossorigin="anonymous"></script>
|
<script src="../plugin-studio.js" integrity="sha384-N70Sfwq4XTc7BHosJDT1r9/qUrzr1NZ5DiLb3QUYST8vY7+quBQzytqcpOs4Rp1E" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
|
|
||||||
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
|
||||||
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
<!-- Bundle IIFE — registers the <plugin-studio> custom element. -->
|
||||||
<script src="../../plugin-studio.js" integrity="sha384-6Dmz25GPZmlHiB0aEzeuaYQvFfvy+1IMNy8Tsk9VflO2lH2543P/Q2W6WvetrU9o" crossorigin="anonymous"></script>
|
<script src="../../plugin-studio.js" integrity="sha384-N70Sfwq4XTc7BHosJDT1r9/qUrzr1NZ5DiLb3QUYST8vY7+quBQzytqcpOs4Rp1E" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Mapbox token resolution — the A3 ladder, in precedence order:
|
// Mapbox token resolution — the A3 ladder, in precedence order:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue