Plugin studio: fold every composite, so the rail starts as a plain list
Some checks are pending
build-gate / build (push) Waiting to run
Some checks are pending
build-gate / build (push) Waiting to run
With every composition tree open, the rail was too long to find your way around. Each composite now carries a chevron and starts folded, so the rail first reads as the 35 plugins by capability, each composite saying how many parts it has. Open one and you see its direct parts; a part that is itself a composite folds again, so doxa-maps-spa opens to its tabs and each tab opens to what it is made of, one level at a time. Choosing a composite also opens it; only the chevron closes it. The same child folds independently wherever it appears. VerticalTabsLayout gains two optional row fields: `branch` makes a row foldable, `ancestors` hides a row until every branch above it is open. Rows are wrapped so the chevron can sit beside the tab rather than inside it. doxa-maps-spa sets neither field and renders unchanged.
This commit is contained in:
parent
1257ab575b
commit
e3df4677d3
2 changed files with 72 additions and 15 deletions
|
|
@ -29,6 +29,9 @@
|
|||
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
|
||||
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:
|
||||
|
|
@ -161,14 +164,24 @@
|
|||
</div>
|
||||
|
||||
<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
|
||||
v-for="row in grp.rows" :key="row.key" type="button" role="tab"
|
||||
class="vtl-tab" :class="{ active: target(row) === active, alias: !!row.selects }"
|
||||
type="button" role="tab"
|
||||
class="vtl-tab" :class="{ active: target(row) === active, alias: !!row.selects, 'has-branch': !!row.branch }"
|
||||
:data-depth="row.depth || null" :style="row.depth ? { '--vtl-depth': row.depth } : null"
|
||||
:aria-selected="String(target(row) === active)"
|
||||
:tabindex="!row.selects && row.key === active ? 0 : -1"
|
||||
:ref="el => { if (!row.selects) registerTab(row.key, el) }"
|
||||
@click="pick(target(row))" @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-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
|
||||
|
|
@ -180,6 +193,7 @@
|
|||
<span class="vtl-tab-sub" v-if="row.sub"><span v-html="row.sub"></span></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -368,6 +382,11 @@ const VTL_SHADOW_CSS = `
|
|||
.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:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
|
||||
|
|
@ -463,6 +482,7 @@ const VTL_SHADOW_CSS = `
|
|||
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
|
||||
.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-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; }
|
||||
|
|
@ -489,6 +509,7 @@ const VTL_SHADOW_CSS = `
|
|||
.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-fallback { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
|
||||
|
|
@ -529,7 +550,7 @@ export default defineComponent({
|
|||
// 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).
|
||||
return {
|
||||
navOpen: this.initialNavOpen, railCollapsed: this.initialRailCollapsed, _tabEls: {}, folderOverride: {},
|
||||
navOpen: this.initialNavOpen, railCollapsed: this.initialRailCollapsed, _tabEls: {}, folderOverride: {}, branchOpen: {},
|
||||
// `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,
|
||||
// railCollapsed = desktop slim rail) at the breakpoint and drive rail visibility correctly.
|
||||
|
|
@ -690,7 +711,15 @@ export default defineComponent({
|
|||
},
|
||||
// The key a row selects: its own, or — for an ALIAS row — the canonical row it points at.
|
||||
target(row) { return row.selects || row.key },
|
||||
pick(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)
|
||||
// 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.
|
||||
|
|
@ -820,6 +849,11 @@ export default defineComponent({
|
|||
.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:focus-visible { outline:2px solid var(--vtl-accent); outline-offset:-2px; }
|
||||
.vtl-tab.active { background:var(--vtl-accented); color:#3b463d; font-weight:500; }
|
||||
|
|
@ -915,6 +949,7 @@ export default defineComponent({
|
|||
.vtl.rail-collapsed .vtl-tab-emoji { font-size:19px; }
|
||||
.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-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; }
|
||||
|
|
@ -941,6 +976,7 @@ export default defineComponent({
|
|||
.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-fallback { display:none; }
|
||||
.vtl.rail-collapsed .vtl-tier { padding-left:8px; }
|
||||
|
|
|
|||
|
|
@ -422,17 +422,32 @@ export default defineComponent({
|
|||
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]))
|
||||
// ★ FOLDED BY DEFAULT (Driver, 2026-09-24: "it needs to be collapsible, otherwise it is too
|
||||
// hard to know how to navigate so many nested plugins"). Every node with parts is a BRANCH with
|
||||
// its own chevron, closed until opened — so the rail first reads as a plain list of plugins, and
|
||||
// you drill into one composite, then one level of it, at a time. Picking a composite opens it.
|
||||
// Branch ids are namespaced by the composite's row key: the same child (dashboard-plus-grid)
|
||||
// folds independently under doxa-maps-spa and in its own CAP 4 entry.
|
||||
const parts = (n) => n + (n === 1 ? ' part' : ' parts')
|
||||
const rowsFor = (r) => {
|
||||
const out = [{ key: r.key, label: r.label, icon: r.icon, sub: '<code><' + esc(r.tag) + '></code>' }]
|
||||
const t = treeOf.get(r.key)
|
||||
const bid = (id) => 'b:' + r.key + ':' + id
|
||||
const out = [{
|
||||
key: r.key, label: r.label, icon: r.icon,
|
||||
branch: t ? bid(0) : undefined,
|
||||
sub: '<code><' + esc(r.tag) + '></code>' + (t ? ' · ' + parts(t.nodes[0].kids) : '')
|
||||
}]
|
||||
if (t) {
|
||||
t.nodes.slice(1).forEach((n, i) => out.push({
|
||||
key: 'tree-' + r.key + '-' + i,
|
||||
t.nodes.slice(1).forEach((n) => out.push({
|
||||
key: 'tree-' + r.key + '-' + n.id,
|
||||
selects: n.selects || ('none:' + r.key),
|
||||
depth: n.depth,
|
||||
label: n.label,
|
||||
badge: n.part ? 'part' : '',
|
||||
sub: n.part ? 'no standalone page' : 'CAP ' + n.cap + ' · <code><' + esc(n.tag) + '></code>'
|
||||
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
|
||||
|
|
@ -464,7 +479,9 @@ export default defineComponent({
|
|||
// own page because it has none, so it opens the composite it lives in.
|
||||
compositionTrees() {
|
||||
const out = []
|
||||
const walk = (row, depth, path, nodes) => {
|
||||
// Every node records `ancestors` (the ids of the nodes above it) and, if anything was nested
|
||||
// under it, `kids` — which is what lets the rail fold each level independently.
|
||||
const walk = (row, depth, path, nodes, ancestors) => {
|
||||
const b = this.manifestBundles.find(x => x.bundle === row.bundle)
|
||||
const refs = (row.cap >= 4 && b && b.composes) || []
|
||||
const kids = []
|
||||
|
|
@ -479,12 +496,15 @@ export default defineComponent({
|
|||
}
|
||||
kids.sort((a, b2) => b2.cap - a.cap || a.label.localeCompare(b2.label))
|
||||
for (const k of kids) {
|
||||
nodes.push({ label: k.label, tag: k.tag, cap: k.cap, selects: k.key, depth: depth + 1 })
|
||||
if (!path.has(k.key)) walk(k, depth + 1, new Set([...path, k.key]), nodes)
|
||||
const node = { id: nodes.length, label: k.label, tag: k.tag, cap: k.cap, selects: k.key, depth: depth + 1, ancestors, kids: 0 }
|
||||
nodes.push(node)
|
||||
const before = nodes.length
|
||||
if (!path.has(k.key)) walk(k, depth + 1, new Set([...path, k.key]), nodes, [...ancestors, node.id])
|
||||
node.kids = nodes.filter((n, i) => i >= before && n.depth === depth + 2).length
|
||||
}
|
||||
// Unpublished parts have no tier, so they sort LAST — below every capability — and open the
|
||||
// composite they live in.
|
||||
for (const ref of parts) nodes.push({ label: ref, part: true, selects: row.key, depth: depth + 1 })
|
||||
for (const ref of parts) nodes.push({ id: nodes.length, label: ref, part: true, selects: row.key, depth: depth + 1, ancestors, kids: 0 })
|
||||
}
|
||||
for (const b of this.manifestBundles) {
|
||||
if (!b.composes || !b.composes.length || STUDIO_HIDDEN.has(b.bundle)) continue
|
||||
|
|
@ -493,8 +513,9 @@ export default defineComponent({
|
|||
// this rail already IS its composition.
|
||||
const own = this.rows.find(r => r.bundle === b.bundle)
|
||||
if (!own) continue
|
||||
const nodes = [{ label: own.label, tag: own.tag, cap: own.cap, selects: own.key, depth: 0 }]
|
||||
walk(own, 0, new Set([own.key]), nodes)
|
||||
const nodes = [{ id: 0, label: own.label, tag: own.tag, cap: own.cap, selects: own.key, depth: 0, ancestors: [] }]
|
||||
walk(own, 0, new Set([own.key]), nodes, [0])
|
||||
nodes[0].kids = nodes.filter(n => n.depth === 1).length
|
||||
if (nodes.length > 1) out.push({ bundle: b.bundle, nodes })
|
||||
}
|
||||
return out
|
||||
|
|
|
|||
Loading…
Reference in a new issue