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: 1 addition & 1 deletion packages/core/src/client/webcomponents/.generated/css.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { DevToolsViewGroup } from '@vitejs/devtools-kit'
import { h } from 'vue'
import { groupedEntries } from '../../stories/fixtures'
import { groupedEntries, subcategorizedGroupEntries, toolsGroup } from '../../stories/fixtures'
import { mountWithContext, stage } from '../../stories/story-helpers'
import DockGroupSidebar from './DockGroupSidebar.vue'

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

/** A bordered, tall shell that mimics the panel the sidebar lives inside. */
function shell(children: any) {
return h('div', { class: 'flex h-80 bg-glass color-base border border-base rounded-lg shadow overflow-hidden font-sans' }, [
/** A bordered shell that mimics the panel the sidebar lives inside. */
function shell(children: any, heightClass = 'h-80') {
return h('div', { class: `flex ${heightClass} bg-glass color-base border border-base rounded-lg shadow overflow-hidden font-sans` }, [
children,
h('div', { class: 'flex-1 flex items-center justify-center op40 text-sm' }, 'panel body'),
])
Expand Down Expand Up @@ -54,3 +54,22 @@ export const WithSelection: Story = {
),
}),
}

/**
* A short frame folds the members that don't fit into a bottom "show more"
* button (with a count badge). Here the active member (`tools:graph`) lands in
* the overflow, so the show-more button is highlighted to flag the hidden
* selection.
*/
export const Overflow: Story = {
render: () => ({
setup: () => mountWithContext(
{ entries: subcategorizedGroupEntries, selectedId: 'tools:graph' },
ctx => stage(shell(h(DockGroupSidebar, {
context: ctx,
group: toolsGroup,
selectedId: ctx.docks.selectedId,
}), 'h-44')),
),
}),
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script setup lang="ts">
import type { DevToolsViewGroup } from '@vitejs/devtools-kit'
import type { DevToolsDockEntry, DevToolsViewGroup } from '@vitejs/devtools-kit'
import type { DocksContext } from '@vitejs/devtools-kit/client'
import { computed } from 'vue'
import { getGroupMembersGrouped } from '../../state/dock-settings'
import { useElementBounding, watchDebounced } from '@vueuse/core'
import { computed, h, ref, useTemplateRef } from 'vue'
import { deriveSidebarCapacity, docksSplitGroupsWithCapacity, getGroupMembersGrouped } from '../../state/dock-settings'
import { sharedStateToRef } from '../../state/docks'
import { setFloatingTooltip } from '../../state/floating-tooltip'
import { setDocksSidebarOverflowPanel, setFloatingTooltip, useDocksSidebarOverflowPanel } from '../../state/floating-tooltip'
import DockGroupPopover from './DockGroupPopover.vue'
import DockIcon from './DockIcon.vue'

const props = defineProps<{
Expand All @@ -13,6 +15,15 @@ const props = defineProps<{
selectedId: string | null
}>()

// Vertical rhythm of the rail, in px. Mirrors the Uno classes on the template:
// each button is `w-8 h-8` (32) and children sit in a `gap-0.5` (2) flex column;
// the root has `py1.5` (6+6) padding; dividers are `h-px my0.5` (1 + 2*2).
const ITEM_HEIGHT = 34 // 32 button + 2 gap
const MORE_BUTTON_HEIGHT = 34 // matches a member button + gap
const DIVIDER_HEIGHT = 7 // 5 divider + 2 gap
// Root padding (12) + anchor (32) + gap (2) + anchor divider (5) + gap (2).
const RESERVED_HEIGHT = 53

const settings = sharedStateToRef(props.context.docks.settings)

// Members split by in-group sub-category so the sidebar can divide sections.
Expand All @@ -23,6 +34,35 @@ const memberGroups = computed(() => getGroupMembersGrouped(
{ whenContext: props.context.when.context },
))

const sidebar = useTemplateRef<HTMLDivElement>('sidebar')
const { height: frameHeight } = useElementBounding(sidebar)

const totalItems = computed(() => memberGroups.value.reduce((acc, [, items]) => acc + items.length, 0))

// How many members the current frame height can hold before folding the rest
// into the show-more popover.
const capacity = computed(() => deriveSidebarCapacity({
availableHeight: frameHeight.value,
reservedHeight: RESERVED_HEIGHT,
itemHeight: ITEM_HEIGHT,
dividerHeight: DIVIDER_HEIGHT,
moreButtonHeight: MORE_BUTTON_HEIGHT,
dividerCount: Math.max(0, memberGroups.value.filter(([, items]) => items.length).length - 1),
totalItems: totalItems.value,
}))

const split = computed(() => docksSplitGroupsWithCapacity(memberGroups.value, capacity.value))
const visibleGroups = computed(() => split.value.visible)
const overflowGroups = computed(() => split.value.overflow)

const overflowCount = computed(() => overflowGroups.value.reduce((acc, [, items]) => acc + items.length, 0))
const hasOverflow = computed(() => overflowCount.value > 0)
const overflowBadge = computed(() => (overflowCount.value > 9 ? '9+' : overflowCount.value.toString()))

// The current selection is hidden inside the show-more popover.
const selectedInOverflow = computed(() =>
!!props.selectedId && overflowGroups.value.some(([, items]) => items.some(m => m.id === props.selectedId)))

function select(id: string) {
props.context.docks.switchEntry(id)
}
Expand All @@ -34,10 +74,61 @@ function showTooltip(event: PointerEvent, title: string) {
function hideTooltip() {
setFloatingTooltip(null)
}

// --- Show more (overflow) flyout ---
const moreButton = useTemplateRef<HTMLButtonElement>('moreButton')
const isOverflowPanelVisible = ref(false)
const overflowPanel = useDocksSidebarOverflowPanel()

function showOverflowPanel() {
if (!moreButton.value)
return
isOverflowPanelVisible.value = true
setDocksSidebarOverflowPanel({
el: moreButton.value,
placement: 'right',
content: () => h(DockGroupPopover, {
context: props.context,
group: props.group,
// Only the members that didn't fit on the rail.
members: overflowGroups.value,
selectedId: props.selectedId,
onSelect: (entry: DevToolsDockEntry) => {
select(entry.id)
hideOverflowPanel()
},
}),
})
}

function hideOverflowPanel() {
isOverflowPanelVisible.value = false
setDocksSidebarOverflowPanel(null)
}

function toggleOverflowPanel() {
if (isOverflowPanelVisible.value)
hideOverflowPanel()
else
showOverflowPanel()
}

// Sync internal visibility from the store on a delay so it doesn't race the
// "click outside" dismissal (same pattern as DockOverflowButton).
watchDebounced(
() => overflowPanel.value,
(value) => {
isOverflowPanelVisible.value = value?.el === moreButton.value
},
{ debounce: 1000 },
)

// Highlight the button when the hidden selection lives here, or while open.
const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPanelVisible.value)
</script>

<template>
<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">
<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">
<!-- Group anchor -->
<div
class="flex items-center justify-center w-8 h-8 op60"
Expand All @@ -49,7 +140,7 @@ function hideTooltip() {
<div class="w-8 h-px border-t border-base my0.5" />

<!-- Member icons, grouped by in-group sub-category -->
<template v-for="([category, members], idx) of memberGroups" :key="category">
<template v-for="([category, members], idx) of visibleGroups" :key="category">
<!-- Sub-category divider, mirroring the group anchor separator -->
<div v-if="idx > 0 && members.length" class="w-8 h-px border-t border-base my0.5" />
<button
Expand All @@ -71,5 +162,22 @@ function hideTooltip() {
</div>
</button>
</template>

<!-- Show more: members that don't fit the current frame height -->
<button
v-if="hasOverflow"
ref="moreButton"
class="relative flex items-center justify-center w-8 h-8 rounded-lg transition mt-auto flex-none"
:class="moreButtonActive ? 'text-primary bg-active' : 'op60 hover:op100 hover:bg-active'"
@pointerenter="showTooltip($event, 'Show more')"
@pointerleave="hideTooltip"
@pointerdown="hideTooltip"
@click="toggleOverflowPanel"
>
<DockIcon icon="ph:dots-three-circle-duotone" class="w-5 h-5 flex-none" />
<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">
{{ overflowBadge }}
</div>
</button>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { setDockContextMenu, setDocksGroupPanel, setDocksOverflowPanel, setEdgePositionDropdown, useDockContextMenu, useDocksGroupPanel, useDocksOverflowPanel, useEdgePositionDropdown, useFloatingTooltip } from '../../state/floating-tooltip'
import { setDockContextMenu, setDocksGroupPanel, setDocksOverflowPanel, setDocksSidebarOverflowPanel, setEdgePositionDropdown, useDockContextMenu, useDocksGroupPanel, useDocksOverflowPanel, useDocksSidebarOverflowPanel, useEdgePositionDropdown, useFloatingTooltip } from '../../state/floating-tooltip'
import FloatingPopover from './FloatingPopover'

const tooltip = useFloatingTooltip()
const docksOverflowPanel = useDocksOverflowPanel()
const docksSidebarOverflowPanel = useDocksSidebarOverflowPanel()
const docksGroupPanel = useDocksGroupPanel()
const dockContextMenu = useDockContextMenu()
const edgePositionDropdown = useEdgePositionDropdown()
Expand All @@ -19,6 +20,7 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {

<template>
<FloatingPopover :item="docksOverflowPanel" @dismiss="() => setDocksOverflowPanel(null)" />
<FloatingPopover :item="docksSidebarOverflowPanel" @dismiss="() => setDocksSidebarOverflowPanel(null)" />
<FloatingPopover :item="docksGroupPanel" @dismiss="() => setDocksGroupPanel(null)" />
<FloatingPopover :item="dockContextMenu" @dismiss="() => setDockContextMenu(null)" />
<FloatingPopover :item="edgePositionDropdown" @dismiss="() => setEdgePositionDropdown(null)" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { DevToolsDockEntriesGrouped, DevToolsDockEntry } from '@vitejs/devtools-kit'
import { describe, expect, it } from 'vitest'
import { deriveSidebarCapacity, docksSplitGroupsWithCapacity } from '../dock-settings'

function iframe(id: string): DevToolsDockEntry {
return { id, type: 'iframe', url: '/', title: id, icon: 'i' } as DevToolsDockEntry
}

// Rhythm constants mirroring DockGroupSidebar.vue.
const BASE = {
reservedHeight: 53,
itemHeight: 34,
dividerHeight: 7,
moreButtonHeight: 34,
dividerCount: 0,
}

describe('deriveSidebarCapacity', () => {
it('returns 0 when there are no members', () => {
expect(deriveSidebarCapacity({ ...BASE, availableHeight: 1000, totalItems: 0 })).toBe(0)
})

it('returns the full count when every member fits without a show-more button', () => {
// 53 reserved + 5 items * 34 = 223 → 230 leaves room for all 5, no button.
expect(deriveSidebarCapacity({ ...BASE, availableHeight: 230, totalItems: 5 })).toBe(5)
})

it('reserves the show-more button slot only once overflow is unavoidable', () => {
// budget = 200 - 53 = 147. Without button: floor(147/34) = 4 < 6 total → overflow.
// With button reserved: floor((147 - 34)/34) = floor(113/34) = 3.
expect(deriveSidebarCapacity({ ...BASE, availableHeight: 200, totalItems: 6 })).toBe(3)
})

it('never returns negative capacity when the frame is tiny', () => {
expect(deriveSidebarCapacity({ ...BASE, availableHeight: 40, totalItems: 6 })).toBe(0)
})

it('subtracts sub-category divider height from the budget', () => {
// budget = 300 - 53 - 2*7 = 233. Without button: floor(233/34) = 6 < 8 → overflow.
// With button: floor((233 - 34)/34) = floor(199/34) = 5.
expect(deriveSidebarCapacity({ ...BASE, availableHeight: 300, dividerCount: 2, totalItems: 8 })).toBe(5)
})

it('is a no-op split at full capacity (all visible, nothing overflowed)', () => {
const groups: DevToolsDockEntriesGrouped = [['default', [iframe('a'), iframe('b')]]]
const cap = deriveSidebarCapacity({ ...BASE, availableHeight: 500, totalItems: 2 })
const { visible, overflow } = docksSplitGroupsWithCapacity(groups, cap)
expect(visible).toEqual(groups)
expect(overflow).toEqual([])
})
})

describe('docksSplitGroupsWithCapacity (sidebar overflow)', () => {
it('folds members beyond capacity into overflow, preserving order', () => {
const groups: DevToolsDockEntriesGrouped = [['default', [iframe('a'), iframe('b'), iframe('c')]]]
const { visible, overflow } = docksSplitGroupsWithCapacity(groups, 2)
expect(visible).toEqual([['default', [iframe('a'), iframe('b')]]])
expect(overflow).toEqual([['default', [iframe('c')]]])
})

it('splits across sub-categories once the first fills capacity', () => {
const groups: DevToolsDockEntriesGrouped = [
['app', [iframe('a'), iframe('b')]],
['web', [iframe('c'), iframe('d')]],
]
const { visible, overflow } = docksSplitGroupsWithCapacity(groups, 3)
expect(visible).toEqual([['app', [iframe('a'), iframe('b')]], ['web', [iframe('c')]]])
expect(overflow).toEqual([['web', [iframe('d')]]])
})
})
50 changes: 50 additions & 0 deletions packages/core/src/client/webcomponents/state/dock-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,56 @@ export function docksGroupByCategories(
return grouped
}

export interface SidebarCapacityOptions {
/** Measured height of the rail root, in px. */
availableHeight: number
/**
* Fixed vertical overhead that is always present regardless of member count:
* the root's padding, the pinned group anchor, and the anchor divider.
*/
reservedHeight: number
/** Height of one member button, including its inter-item gap. */
itemHeight: number
/** Height of one sub-category divider, including its gaps. */
dividerHeight: number
/** Height of the "show more" button, including its gap. */
moreButtonHeight: number
/** Number of sub-category dividers that could render (sub-categories − 1). */
dividerCount: number
/** Total member count across every sub-category. */
totalItems: number
}

/**
* Derive how many group side nav member buttons fit in the measured rail height.
*
* Two-pass: first test whether every member fits with no show-more button; if
* they do, the full count is returned (no button, no overflow). Otherwise the
* show-more button's height is reserved and the capacity recomputed, so the
* button only ever costs a slot when it is actually shown.
*
* The sub-category divider budget is subtracted up front for every divider that
* might render, keeping the estimate conservative — the rail may fold one
* member early into the popover, but it never clips.
*/
export function deriveSidebarCapacity(options: SidebarCapacityOptions): number {
const { availableHeight, reservedHeight, itemHeight, dividerHeight, moreButtonHeight, dividerCount, totalItems } = options

if (totalItems <= 0 || itemHeight <= 0)
return 0

const budget = availableHeight - reservedHeight - Math.max(0, dividerCount) * dividerHeight

// Pass 1: does everything fit without a show-more button?
const fitWithoutButton = Math.floor(budget / itemHeight)
if (fitWithoutButton >= totalItems)
return totalItems

// Pass 2: overflow is unavoidable, so reserve the show-more button's slot.
const fitWithButton = Math.floor((budget - moreButtonHeight) / itemHeight)
return Math.max(0, fitWithButton)
}

/**
* Split grouped entries into visible and overflow based on capacity.
*/
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/client/webcomponents/state/floating-tooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface FloatingPopoverProps {

const tooltip = shallowRef<FloatingPopoverProps | null>(null)
const docksOverflowPanel = shallowRef<FloatingPopoverProps | null>(null)
const docksSidebarOverflowPanel = shallowRef<FloatingPopoverProps | null>(null)
const dockContextMenu = shallowRef<FloatingPopoverProps | null>(null)

export function setFloatingTooltip(info: FloatingPopoverProps | null) {
Expand All @@ -28,6 +29,20 @@ export function useDocksOverflowPanel() {
return docksOverflowPanel
}

/**
* Dedicated slot for the group side nav's height-based "show more" flyout,
* kept separate from {@link docksOverflowPanel} (the dock bar's overflow) and
* {@link docksGroupPanel} (the bar group button's popover) so the rail's
* show-more never fights those features over one shared ref.
*/
export function setDocksSidebarOverflowPanel(info: FloatingPopoverProps | null) {
docksSidebarOverflowPanel.value = info
}

export function useDocksSidebarOverflowPanel() {
return docksSidebarOverflowPanel
}

const docksGroupPanel = shallowRef<FloatingPopoverProps | null>(null)

export function setDocksGroupPanel(info: FloatingPopoverProps | null) {
Expand Down
Loading