Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/kit/dock-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof stubMessageBus> | 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<ReturnType<typeof createDocksContext>>, 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')
})
})
31 changes: 31 additions & 0 deletions packages/core/src/client/webcomponents/state/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>()

const switchEntry = async (id: string | null = null) => {
if (id == null) {
selectedId.value = null
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions playgrounds/core/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})

Expand Down
Loading