diff --git a/docs/kit/dock-system.md b/docs/kit/dock-system.md index da23a83d3..e742c5d5c 100644 --- a/docs/kit/dock-system.md +++ b/docs/kit/dock-system.md @@ -490,6 +490,8 @@ ctx.docks.register({ A group carries the usual `title`/`icon`/`category`/`defaultOrder`/`when` fields and has no view of its own. `defaultChildId` names the member opened when the group button is activated; without it, the button reveals the member popover and opens a view once a member is chosen. +Pointing `defaultChildId` at a [shared-iframe anchor](#shared-iframe-soft-navigation) that is hidden with `visibility: 'false'` is the idiomatic way to boot a soft-nav frame: activating the group mounts the anchor's iframe the first time, and every later activation resurfaces the frame's current member tab so a visible dock stays highlighted rather than the anchor itself. + Membership is a flat pointer, not containment: every member stays an independently-registered top-level entry. A member whose `groupId` references a group that was never registered renders as a normal top-level entry, and a group with no members stays hidden until an entry joins it. Grouping is one level deep — a group entry does not set its own `groupId`. ### Categories inside a group diff --git a/packages/core/src/client/webcomponents/state/__tests__/frame-nav.test.ts b/packages/core/src/client/webcomponents/state/__tests__/frame-nav.test.ts index 039adfa86..727408fcf 100644 --- a/packages/core/src/client/webcomponents/state/__tests__/frame-nav.test.ts +++ b/packages/core/src/client/webcomponents/state/__tests__/frame-nav.test.ts @@ -234,3 +234,95 @@ describe('shared-iframe soft navigation', () => { expect(context.docks.selectedId).toBeNull() }) }) + +// A hidden `subTabs` anchor (`visibility: 'false'`) exists only to boot the +// shared frame; its synthesized member tabs render the real buttons. A group's +// `defaultChildId` points at the anchor so opening the group boots the frame — +// but selection must then land on a visible member tab, never linger on the +// invisible anchor. +describe('hidden subTabs anchor: selection lands on a visible member', () => { + let bus: ReturnType | undefined + + afterEach(() => { + bus?.restore() + bus = undefined + }) + + function hiddenAnchorEntry(): DevToolsDockEntry { + return { ...anchorEntry(), groupId: 'nuxt-group', visibility: 'false' } as DevToolsDockEntry + } + + function groupEntry(): DevToolsDockEntry { + return { + id: 'nuxt-group', + type: 'group', + title: 'Nuxt', + icon: 'i', + defaultChildId: 'nuxt', + } as DevToolsDockEntry + } + + async function bootFrame(context: Awaited>, current = 'modules') { + const { iframe } = makeFakeIframe(ANCHOR_URL) + context.docks.getStateById('nuxt')!.domElements.iframe = iframe + await nextTick() + bus!.emit(frameMessage('ready', { tabs: READY_TABS, current })) + } + + it('first boot selects the anchor to mount its frame (no member exists yet)', async () => { + bus = stubMessageBus() + const context = await createDocksContext('embedded', createMockRpc([hiddenAnchorEntry()])) + + // No member tabs materialized yet, so selecting the anchor mounts its iframe. + await context.docks.switchEntry('nuxt') + expect(context.docks.selectedId).toBe('nuxt') + }) + + it('redirects a re-selected anchor to the frame\'s current member tab', async () => { + bus = stubMessageBus() + const context = await createDocksContext('embedded', createMockRpc([hiddenAnchorEntry()])) + await bootFrame(context, 'modules') + expect(context.docks.selectedId).toBe('nuxt:modules') + + await context.docks.switchEntry('nuxt:timeline') + expect(context.docks.selectedId).toBe('nuxt:timeline') + + // Selecting the (hidden) anchor again lands back on the live member, not the anchor. + await context.docks.switchEntry('nuxt') + expect(context.docks.selectedId).toBe('nuxt:timeline') + }) + + it('re-opening the group lands on the current member, not the hidden anchor', async () => { + bus = stubMessageBus() + const context = await createDocksContext('embedded', createMockRpc([groupEntry(), hiddenAnchorEntry()])) + + // Open the group → boots the frame via its `defaultChildId` anchor → lands + // on the reported member tab. + await context.docks.switchEntry('nuxt-group') + await bootFrame(context, 'modules') + expect(context.docks.selectedId).toBe('nuxt:modules') + + await context.docks.switchEntry('nuxt:timeline') + await context.docks.switchEntry(null) + expect(context.docks.selectedId).toBeNull() + + // Re-open the group: it must resurface the current member, not the invisible anchor. + await context.docks.switchEntry('nuxt-group') + expect(context.docks.selectedId).toBe('nuxt:timeline') + }) + + it('falls back to the anchor when the remembered member no longer exists', async () => { + bus = stubMessageBus() + const context = await createDocksContext('embedded', createMockRpc([hiddenAnchorEntry()])) + await bootFrame(context, 'modules') + expect(context.docks.selectedId).toBe('nuxt:modules') + + // The manifest empties, disposing every member. Re-selecting the anchor now + // has no live member to redirect to, so it boots the frame afresh. + bus.emit(frameMessage('manifest', { tabs: [] })) + expect(context.docks.selectedId).toBeNull() + + await context.docks.switchEntry('nuxt') + expect(context.docks.selectedId).toBe('nuxt') + }) +}) diff --git a/packages/core/src/client/webcomponents/state/context.ts b/packages/core/src/client/webcomponents/state/context.ts index 098a59856..104aec1cc 100644 --- a/packages/core/src/client/webcomponents/state/context.ts +++ b/packages/core/src/client/webcomponents/state/context.ts @@ -138,6 +138,16 @@ export async function createDocksContext( dockSelectedId: selectedId.value ?? '', }) + // Tracks the shared frame's current member tab, keyed by `frameId`. A + // `subTabs` anchor boots a shared iframe but has no view distinct from its + // synthesized member tabs (they all render the same frame), and it is usually + // hidden from the bar via `visibility: 'false'`. Remembering which member is + // live lets `switchEntry` redirect a later re-selection of the anchor (e.g. a + // group `defaultChildId` reopening the group) onto that visible tab instead of + // lingering on the invisible anchor. Populated below whenever a member is + // selected; read when a `subTabs` anchor is selected. + const frameNavCurrentMember = new Map() + const switchEntry = async (id: string | null = null) => { if (id == null) { selectedId.value = null @@ -167,6 +177,21 @@ export async function createDocksContext( return switchEntry(target) } + // A `subTabs` anchor owns the shared frame but has no view of its own apart + // from its synthesized member tabs, and is usually hidden from the bar + // (`visibility: 'false'`). Once the frame has reported a current tab, + // selecting the anchor — via a group `defaultChildId` boot, the command + // palette, or an RPC activation — redirects to that live member so a visible + // dock is highlighted instead of the invisible anchor. Before any tab exists + // (first boot) there is no current member, so we fall through and select the + // anchor itself to mount its iframe and boot the frame. + if (entry.type === 'iframe' && entry.subTabs) { + const frameId = entry.frameId ?? entry.id + const currentMemberId = frameNavCurrentMember.get(frameId) + if (currentMemberId && currentMemberId !== id && entries.value.some(e => e.id === currentMemberId)) + return switchEntry(currentMemberId) + } + // If the action is in a popup, delegate to the main frame if (entry.type === 'action') { const delegated = await triggerMainFrameDockAction(clientType, entry.id) @@ -191,6 +216,12 @@ export async function createDocksContext( await executeSetupScript(entry, scriptContext) } + // Remember the shared frame's current member tab (a member carries its + // anchor's `frameId` but is not itself a `subTabs` anchor) so re-selecting + // the usually-hidden anchor later lands back on this visible tab. + if (entry.type === 'iframe' && entry.frameId && !entry.subTabs) + frameNavCurrentMember.set(entry.frameId, entry.id) + selectedId.value = entry.id panelStore.value.open = true return true diff --git a/playgrounds/core/vite.config.ts b/playgrounds/core/vite.config.ts index c47f0bce1..ab6e11a22 100644 --- a/playgrounds/core/vite.config.ts +++ b/playgrounds/core/vite.config.ts @@ -199,6 +199,12 @@ export default defineConfig({ groupId: 'nuxt', frameId: 'nuxt', subTabs: { protocol: 'postmessage' }, + // The anchor only exists to boot the shared frame; its synthesized + // member tabs (Overview / Pages / …) render the real buttons, so the + // anchor's own button is hidden via the render-only `visibility` + // clause. `defaultChildId` on the group still points here so opening + // the group boots the frame. + visibility: 'false', defaultOrder: -1, })