Skip to content

Commit 081279a

Browse files
authored
feat(core): fold group side nav overflow into a height-based show-more (#467)
1 parent 9834ca0 commit 081279a

7 files changed

Lines changed: 276 additions & 12 deletions

File tree

packages/core/src/client/webcomponents/.generated/css.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/core/src/client/webcomponents/components/dock/DockGroupSidebar.stories.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import type { Meta, StoryObj } from '@storybook/vue3-vite'
22
import type { DevToolsViewGroup } from '@vitejs/devtools-kit'
33
import { h } from 'vue'
4-
import { groupedEntries } from '../../stories/fixtures'
4+
import { groupedEntries, subcategorizedGroupEntries, toolsGroup } from '../../stories/fixtures'
55
import { mountWithContext, stage } from '../../stories/story-helpers'
66
import DockGroupSidebar from './DockGroupSidebar.vue'
77

88
const nuxtGroup = groupedEntries.find(e => e.id === 'nuxt') as DevToolsViewGroup
99

10-
/** A bordered, tall shell that mimics the panel the sidebar lives inside. */
11-
function shell(children: any) {
12-
return h('div', { class: 'flex h-80 bg-glass color-base border border-base rounded-lg shadow overflow-hidden font-sans' }, [
10+
/** A bordered shell that mimics the panel the sidebar lives inside. */
11+
function shell(children: any, heightClass = 'h-80') {
12+
return h('div', { class: `flex ${heightClass} bg-glass color-base border border-base rounded-lg shadow overflow-hidden font-sans` }, [
1313
children,
1414
h('div', { class: 'flex-1 flex items-center justify-center op40 text-sm' }, 'panel body'),
1515
])
@@ -54,3 +54,22 @@ export const WithSelection: Story = {
5454
),
5555
}),
5656
}
57+
58+
/**
59+
* A short frame folds the members that don't fit into a bottom "show more"
60+
* button (with a count badge). Here the active member (`tools:graph`) lands in
61+
* the overflow, so the show-more button is highlighted to flag the hidden
62+
* selection.
63+
*/
64+
export const Overflow: Story = {
65+
render: () => ({
66+
setup: () => mountWithContext(
67+
{ entries: subcategorizedGroupEntries, selectedId: 'tools:graph' },
68+
ctx => stage(shell(h(DockGroupSidebar, {
69+
context: ctx,
70+
group: toolsGroup,
71+
selectedId: ctx.docks.selectedId,
72+
}), 'h-44')),
73+
),
74+
}),
75+
}

packages/core/src/client/webcomponents/components/dock/DockGroupSidebar.vue

Lines changed: 114 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
<script setup lang="ts">
2-
import type { DevToolsViewGroup } from '@vitejs/devtools-kit'
2+
import type { DevToolsDockEntry, DevToolsViewGroup } from '@vitejs/devtools-kit'
33
import type { DocksContext } from '@vitejs/devtools-kit/client'
4-
import { computed } from 'vue'
5-
import { getGroupMembersGrouped } from '../../state/dock-settings'
4+
import { useElementBounding, watchDebounced } from '@vueuse/core'
5+
import { computed, h, ref, useTemplateRef } from 'vue'
6+
import { deriveSidebarCapacity, docksSplitGroupsWithCapacity, getGroupMembersGrouped } from '../../state/dock-settings'
67
import { sharedStateToRef } from '../../state/docks'
7-
import { setFloatingTooltip } from '../../state/floating-tooltip'
8+
import { setDocksSidebarOverflowPanel, setFloatingTooltip, useDocksSidebarOverflowPanel } from '../../state/floating-tooltip'
9+
import DockGroupPopover from './DockGroupPopover.vue'
810
import DockIcon from './DockIcon.vue'
911
1012
const props = defineProps<{
@@ -13,6 +15,15 @@ const props = defineProps<{
1315
selectedId: string | null
1416
}>()
1517
18+
// Vertical rhythm of the rail, in px. Mirrors the Uno classes on the template:
19+
// each button is `w-8 h-8` (32) and children sit in a `gap-0.5` (2) flex column;
20+
// the root has `py1.5` (6+6) padding; dividers are `h-px my0.5` (1 + 2*2).
21+
const ITEM_HEIGHT = 34 // 32 button + 2 gap
22+
const MORE_BUTTON_HEIGHT = 34 // matches a member button + gap
23+
const DIVIDER_HEIGHT = 7 // 5 divider + 2 gap
24+
// Root padding (12) + anchor (32) + gap (2) + anchor divider (5) + gap (2).
25+
const RESERVED_HEIGHT = 53
26+
1627
const settings = sharedStateToRef(props.context.docks.settings)
1728
1829
// Members split by in-group sub-category so the sidebar can divide sections.
@@ -23,6 +34,35 @@ const memberGroups = computed(() => getGroupMembersGrouped(
2334
{ whenContext: props.context.when.context },
2435
))
2536
37+
const sidebar = useTemplateRef<HTMLDivElement>('sidebar')
38+
const { height: frameHeight } = useElementBounding(sidebar)
39+
40+
const totalItems = computed(() => memberGroups.value.reduce((acc, [, items]) => acc + items.length, 0))
41+
42+
// How many members the current frame height can hold before folding the rest
43+
// into the show-more popover.
44+
const capacity = computed(() => deriveSidebarCapacity({
45+
availableHeight: frameHeight.value,
46+
reservedHeight: RESERVED_HEIGHT,
47+
itemHeight: ITEM_HEIGHT,
48+
dividerHeight: DIVIDER_HEIGHT,
49+
moreButtonHeight: MORE_BUTTON_HEIGHT,
50+
dividerCount: Math.max(0, memberGroups.value.filter(([, items]) => items.length).length - 1),
51+
totalItems: totalItems.value,
52+
}))
53+
54+
const split = computed(() => docksSplitGroupsWithCapacity(memberGroups.value, capacity.value))
55+
const visibleGroups = computed(() => split.value.visible)
56+
const overflowGroups = computed(() => split.value.overflow)
57+
58+
const overflowCount = computed(() => overflowGroups.value.reduce((acc, [, items]) => acc + items.length, 0))
59+
const hasOverflow = computed(() => overflowCount.value > 0)
60+
const overflowBadge = computed(() => (overflowCount.value > 9 ? '9+' : overflowCount.value.toString()))
61+
62+
// The current selection is hidden inside the show-more popover.
63+
const selectedInOverflow = computed(() =>
64+
!!props.selectedId && overflowGroups.value.some(([, items]) => items.some(m => m.id === props.selectedId)))
65+
2666
function select(id: string) {
2767
props.context.docks.switchEntry(id)
2868
}
@@ -34,10 +74,61 @@ function showTooltip(event: PointerEvent, title: string) {
3474
function hideTooltip() {
3575
setFloatingTooltip(null)
3676
}
77+
78+
// --- Show more (overflow) flyout ---
79+
const moreButton = useTemplateRef<HTMLButtonElement>('moreButton')
80+
const isOverflowPanelVisible = ref(false)
81+
const overflowPanel = useDocksSidebarOverflowPanel()
82+
83+
function showOverflowPanel() {
84+
if (!moreButton.value)
85+
return
86+
isOverflowPanelVisible.value = true
87+
setDocksSidebarOverflowPanel({
88+
el: moreButton.value,
89+
placement: 'right',
90+
content: () => h(DockGroupPopover, {
91+
context: props.context,
92+
group: props.group,
93+
// Only the members that didn't fit on the rail.
94+
members: overflowGroups.value,
95+
selectedId: props.selectedId,
96+
onSelect: (entry: DevToolsDockEntry) => {
97+
select(entry.id)
98+
hideOverflowPanel()
99+
},
100+
}),
101+
})
102+
}
103+
104+
function hideOverflowPanel() {
105+
isOverflowPanelVisible.value = false
106+
setDocksSidebarOverflowPanel(null)
107+
}
108+
109+
function toggleOverflowPanel() {
110+
if (isOverflowPanelVisible.value)
111+
hideOverflowPanel()
112+
else
113+
showOverflowPanel()
114+
}
115+
116+
// Sync internal visibility from the store on a delay so it doesn't race the
117+
// "click outside" dismissal (same pattern as DockOverflowButton).
118+
watchDebounced(
119+
() => overflowPanel.value,
120+
(value) => {
121+
isOverflowPanelVisible.value = value?.el === moreButton.value
122+
},
123+
{ debounce: 1000 },
124+
)
125+
126+
// Highlight the button when the hidden selection lives here, or while open.
127+
const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPanelVisible.value)
37128
</script>
38129

39130
<template>
40-
<div class="vite-devtools-group-sidebar flex flex-col flex-none w-12 h-full border-r border-base of-y-auto of-x-hidden select-none py1.5 items-center gap-0.5">
131+
<div ref="sidebar" class="vite-devtools-group-sidebar flex flex-col flex-none w-12 h-full border-r border-base of-y-hidden of-x-hidden select-none py1.5 items-center gap-0.5">
41132
<!-- Group anchor -->
42133
<div
43134
class="flex items-center justify-center w-8 h-8 op60"
@@ -49,7 +140,7 @@ function hideTooltip() {
49140
<div class="w-8 h-px border-t border-base my0.5" />
50141

51142
<!-- Member icons, grouped by in-group sub-category -->
52-
<template v-for="([category, members], idx) of memberGroups" :key="category">
143+
<template v-for="([category, members], idx) of visibleGroups" :key="category">
53144
<!-- Sub-category divider, mirroring the group anchor separator -->
54145
<div v-if="idx > 0 && members.length" class="w-8 h-px border-t border-base my0.5" />
55146
<button
@@ -71,5 +162,22 @@ function hideTooltip() {
71162
</div>
72163
</button>
73164
</template>
165+
166+
<!-- Show more: members that don't fit the current frame height -->
167+
<button
168+
v-if="hasOverflow"
169+
ref="moreButton"
170+
class="relative flex items-center justify-center w-8 h-8 rounded-lg transition mt-auto flex-none"
171+
:class="moreButtonActive ? 'text-primary bg-active' : 'op60 hover:op100 hover:bg-active'"
172+
@pointerenter="showTooltip($event, 'Show more')"
173+
@pointerleave="hideTooltip"
174+
@pointerdown="hideTooltip"
175+
@click="toggleOverflowPanel"
176+
>
177+
<DockIcon icon="ph:dots-three-circle-duotone" class="w-5 h-5 flex-none" />
178+
<div class="absolute top-0.5 right-0.5 bg-gray-6 text-white text-0.6em px-0.5 rounded-full shadow leading-none">
179+
{{ overflowBadge }}
180+
</div>
181+
</button>
74182
</div>
75183
</template>

packages/core/src/client/webcomponents/components/floating/FloatingElements.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
<script setup lang="ts">
22
import { useEventListener } from '@vueuse/core'
3-
import { setDockContextMenu, setDocksGroupPanel, setDocksOverflowPanel, setEdgePositionDropdown, useDockContextMenu, useDocksGroupPanel, useDocksOverflowPanel, useEdgePositionDropdown, useFloatingTooltip } from '../../state/floating-tooltip'
3+
import { setDockContextMenu, setDocksGroupPanel, setDocksOverflowPanel, setDocksSidebarOverflowPanel, setEdgePositionDropdown, useDockContextMenu, useDocksGroupPanel, useDocksOverflowPanel, useDocksSidebarOverflowPanel, useEdgePositionDropdown, useFloatingTooltip } from '../../state/floating-tooltip'
44
import FloatingPopover from './FloatingPopover'
55
66
const tooltip = useFloatingTooltip()
77
const docksOverflowPanel = useDocksOverflowPanel()
8+
const docksSidebarOverflowPanel = useDocksSidebarOverflowPanel()
89
const docksGroupPanel = useDocksGroupPanel()
910
const dockContextMenu = useDockContextMenu()
1011
const edgePositionDropdown = useEdgePositionDropdown()
@@ -19,6 +20,7 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
1920

2021
<template>
2122
<FloatingPopover :item="docksOverflowPanel" @dismiss="() => setDocksOverflowPanel(null)" />
23+
<FloatingPopover :item="docksSidebarOverflowPanel" @dismiss="() => setDocksSidebarOverflowPanel(null)" />
2224
<FloatingPopover :item="docksGroupPanel" @dismiss="() => setDocksGroupPanel(null)" />
2325
<FloatingPopover :item="dockContextMenu" @dismiss="() => setDockContextMenu(null)" />
2426
<FloatingPopover :item="edgePositionDropdown" @dismiss="() => setEdgePositionDropdown(null)" />
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { DevToolsDockEntriesGrouped, DevToolsDockEntry } from '@vitejs/devtools-kit'
2+
import { describe, expect, it } from 'vitest'
3+
import { deriveSidebarCapacity, docksSplitGroupsWithCapacity } from '../dock-settings'
4+
5+
function iframe(id: string): DevToolsDockEntry {
6+
return { id, type: 'iframe', url: '/', title: id, icon: 'i' } as DevToolsDockEntry
7+
}
8+
9+
// Rhythm constants mirroring DockGroupSidebar.vue.
10+
const BASE = {
11+
reservedHeight: 53,
12+
itemHeight: 34,
13+
dividerHeight: 7,
14+
moreButtonHeight: 34,
15+
dividerCount: 0,
16+
}
17+
18+
describe('deriveSidebarCapacity', () => {
19+
it('returns 0 when there are no members', () => {
20+
expect(deriveSidebarCapacity({ ...BASE, availableHeight: 1000, totalItems: 0 })).toBe(0)
21+
})
22+
23+
it('returns the full count when every member fits without a show-more button', () => {
24+
// 53 reserved + 5 items * 34 = 223 → 230 leaves room for all 5, no button.
25+
expect(deriveSidebarCapacity({ ...BASE, availableHeight: 230, totalItems: 5 })).toBe(5)
26+
})
27+
28+
it('reserves the show-more button slot only once overflow is unavoidable', () => {
29+
// budget = 200 - 53 = 147. Without button: floor(147/34) = 4 < 6 total → overflow.
30+
// With button reserved: floor((147 - 34)/34) = floor(113/34) = 3.
31+
expect(deriveSidebarCapacity({ ...BASE, availableHeight: 200, totalItems: 6 })).toBe(3)
32+
})
33+
34+
it('never returns negative capacity when the frame is tiny', () => {
35+
expect(deriveSidebarCapacity({ ...BASE, availableHeight: 40, totalItems: 6 })).toBe(0)
36+
})
37+
38+
it('subtracts sub-category divider height from the budget', () => {
39+
// budget = 300 - 53 - 2*7 = 233. Without button: floor(233/34) = 6 < 8 → overflow.
40+
// With button: floor((233 - 34)/34) = floor(199/34) = 5.
41+
expect(deriveSidebarCapacity({ ...BASE, availableHeight: 300, dividerCount: 2, totalItems: 8 })).toBe(5)
42+
})
43+
44+
it('is a no-op split at full capacity (all visible, nothing overflowed)', () => {
45+
const groups: DevToolsDockEntriesGrouped = [['default', [iframe('a'), iframe('b')]]]
46+
const cap = deriveSidebarCapacity({ ...BASE, availableHeight: 500, totalItems: 2 })
47+
const { visible, overflow } = docksSplitGroupsWithCapacity(groups, cap)
48+
expect(visible).toEqual(groups)
49+
expect(overflow).toEqual([])
50+
})
51+
})
52+
53+
describe('docksSplitGroupsWithCapacity (sidebar overflow)', () => {
54+
it('folds members beyond capacity into overflow, preserving order', () => {
55+
const groups: DevToolsDockEntriesGrouped = [['default', [iframe('a'), iframe('b'), iframe('c')]]]
56+
const { visible, overflow } = docksSplitGroupsWithCapacity(groups, 2)
57+
expect(visible).toEqual([['default', [iframe('a'), iframe('b')]]])
58+
expect(overflow).toEqual([['default', [iframe('c')]]])
59+
})
60+
61+
it('splits across sub-categories once the first fills capacity', () => {
62+
const groups: DevToolsDockEntriesGrouped = [
63+
['app', [iframe('a'), iframe('b')]],
64+
['web', [iframe('c'), iframe('d')]],
65+
]
66+
const { visible, overflow } = docksSplitGroupsWithCapacity(groups, 3)
67+
expect(visible).toEqual([['app', [iframe('a'), iframe('b')]], ['web', [iframe('c')]]])
68+
expect(overflow).toEqual([['web', [iframe('d')]]])
69+
})
70+
})

packages/core/src/client/webcomponents/state/dock-settings.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,56 @@ export function docksGroupByCategories(
222222
return grouped
223223
}
224224

225+
export interface SidebarCapacityOptions {
226+
/** Measured height of the rail root, in px. */
227+
availableHeight: number
228+
/**
229+
* Fixed vertical overhead that is always present regardless of member count:
230+
* the root's padding, the pinned group anchor, and the anchor divider.
231+
*/
232+
reservedHeight: number
233+
/** Height of one member button, including its inter-item gap. */
234+
itemHeight: number
235+
/** Height of one sub-category divider, including its gaps. */
236+
dividerHeight: number
237+
/** Height of the "show more" button, including its gap. */
238+
moreButtonHeight: number
239+
/** Number of sub-category dividers that could render (sub-categories − 1). */
240+
dividerCount: number
241+
/** Total member count across every sub-category. */
242+
totalItems: number
243+
}
244+
245+
/**
246+
* Derive how many group side nav member buttons fit in the measured rail height.
247+
*
248+
* Two-pass: first test whether every member fits with no show-more button; if
249+
* they do, the full count is returned (no button, no overflow). Otherwise the
250+
* show-more button's height is reserved and the capacity recomputed, so the
251+
* button only ever costs a slot when it is actually shown.
252+
*
253+
* The sub-category divider budget is subtracted up front for every divider that
254+
* might render, keeping the estimate conservative — the rail may fold one
255+
* member early into the popover, but it never clips.
256+
*/
257+
export function deriveSidebarCapacity(options: SidebarCapacityOptions): number {
258+
const { availableHeight, reservedHeight, itemHeight, dividerHeight, moreButtonHeight, dividerCount, totalItems } = options
259+
260+
if (totalItems <= 0 || itemHeight <= 0)
261+
return 0
262+
263+
const budget = availableHeight - reservedHeight - Math.max(0, dividerCount) * dividerHeight
264+
265+
// Pass 1: does everything fit without a show-more button?
266+
const fitWithoutButton = Math.floor(budget / itemHeight)
267+
if (fitWithoutButton >= totalItems)
268+
return totalItems
269+
270+
// Pass 2: overflow is unavoidable, so reserve the show-more button's slot.
271+
const fitWithButton = Math.floor((budget - moreButtonHeight) / itemHeight)
272+
return Math.max(0, fitWithButton)
273+
}
274+
225275
/**
226276
* Split grouped entries into visible and overflow based on capacity.
227277
*/

packages/core/src/client/webcomponents/state/floating-tooltip.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface FloatingPopoverProps {
1010

1111
const tooltip = shallowRef<FloatingPopoverProps | null>(null)
1212
const docksOverflowPanel = shallowRef<FloatingPopoverProps | null>(null)
13+
const docksSidebarOverflowPanel = shallowRef<FloatingPopoverProps | null>(null)
1314
const dockContextMenu = shallowRef<FloatingPopoverProps | null>(null)
1415

1516
export function setFloatingTooltip(info: FloatingPopoverProps | null) {
@@ -28,6 +29,20 @@ export function useDocksOverflowPanel() {
2829
return docksOverflowPanel
2930
}
3031

32+
/**
33+
* Dedicated slot for the group side nav's height-based "show more" flyout,
34+
* kept separate from {@link docksOverflowPanel} (the dock bar's overflow) and
35+
* {@link docksGroupPanel} (the bar group button's popover) so the rail's
36+
* show-more never fights those features over one shared ref.
37+
*/
38+
export function setDocksSidebarOverflowPanel(info: FloatingPopoverProps | null) {
39+
docksSidebarOverflowPanel.value = info
40+
}
41+
42+
export function useDocksSidebarOverflowPanel() {
43+
return docksSidebarOverflowPanel
44+
}
45+
3146
const docksGroupPanel = shallowRef<FloatingPopoverProps | null>(null)
3247

3348
export function setDocksGroupPanel(info: FloatingPopoverProps | null) {

0 commit comments

Comments
 (0)