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
18 changes: 18 additions & 0 deletions docs/guide/when-clauses.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ ctx.docks.register({

`when: 'false'` hides an entry unconditionally.

### Render-only visibility on dock entries

A dock entry also takes `visibility`, a second expression in the same syntax that hides only the entry's own dock-bar button, leaving the entry registered and reachable — `docks.activate()`/`switchEntry()` by id, RPC lookups, and anything else that walks the raw entry list keep working. `when` remains the general relevance switch for the entry as a whole; reach for `visibility` only when an entry must stay in the model without a button of its own.

The canonical case is a shared-frame anchor (`subTabs`): the anchor iframe must stay registered to keep driving the postMessage nav loop, but only its synthesized member tabs should render as dock-bar buttons.

```ts
ctx.docks.register({
id: 'my-devtool:anchor',
title: 'My Devtool',
type: 'iframe',
icon: 'ph:squares-four-duotone',
url: '/__my-devtool/',
subTabs: { protocol: 'postmessage' },
visibility: 'false', // hide the anchor's own button; its tabs still render
})
```

## Expression syntax

### Operators
Expand Down
7 changes: 7 additions & 0 deletions packages/devframe/src/types/devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@ export interface DevframeDockDefaults {
* clauses). Set to `'false'` to hide the entry unconditionally.
*/
when?: string
/**
* Render-only visibility expression, same syntax as {@link when}. Hides the
* entry's own dock-bar button when it evaluates to `false` while leaving it
* registered and reachable (activation, RPC lookups, etc.) — unlike `when`,
* which is the general relevance switch for the entry as a whole.
*/
visibility?: string
/** Badge text rendered on the dock icon (e.g. an unread count). */
badge?: string
/** Id of the dock group this entry collapses under, if any. */
Expand Down
18 changes: 18 additions & 0 deletions packages/hub/src/client/__tests__/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,24 @@ describe('createDevframeClientHost', () => {
host.dispose()
})

it('keeps a `visibility: false` entry in the raw model, activatable by id', async () => {
const { rpc, states } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
states.get('devframe:docks')!.push([
iframeEntry('anchor', { subTabs: { protocol: 'postmessage' }, visibility: 'false' }),
iframeEntry('visible'),
])

// `visibility` is a render-only hint for the UI layer — the hub itself
// never filters `entries`/`getStateById`/`switchEntry` by it, so the
// anchor stays fully reachable.
expect(host.context.docks.entries.map(e => e.id)).toEqual(['anchor', 'visible'])
expect(host.context.docks.getStateById('anchor')).toBeDefined()
expect(await host.context.docks.switchEntry('anchor')).toBe(true)
expect(host.context.docks.selected?.id).toBe('anchor')
host.dispose()
})

it('groups entries by category — grouped members bucket under their group, orphans by their own', async () => {
const { rpc, states } = createStubRpc()
const host = await createDevframeClientHost({ rpc })
Expand Down
6 changes: 5 additions & 1 deletion packages/hub/src/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ export function defineCommand<const W extends string = ''>(
export function defineDockEntry<
const T extends DevframeDockUserEntry,
const W extends string = '',
const V extends string = '',
>(
entry: Omit<T, 'when'> & { when?: WhenExpression<WhenContext, W> },
entry: Omit<T, 'when' | 'visibility'> & {
when?: WhenExpression<WhenContext, W>
visibility?: WhenExpression<WhenContext, V>
},
): T {
return entry as unknown as T
}
Expand Down
13 changes: 13 additions & 0 deletions packages/hub/src/node/__tests__/mount-devframe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ describe('mountDevframe', () => {
})
})

it('applies the definition-level dock `visibility` default to the synthesized entry, independent of `when`', async () => {
const ctx = createContext()
await mountDevframe(ctx, makeDevframe({
dock: { when: 'clientType == embedded', visibility: 'false' },
}))

expect(ctx.docks.views.get('demo')).toMatchObject({
id: 'demo',
when: 'clientType == embedded',
visibility: 'false',
})
})

it('lets per-mount dock overrides win over the definition dock defaults', async () => {
const ctx = createContext()
await mountDevframe(
Expand Down
7 changes: 4 additions & 3 deletions packages/hub/src/node/mount-devframe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ export interface MountDevframeOptions {
/**
* Per-mount overrides for the auto-synthesized iframe dock entry. Use
* this to customize the entry's `category`, override the icon, hide it
* via `when`, etc. Takes precedence over the definition's own
* {@link DevframeDefinition.dock} defaults. Cannot change `id`, `type`,
* or `url` — those are derived from the devframe definition.
* via `when` (or only its dock-bar button via `visibility`), etc. Takes
* precedence over the definition's own {@link DevframeDefinition.dock}
* defaults. Cannot change `id`, `type`, or `url` — those are derived from
* the devframe definition.
*/
dock?: Partial<Omit<DevframeViewIframe, 'id' | 'type' | 'url'>>
}
Expand Down
26 changes: 26 additions & 0 deletions packages/hub/src/types/docks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,28 @@ export interface DevframeDockEntryBase {
* @see {@link import('devframe/utils/when').evaluateWhen}
*/
when?: string
/**
* Render-only conditional visibility expression, same syntax as {@link when}.
* When it evaluates to `false`, a viewer omits the entry from the rendered
* dock bar / list, but the entry stays registered and fully reachable —
* `docks.activate()`/`switchEntry()` by id, RPC lookups, and anything else
* that walks the raw entry list (e.g. the {@link DevframeViewIframe.subTabs}
* frame-nav adapter) keep working exactly as if it were visible.
*
* Use this instead of {@link when} when an entry must remain part of the
* model without a dock-bar button of its own — the canonical case is a
* shared-frame {@link DevframeViewIframe.subTabs anchor}: set
* `visibility: 'false'` on the anchor so only its synthesized member tabs
* render, while the anchor itself keeps driving the postMessage nav loop.
* `when`, by contrast, is the general relevance switch for the entry as a
* whole; reach for `visibility` only for this render-only carve-out.
*
* Set to `'false'` to unconditionally hide the entry's own dock-bar button.
*
* @example 'false'
* @see {@link import('devframe/utils/when').evaluateWhen}
*/
visibility?: string
/**
* Badge text to display on the dock icon (e.g., unread count)
*/
Expand Down Expand Up @@ -182,6 +204,10 @@ export interface DevframeViewIframe extends DevframeDockEntryBase {
* (grouped/soft-navigated via this anchor's {@link frameId}), and drives the
* live navigation loop. Absent a shim, the anchor simply renders as a single
* plain iframe dock.
*
* Set {@link DevframeDockEntryBase.visibility} to `'false'` on the anchor to
* hide its own dock-bar button once tabs are discovered, surfacing only the
* synthesized member docks while the anchor keeps driving the nav loop.
*/
subTabs?: FrameSubTabsConfig
/**
Expand Down
Loading