Skip to content

Commit 85aa249

Browse files
antfubotclaude
andcommitted
feat(hub): add toggle dock actions and entry descriptions
A `toggle: true` action is an on/off tool, such as an in-page element picker. It never takes the selection: it hides an open panel while on, restores it when off, and its dock button renders as active. A click, `docks.setActionActive(id, false)`, or Escape in hub-ui turns it off. Dock entries also get an optional `description`, shown under the title in the dock button tooltip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7889f1a commit 85aa249

30 files changed

Lines changed: 421 additions & 27 deletions

‎docs/content/1.guide/17.client-context.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,30 @@ The exported function (`DockClientScriptContext`) receives the client context an
7676
- **`current`** holds this entry's state: `entryMeta`, `isActive`, `domElements`, `events` (`entry:activated`, `entry:deactivated`, `entry:updated`, `dom:panel:mounted`, `dom:iframe:mounted`).
7777
- **`messages`**: an entry-scoped messages client (`category` defaults to the entry id; `info`/`warn`/`error`/`success`/`debug` shortcuts for `add()`).
7878

79-
Failed setup retries on the next activation, or on a dock update for eager scripts. Setup is cached per RPC connection, dock and import descriptor. Action clicks always execute again.
79+
Failed setup retries on the next activation, or on a dock update for eager scripts. Setup is cached per RPC connection, dock and import descriptor. Action clicks always execute again, except for a [toggle action](#toggle-actions).
80+
81+
### Toggle actions
82+
83+
Set `toggle: true` on an `action` entry to make it an on/off tool, such as an in-page element picker. A click turns it on and a second click turns it off. While it is on:
84+
85+
- the dock button shows as active (`current.isActive` is `true`);
86+
- the selected dock stays selected, and an open panel is hidden until the tool turns off;
87+
- `Escape` turns it off in the reference hub UI.
88+
89+
The script sets up once and drives the tool from `entry:activated` / `entry:deactivated`. Call `docks.setActionActive(id, false)` when the tool is done, which brings the hidden panel back. Selecting another dock, or `switchEntry(null)`, also turns the tool off, and then the panel stays as the new selection leaves it.
90+
91+
```ts
92+
// Dock entry: { type: 'action', toggle: true, action: { importFrom } }
93+
import type { DockClientScriptContext } from '@devframes/hub/client'
94+
95+
export default function setup(ctx: DockClientScriptContext) {
96+
const id = ctx.current.entryMeta.id
97+
ctx.current.events.on('entry:activated', () => {
98+
startPicker({ onPick: () => ctx.docks.setActionActive(id, false) })
99+
})
100+
ctx.current.events.on('entry:deactivated', () => stopPicker())
101+
}
102+
```
80103

81104
### Shipping a client script
82105

‎docs/content/1.guide/22.build-your-own-hub-ui.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ and loads dock client scripts. Honor:
3939
Render the built-in variants of the open dock union
4040
(`DevframeDockEntryRegistry`, `@devframes/hub/types`): `iframe` (the entry's
4141
`url` in a kept-alive iframe, honoring `subTabs` soft nav), `action` (a
42-
dock-rail button running its client script), `custom-render` (a container its
42+
dock-rail button running its client script, shown as active while a
43+
`toggle: true` action is on), `custom-render` (a container its
4344
client script mounts into), `launcher` (a launch call-to-action reflecting
4445
`launcher.status`), `group` (one dock-rail button collapsing its members), and
4546
`~builtin` (your native views for reserved ids). What to render per type is in

‎docs/content/8.references/6.hub-api.md‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ The properties of `DevframeClientContext`: [The client context](/guide/client-co
148148
|----------|-------------|
149149
| `rpc` | The [RPC client](/guide/client): server/client functions, shared state. |
150150
| `clientType` | `'embedded'` (inside the user app) or `'standalone'` (independent hub page). |
151-
| `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, `register()` / `update()` for [client-only docks](/guide/client-context#client-only-docks). |
151+
| `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `setActionActive(id, active)` for [toggle actions](/guide/client-context#toggle-actions), `getStateById()`, `register()` / `update()` for [client-only docks](/guide/client-context#client-only-docks). |
152152
| `panel` | Current `state`, local `events`, session, position, size, and drag/resize state for the dock panel. |
153153
| `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`, `paletteOpen`, `paletteScopeId`, `openPalette(atCommandId?)`. Passing an id opens the palette drilled into that command's children ([Activating a group](/guide/hub#activating-a-group)). |
154154
| `renderers` | Dock-renderer registry: `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a renderer (local boot or the hub's [manifest](/guide/hub-initiate#renderer-modules); local wins). `mount()` resolves a `status`: `mounted` (with `dispose`), `missing-renderer`, or `load-error` (with `error`). |
@@ -161,11 +161,11 @@ Which `ClientScriptEntry` field carries an entry's client script, and when it ru
161161

162162
| Entry kind | Field | Runs |
163163
|---|---|---|
164-
| `action` | `action` | when the dock button is activated |
164+
| `action` | `action` | on each click; once for a `toggle: true` action |
165165
| `custom-render` | `renderer` | to render the entry's panel |
166166
| `iframe` | `clientScript` (optional) | inside the host page on first activation |
167167

168-
`ClientScriptEntry.eager` defaults to `false`. Set it to `true` on an `iframe` `clientScript` or an `action` to initialize it after RPC trust, before dock activation. A `custom-render` `renderer` needs its mounted panel, so it always initializes on activation regardless of `eager`. Setup is cached per RPC connection and dock; action clicks execute on every activation.
168+
`ClientScriptEntry.eager` defaults to `false`. Set it to `true` on an `iframe` `clientScript` or an `action` to initialize it after RPC trust, before dock activation. A `custom-render` `renderer` needs its mounted panel, so it always initializes on activation regardless of `eager`. Setup is cached per RPC connection and dock; action clicks execute on every activation, except a `toggle: true` action, which sets up once.
169169

170170
## Frame-nav messages
171171

@@ -179,12 +179,12 @@ The origin-locked `postMessage` protocol on `devframe:frame-nav`: [Shared-iframe
179179

180180
## Dock entry types
181181

182-
The built-in variants of the open dock union (`DevframeDockEntryRegistry`, `@devframes/hub/types`) a hub UI provider renders: [Build Your Own Hub UI](/guide/build-your-own-hub-ui). Each entry's `icon` takes any of the [icon values](/references/node-api#icon-values). Each entry's `title` has an optional `titleLocales` map of translations, resolved with `resolveTitle(entry, locale)` from `@devframes/hub/client`; commands and launchers carry the same pair.
182+
The built-in variants of the open dock union (`DevframeDockEntryRegistry`, `@devframes/hub/types`) a hub UI provider renders: [Build Your Own Hub UI](/guide/build-your-own-hub-ui). Each entry's `icon` takes any of the [icon values](/references/node-api#icon-values). Each entry's `title` has an optional `titleLocales` map of translations, resolved with `resolveTitle(entry, locale)` from `@devframes/hub/client`; commands and launchers carry the same pair. An optional one-line `description` explains the entry; the reference hub UI shows it under the title in the dock button tooltip.
183183

184184
| Type | The hub UI provider renders |
185185
|---|---|
186186
| `iframe` | the entry's `url` in a kept-alive iframe (per `frameId` when shared); honor `subTabs` soft nav; show the existing address bar with `addressBar: true`, or configure Back, Reload, and Open externally by passing an `addressBar` object |
187-
| `action` | a dock-rail button; activating runs its client script |
187+
| `action` | a dock-rail button; activating runs its client script. With `toggle: true`, render it as active while `getStateById(id).isActive` is `true` |
188188
| `custom-render` | a container its client script mounts into |
189189
| `launcher` | a launch call-to-action reflecting `launcher.status` |
190190
| `group` | one dock-rail button collapsing its member entries |

‎packages/hub-ui/src/client/components/dock/Dock.vue‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,9 @@ const isMinimized = computed(() => {
226226
return false
227227
if (docksGroupPanel.value)
228228
return false
229+
// Keep an active tool's button in sight while it hides the panel.
230+
if (context.when.context.dockActionActive)
231+
return false
229232
if (context.panel.store.inactiveTimeout === 0)
230233
return true
231234
// @ts-expect-error compatibility

‎packages/hub-ui/src/client/components/dock/DockEdge.vue‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ const isCollapsed = computed(() => {
116116
return false
117117
if (hasPanelContent.value)
118118
return false
119+
// Keep an active tool's button in sight while it hides the panel.
120+
if (context.when.context.dockActionActive)
121+
return false
119122
if (store.inactiveTimeout === 0)
120123
return true
121124
// @ts-expect-error compatibility (mirrors Dock.vue's own touch-device check)

‎packages/hub-ui/src/client/components/dock/DockEntries.vue‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ function isDockVisible(dock: DevframeDockEntry): boolean {
4848
return true
4949
}
5050
51+
// Selected, or a toggle action that is on (it never takes the selection).
52+
function isDockActive(dock: DevframeDockEntry): boolean {
53+
return selected.value?.id === dock.id || !!props.context.docks.getStateById(dock.id)?.isActive
54+
}
55+
5156
function toggleDockEntry(dock: DevframeDockEntry) {
5257
if (selected.value?.id === dock.id)
5358
emit('select', undefined!)
@@ -73,8 +78,8 @@ function toggleDockEntry(dock: DevframeDockEntry) {
7378
:context="context"
7479
:dock
7580
:is-action="dock.type === 'action'"
76-
:is-selected="selected?.id === dock.id"
77-
:is-dimmed="dimInactive && selected ? (selected.id !== dock.id) : false"
81+
:is-selected="isDockActive(dock)"
82+
:is-dimmed="dimInactive && selected ? !isDockActive(dock) : false"
7883
:is-vertical="isVertical"
7984
:badge="dock.badge"
8085
:badge-variant="dock.badgeVariant"

‎packages/hub-ui/src/client/components/dock/DockEntry.vue‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { DevframeDockBadgeVariant, DevframeDockEntryBase } from '@devframes
33
import type { DocksContext } from '@devframes/hub/client'
44
import { useEventListener } from '@vueuse/core'
55
import { computed, useTemplateRef } from 'vue'
6-
import { setFloatingTooltip } from '../../state/floating-tooltip'
6+
import { dockTooltipContent, setFloatingTooltip } from '../../state/floating-tooltip'
77
import { openDockContextMenu } from './DockContextMenu'
88
import DockIcon from './DockIcon.vue'
99
@@ -51,7 +51,7 @@ function updateTooltip() {
5151
if (!button.value)
5252
return
5353
setFloatingTooltip({
54-
content: props.dock.title,
54+
content: dockTooltipContent(props.dock),
5555
el: button.value,
5656
})
5757
}

‎packages/hub-ui/src/client/components/dock/DockGroupButton.vue‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ const isActive = computed(() => {
4747
return !!id && members.value.some(m => m.id === id)
4848
})
4949
50+
// A member toggle action that is on lights the button up too, without owning the panel.
51+
const hasActiveTool = computed(() => members.value.some(m => m.type === 'action' && props.context.docks.getStateById(m.id)?.isActive))
52+
5053
const groupButton = useTemplateRef<HTMLElement>('groupButton')
5154
const isPanelVisible = ref(false)
5255
const docksGroupPanel = useDocksGroupPanel()
@@ -131,8 +134,8 @@ function onClick() {
131134
:context="context"
132135
:dock="group"
133136
:is-vertical="isVertical"
134-
:is-selected="isActive || isPanelVisible"
135-
:is-dimmed="dimInactive && selected ? !isActive : false"
137+
:is-selected="isActive || isPanelVisible || hasActiveTool"
138+
:is-dimmed="dimInactive && selected ? !isActive && !hasActiveTool : false"
136139
:badge="group.badge"
137140
:badge-variant="group.badgeVariant"
138141
@click="onClick"

‎packages/hub-ui/src/client/components/dock/DockGroupPopover.vue‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup }
33
import type { DocksContext } from '@devframes/hub/client'
44
import { computed } from 'vue'
55
import { t } from '../../i18n'
6+
import { dockTooltipContent, setFloatingTooltip } from '../../state/floating-tooltip'
67
import { accentVarStyle } from '../../utils/accent-color'
78
import { openDockContextMenu } from './DockContextMenu'
89
import DockIcon from './DockIcon.vue'
@@ -19,6 +20,16 @@ const emit = defineEmits<{
1920
(e: 'select', entry: DevframeDockEntry): void
2021
}>()
2122
23+
// A row already shows its title, so only a `description` is worth a tooltip.
24+
function showTooltip(event: PointerEvent, member: DevframeDockEntry) {
25+
if (member.description)
26+
setFloatingTooltip({ content: dockTooltipContent(member), el: event.currentTarget as HTMLElement, placement: 'right' })
27+
}
28+
29+
function hideTooltip() {
30+
setFloatingTooltip(null)
31+
}
32+
2233
const isEmpty = computed(() => props.members.every(([, items]) => items.length === 0))
2334
2435
function openContextMenu(e: MouseEvent, member: DevframeDockEntry) {
@@ -55,9 +66,11 @@ function openContextMenu(e: MouseEvent, member: DevframeDockEntry) {
5566
v-for="member of items"
5667
:key="member.id"
5768
class="flex items-center gap-2 w-full px2 py1.5 rounded text-sm text-left transition"
58-
:class="selectedId === member.id ? 'text-primary bg-active' : 'op80 hover:op100 hover:bg-active'"
59-
@click="emit('select', member)"
60-
@contextmenu="openContextMenu($event, member)"
69+
:class="selectedId === member.id || context.docks.getStateById(member.id)?.isActive ? 'text-primary bg-active' : 'op80 hover:op100 hover:bg-active'"
70+
@pointerenter="showTooltip($event, member)"
71+
@pointerleave="hideTooltip"
72+
@click="hideTooltip(); emit('select', member)"
73+
@contextmenu="hideTooltip(); openContextMenu($event, member)"
6174
>
6275
<DockIcon :icon="member.icon" class="w-4.5 h-4.5 flex-none" />
6376
<span class="truncate flex-1">{{ member.title }}</span>

‎packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { watchDebounced } from '@vueuse/core'
55
import { computed, h, ref, useTemplateRef, watch } from 'vue'
66
import { t } from '../../i18n'
77
import { deriveSidebarCapacity, docksSplitGroupsWithCapacity, getGroupMembersGrouped } from '../../state/dock-settings'
8-
import { setDocksSidebarOverflowPanel, setFloatingTooltip, useDocksSidebarOverflowPanel } from '../../state/floating-tooltip'
8+
import { dockTooltipContent, setDocksSidebarOverflowPanel, setFloatingTooltip, useDocksSidebarOverflowPanel } from '../../state/floating-tooltip'
99
import { useSettings } from '../../state/settings-defaults'
1010
import { accentVarStyle } from '../../utils/accent-color'
1111
import { openDockContextMenu } from './DockContextMenu'
@@ -92,8 +92,8 @@ function select(id: string) {
9292
props.context.docks.switchEntry(id)
9393
}
9494
95-
function showTooltip(event: PointerEvent, title: string) {
96-
setFloatingTooltip({ content: title, el: event.currentTarget as HTMLElement })
95+
function showTooltip(event: PointerEvent, entry: { title: string, description?: string }) {
96+
setFloatingTooltip({ content: dockTooltipContent(entry), el: event.currentTarget as HTMLElement })
9797
}
9898
9999
function hideTooltip() {
@@ -175,7 +175,7 @@ const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPa
175175
<div
176176
class="flex items-center justify-center w-8 h-8 op60"
177177
:class="group.accentColor ? 'color-active' : ''"
178-
@pointerenter="showTooltip($event, group.title)"
178+
@pointerenter="showTooltip($event, group)"
179179
@pointerleave="hideTooltip"
180180
>
181181
<DockIcon :icon="group.icon" class="w-5 h-5 flex-none" />
@@ -192,7 +192,7 @@ const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPa
192192
:aria-label="member.title"
193193
class="relative flex items-center justify-center w-8 h-8 rounded-lg transition"
194194
:class="selectedId === member.id ? 'text-primary bg-active' : 'op60 hover:op100 hover:bg-active'"
195-
@pointerenter="showTooltip($event, member.title)"
195+
@pointerenter="showTooltip($event, member)"
196196
@pointerleave="hideTooltip"
197197
@pointerdown="hideTooltip"
198198
@click="select(member.id)"

0 commit comments

Comments
 (0)