Skip to content

Commit 42ba3a3

Browse files
committed
feat(hub-ui): raise the recent dock out of the float bar's overflow
1 parent 867bcca commit 42ba3a3

7 files changed

Lines changed: 348 additions & 18 deletions

File tree

packages/hub-ui/src/client/components/dock/Dock.stories.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,22 @@ export const LowerCapacity: Story = {
133133
args: { maxVisibleItems: 3 },
134134
}
135135

136+
/**
137+
* A recent dock raised out of the overflow: selecting an entry from the
138+
* overflow popover reserves the slot between the divider and the overflow
139+
* button for it, and deselecting it (or selecting a visible neighbour) keeps
140+
* it there instead of folding it straight back into the overflow.
141+
*/
142+
export const WithRecentDock: Story = {
143+
...floatStory({
144+
entries: overflowEntries,
145+
selectedId: 'network',
146+
session: { recentDockId: 'network' },
147+
panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 },
148+
}),
149+
args: { maxVisibleItems: 3 },
150+
}
151+
136152
/** `maxVisibleItems: 9` — a higher capacity absorbs the overflow set inline. */
137153
export const HigherCapacity: Story = {
138154
...floatStory({ entries: overflowEntries, panel: { position: 'bottom', left: 50, top: 100, inactiveTimeout: -1 } }),

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

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import type { DocksContext } from '@devframes/hub/client'
33
import type { CSSProperties } from 'vue'
44
import type { DockLayout } from './dock-layout'
55
import { useEventListener, useScreenSafeArea, whenever } from '@vueuse/core'
6-
import { computed, onMounted, reactive, ref, useTemplateRef } from 'vue'
6+
import { computed, onMounted, reactive, ref, useTemplateRef, watch } from 'vue'
77
import { BUILTIN_ENTRY_CLIENT_AUTH_NOTICE } from '../../constants'
8-
import { docksSplitGroupsWithCapacity } from '../../state/dock-settings'
8+
import { docksSplitGroupsWithCapacity, getEntryGroup, resolveNextRecentDockId, resolveRecentDockEntry } from '../../state/dock-settings'
9+
import { sharedStateToRef } from '../../state/docks'
910
import { setDocksOverflowPanel, useDocksGroupPanel } from '../../state/floating-tooltip'
1011
import { useIsRpcTrusted } from '../../utils/useIsRpcTrusted'
1112
import BrandMark from '../icons/BrandMark.vue'
@@ -17,6 +18,7 @@ import {
1718
resolveViewportMargins,
1819
snapDockPercent,
1920
} from './dock-layout'
21+
import DockEntries from './DockEntries.vue'
2022
import DockEntriesWithCategories from './DockEntriesWithCategories.vue'
2123
import DockOverflowButton from './DockOverflowButton.vue'
2224
@@ -92,14 +94,46 @@ const isRpcTrusted = useIsRpcTrusted(context, (isTrusted) => {
9294
9395
const groupedEntries = computed(() => context.docks.groupedEntries)
9496
97+
const settings = sharedStateToRef(context.docks.settings)
98+
99+
// The recent dock: the entry last selected from beyond the bar — the overflow
100+
// popover or a group popover — raised into its own slot between the visible
101+
// items and the overflow button so deselecting it keeps it one click away.
102+
// Persisted per-tab in the session store; resolved to `null` when the id no
103+
// longer maps to a raisable entry.
104+
const recentEntry = computed(() => resolveRecentDockEntry({
105+
entries: context.docks.entries,
106+
groups: groupedEntries.value,
107+
recentId: context.panel.session.recentDockId,
108+
settings: settings.value,
109+
whenContext: context.when.context,
110+
}))
111+
95112
const splitEntries = computed(() => {
96-
return docksSplitGroupsWithCapacity(groupedEntries.value, layout.value.maxVisibleItems)
113+
return docksSplitGroupsWithCapacity(groupedEntries.value, layout.value.maxVisibleItems, recentEntry.value)
97114
})
98115
99116
const selectedEntry = computed(() => {
100117
return context.docks.selected
101118
})
102119
120+
// Remember the recent dock whenever the selection lands on an entry that has
121+
// no slot on the bar as currently rendered (grouped members always; top-level
122+
// entries when picked from the overflow popover). Observing the selection —
123+
// rather than the bar's own click handlers — also covers selections made via
124+
// the command palette, an RPC activation, or the session restore.
125+
watch(selectedEntry, (entry) => {
126+
if (!entry)
127+
return
128+
context.panel.session.recentDockId = resolveNextRecentDockId({
129+
groups: groupedEntries.value,
130+
capacity: layout.value.maxVisibleItems,
131+
recentEntry: recentEntry.value,
132+
selected: entry,
133+
selectedIsGroupMember: !!getEntryGroup(context.docks.entries, entry),
134+
})
135+
})
136+
103137
onMounted(async () => {
104138
windowSize.width = window.innerWidth
105139
windowSize.height = window.innerHeight
@@ -311,6 +345,14 @@ onMounted(() => {
311345

312346
<template v-if="splitEntries.overflow.length > 0">
313347
<div class="border-base m1 h-20px w-px border-r-1.5" />
348+
<DockEntries
349+
v-if="splitEntries.recent"
350+
:context="context"
351+
:entries="[splitEntries.recent]"
352+
:is-vertical="context.panel.isVertical"
353+
:selected="selectedEntry"
354+
@select="(e) => context.docks.switchEntry(e?.id)"
355+
/>
314356
<DockOverflowButton
315357
:context="context"
316358
:is-vertical="context.panel.isVertical"
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup } from '@devframes/hub'
2+
import { describe, expect, it } from 'vitest'
3+
import { docksSplitGroupsWithCapacity, resolveNextRecentDockId, resolveRecentDockEntry } from './dock-settings'
4+
5+
function iframe(id: string, extra: Partial<DevframeDockEntry> = {}): DevframeDockEntry {
6+
return { id, type: 'iframe', url: '/', title: id.toUpperCase(), icon: 'ph:cube-duotone', ...extra } as DevframeDockEntry
7+
}
8+
9+
function group(id: string, extra: Partial<DevframeViewGroup> = {}): DevframeDockEntry {
10+
return { id, type: 'group', title: id.toUpperCase(), icon: 'ph:folder-duotone', ...extra } as DevframeDockEntry
11+
}
12+
13+
function ids(groups: DevframeDockEntriesGrouped): string[] {
14+
return groups.flatMap(([, items]) => items.map(item => item.id))
15+
}
16+
17+
// Five top-level docks on a 3-slot bar: [a] [b] [c] | [overflow: d, e]
18+
const [a, b, c, d, e] = ['a', 'b', 'c', 'd', 'e'].map(id => iframe(id))
19+
const rail: DevframeDockEntriesGrouped = [['default', [a, b, c, d, e]]]
20+
21+
describe('docksSplitGroupsWithCapacity', () => {
22+
it('splits naturally without a recent entry', () => {
23+
const split = docksSplitGroupsWithCapacity(rail, 3)
24+
expect(ids(split.visible)).toEqual(['a', 'b', 'c'])
25+
expect(ids(split.overflow)).toEqual(['d', 'e'])
26+
expect(split.recent).toBeNull()
27+
})
28+
29+
it('reserves a slot for a recent entry from the overflow', () => {
30+
const split = docksSplitGroupsWithCapacity(rail, 3, d)
31+
expect(ids(split.visible)).toEqual(['a', 'b'])
32+
expect(ids(split.overflow)).toEqual(['c', 'e'])
33+
expect(split.recent).toBe(d)
34+
})
35+
36+
it('renders a recent entry inside the natural slice in place, releasing the slot', () => {
37+
const split = docksSplitGroupsWithCapacity(rail, 3, c)
38+
expect(ids(split.visible)).toEqual(['a', 'b', 'c'])
39+
expect(ids(split.overflow)).toEqual(['d', 'e'])
40+
expect(split.recent).toBeNull()
41+
})
42+
43+
it('raises a grouped member without touching the rail items in the overflow', () => {
44+
const g = group('g')
45+
const member = iframe('g:member', { groupId: 'g' })
46+
const groupedRail: DevframeDockEntriesGrouped = [['default', [a, b, g, c, d]]]
47+
const split = docksSplitGroupsWithCapacity(groupedRail, 3, member)
48+
expect(ids(split.visible)).toEqual(['a', 'b'])
49+
expect(ids(split.overflow)).toEqual(['g', 'c', 'd'])
50+
expect(split.recent).toBe(member)
51+
})
52+
53+
it('skips the reservation when there is no overflow', () => {
54+
const member = iframe('g:member', { groupId: 'g' })
55+
const smallRail: DevframeDockEntriesGrouped = [['default', [a, b, c]]]
56+
const split = docksSplitGroupsWithCapacity(smallRail, 3, member)
57+
expect(ids(split.visible)).toEqual(['a', 'b', 'c'])
58+
expect(split.overflow).toEqual([])
59+
expect(split.recent).toBeNull()
60+
})
61+
62+
it('keeps the lone-overflow fold ahead of the reservation', () => {
63+
const foldRail: DevframeDockEntriesGrouped = [['default', [a, b, c, d]]]
64+
const split = docksSplitGroupsWithCapacity(foldRail, 3, d)
65+
expect(ids(split.visible)).toEqual(['a', 'b', 'c', 'd'])
66+
expect(split.overflow).toEqual([])
67+
expect(split.recent).toBeNull()
68+
})
69+
})
70+
71+
describe('resolveNextRecentDockId', () => {
72+
function next(recentEntry: DevframeDockEntry | null, selected: DevframeDockEntry, selectedIsGroupMember = false) {
73+
return resolveNextRecentDockId({ groups: rail, capacity: 3, recentEntry, selected, selectedIsGroupMember })
74+
}
75+
76+
it('follows the a–e walkthrough on a 3-slot bar', () => {
77+
// [a] [b] [c] | [O] — selecting d from the overflow raises it
78+
expect(next(null, d)).toBe('d')
79+
// [a] [b] | {d} [O] — selecting visible a/b (or d itself) keeps d raised
80+
expect(next(d, a)).toBe('d')
81+
expect(next(d, b)).toBe('d')
82+
expect(next(d, d)).toBe('d')
83+
// [a] [b] | [d] [O] — selecting e from the overflow replaces d
84+
expect(next(d, e)).toBe('e')
85+
// selecting c (folded out by the reserved slot) becomes recent, and the
86+
// split renders it in its natural place — back to [a] [b] {c} | [O]
87+
expect(next(d, c)).toBe('c')
88+
expect(docksSplitGroupsWithCapacity(rail, 3, c).recent).toBeNull()
89+
})
90+
91+
it('always raises a grouped member', () => {
92+
const member = iframe('g:member', { groupId: 'g' })
93+
expect(next(null, member, true)).toBe('g:member')
94+
expect(next(d, member, true)).toBe('g:member')
95+
})
96+
97+
it('keeps the recent dock when the selection is off the rail entirely', () => {
98+
expect(next(d, iframe('~notice'))).toBe('d')
99+
expect(next(null, iframe('~notice'))).toBeNull()
100+
})
101+
102+
it('keeps a naturally-visible selection from claiming the slot', () => {
103+
expect(next(null, a)).toBeNull()
104+
})
105+
})
106+
107+
describe('resolveRecentDockEntry', () => {
108+
const g = group('g')
109+
const member = iframe('g:member', { groupId: 'g' })
110+
const entries = [a, b, c, d, e, g, member]
111+
const groupedRail: DevframeDockEntriesGrouped = [['default', [a, b, c, d, e, g]]]
112+
113+
function resolve(recentId: string | null) {
114+
return resolveRecentDockEntry({ entries, groups: groupedRail, recentId })
115+
}
116+
117+
it('resolves a top-level rail entry', () => {
118+
expect(resolve('d')).toBe(d)
119+
})
120+
121+
it('resolves a grouped member', () => {
122+
expect(resolve('g:member')).toBe(member)
123+
})
124+
125+
it('returns null for no id, an unknown id, or a group button', () => {
126+
expect(resolve(null)).toBeNull()
127+
expect(resolve('missing')).toBeNull()
128+
expect(resolve('g')).toBeNull()
129+
})
130+
131+
it('returns null for an entry that is off the rail', () => {
132+
const hidden = iframe('hidden')
133+
expect(resolveRecentDockEntry({ entries: [...entries, hidden], groups: groupedRail, recentId: 'hidden' })).toBeNull()
134+
})
135+
})

0 commit comments

Comments
 (0)