Skip to content

Commit aa64679

Browse files
committed
fix(hub-ui): keep edge dock entries reachable in limited space
Measure the edge toolbar and move entries that do not fit into the existing overflow menu while reserving space for Settings. Preserve entry order and allow single-entry overflow when category dividers prevent an inline fit. Dismiss anchored group and overflow menus when their buttons unmount, and keep the toolbar expanded while its overflow menu is open.
1 parent 18fa60e commit aa64679

5 files changed

Lines changed: 180 additions & 13 deletions

File tree

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

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
<script setup lang="ts">
22
import type { DocksContext } from '@devframes/hub/client'
33
import type { CSSProperties } from 'vue'
4+
import type { DevframeDockEntriesGrouped } from '../../state/dock-settings'
45
import type { DockEdge as DockEdgePosition, DockLayout } from './dock-layout'
56
import { useEventListener } from '@vueuse/core'
6-
import { computed, h, onMounted, ref, useTemplateRef } from 'vue'
7-
import { getEntryGroup } from '../../state/dock-settings'
8-
import { setEdgePositionDropdown, setFloatingTooltip, useDocksGroupPanel, useEdgePositionDropdown } from '../../state/floating-tooltip'
7+
import { computed, h, onMounted, ref, useTemplateRef, watch } from 'vue'
8+
import { BUILTIN_ENTRY_SETTINGS } from '../../constants'
9+
import { docksSplitGroupsBySize, getEntryGroup } from '../../state/dock-settings'
10+
import { setEdgePositionDropdown, setFloatingTooltip, useDocksGroupPanel, useDocksOverflowPanel, useEdgePositionDropdown } from '../../state/floating-tooltip'
911
import { useSettings } from '../../state/settings-defaults'
1012
import { getEntryPaneKey, useIframePanes } from '../../utils/useIframePanes'
1113
import BrandMark from '../icons/BrandMark.vue'
1214
import ViewEntry from '../views/ViewEntry.vue'
1315
import { resolveDockEdge, resolveDockLayout } from './dock-layout'
16+
import DockEntries from './DockEntries.vue'
1417
import DockEntriesWithCategories from './DockEntriesWithCategories.vue'
1518
import DockGroupSidebar from './DockGroupSidebar.vue'
19+
import DockOverflowButton from './DockOverflowButton.vue'
1620
import DockPanelResizer from './DockPanelResizer.vue'
1721
1822
const props = defineProps<{
@@ -31,7 +35,43 @@ const panes = useIframePanes(viewsContainer, context.panel, () => getEntryPaneKe
3135
3236
const isVertical = computed(() => store.position === 'left' || store.position === 'right')
3337
34-
const groupedEntries = computed(() => context.docks.groupedEntries)
38+
const settingsEntries = computed(() => context.docks.groupedEntries.flatMap(([, entries]) => entries.filter(entry => entry.id === BUILTIN_ENTRY_SETTINGS.id)))
39+
const groupedEntries = computed(() => context.docks.groupedEntries
40+
.map(([category, entries]): DevframeDockEntriesGrouped[number] => [category, entries.filter(entry => entry.id !== BUILTIN_ENTRY_SETTINGS.id)])
41+
.filter(([, entries]) => entries.length > 0))
42+
const toolbarEntries = useTemplateRef<HTMLElement>('toolbarEntries')
43+
const settingsDock = useTemplateRef<HTMLElement>('settingsDock')
44+
const toolbarSize = ref({ width: 0, height: 0, item: 32, gap: 2 })
45+
46+
watch([toolbarEntries, settingsDock], ([el, pinned], _prev, onCleanup) => {
47+
if (!el || !pinned)
48+
return
49+
const view = el.ownerDocument.defaultView
50+
const ResizeObserverCtor = view?.ResizeObserver ?? globalThis.ResizeObserver
51+
if (!ResizeObserverCtor)
52+
return
53+
const observer = new ResizeObserverCtor((entries) => {
54+
const bounds = entries.find(entry => entry.target === el)?.contentRect
55+
const style = view?.getComputedStyle(el)
56+
toolbarSize.value = {
57+
width: bounds?.width ?? toolbarSize.value.width,
58+
height: bounds?.height ?? toolbarSize.value.height,
59+
item: pinned.querySelector('button')?.offsetWidth || 32,
60+
gap: Number.parseFloat(style?.columnGap ?? '') || 0,
61+
}
62+
})
63+
observer.observe(el)
64+
observer.observe(pinned)
65+
onCleanup(() => observer.disconnect())
66+
}, { immediate: true, flush: 'post' })
67+
68+
const splitEntries = computed(() => {
69+
const { width, height, item, gap } = toolbarSize.value
70+
// Category dividers use m1 (four gap-0.5 units) and a 1.5px border.
71+
const separatorSize = gap * 4 + 1.5
72+
const settingsSize = settingsEntries.value.length ? item + separatorSize + gap * 2 : 0
73+
return docksSplitGroupsBySize(groupedEntries.value, (isVertical.value ? height : width) - settingsSize, item, gap, separatorSize)
74+
})
3575
const selectedEntry = computed(() => context.docks.selected)
3676
const activeGroup = computed(() => getEntryGroup(context.docks.entries, selectedEntry.value))
3777
const hasPanelContent = computed(() => {
@@ -59,9 +99,10 @@ function bringUp() {
5999
}, +store.inactiveTimeout || 0)
60100
}
61101
62-
// An open group menu popover anchors to a toolbar button, so collapsing the
102+
// An open menu popover anchors to a toolbar button, so collapsing the
63103
// toolbar out from under it would leave the menu floating, detached from it.
64104
const docksGroupPanel = useDocksGroupPanel()
105+
const docksOverflowPanel = useDocksOverflowPanel()
65106
66107
const isCollapsed = computed(() => {
67108
if (!settings.value.autoCollapseEdgeToolbar)
@@ -70,7 +111,7 @@ const isCollapsed = computed(() => {
70111
return false
71112
if (context.panel.isDragging)
72113
return false
73-
if (docksGroupPanel.value)
114+
if (docksGroupPanel.value || docksOverflowPanel.value)
74115
return false
75116
if (hasPanelContent.value)
76117
return false
@@ -422,21 +463,46 @@ const dragPreviewStyle = computed<CSSProperties | undefined>(() => {
422463
<!-- Toolbar -->
423464
<div class="relative flex items-center shrink-0 select-none py1" :class="toolbarClass">
424465
<div
425-
class="flex items-center flex-1 w-full transition-opacity duration-300"
466+
class="flex items-center flex-1 min-w-0 min-h-0 w-full transition-opacity duration-300"
426467
:class="[isVertical ? 'flex-col' : 'flex-row', isCollapsed ? 'opacity-0 pointer-events-none' : 'opacity-100']"
427468
>
428469
<div
429-
class="flex items-center flex-1 flex-wrap gap-0.5 px1"
430-
:class="isVertical ? 'flex-col py1' : 'flex-row px1'"
470+
ref="toolbarEntries"
471+
class="flex items-center flex-1 min-w-0 min-h-0 gap-0.5 px1 [&>*]:shrink-0"
472+
:class="isVertical ? 'flex-col py1 w-full' : 'flex-row'"
431473
>
432474
<DockEntriesWithCategories
433475
:context="context"
434-
:groups="groupedEntries"
476+
:groups="splitEntries.visible"
435477
:is-vertical="isVertical"
436478
:rotate="false"
437479
:selected="selectedEntry"
438480
@select="(e) => context.docks.switchEntry(e?.id)"
439481
/>
482+
<DockOverflowButton
483+
v-if="splitEntries.overflow.length"
484+
:context="context"
485+
:groups="splitEntries.overflow"
486+
:is-vertical="false"
487+
:placement="positionDropdownPlacement[store.position]"
488+
:selected="selectedEntry"
489+
@select="(e) => context.docks.switchEntry(e?.id)"
490+
@activity="bringUp"
491+
/>
492+
<div
493+
v-if="settingsEntries.length && groupedEntries.length"
494+
class="border-base m1"
495+
:class="isVertical ? 'w-20px h-px border-b-1.5' : 'h-20px w-px border-r-1.5'"
496+
/>
497+
<div ref="settingsDock" class="shrink-0">
498+
<DockEntries
499+
:context="context"
500+
:entries="settingsEntries"
501+
:is-vertical="false"
502+
:selected="selectedEntry"
503+
@select="(e) => context.docks.switchEntry(e?.id)"
504+
/>
505+
</div>
440506
</div>
441507

442508
<!-- Position dropdown & float toggle -->

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import type { DevframeDockEntry, DevframeViewGroup } from '@devframes/hub'
33
import type { DocksContext } from '@devframes/hub/client'
44
import { watchDebounced } from '@vueuse/core'
5-
import { computed, h, ref, useTemplateRef } from 'vue'
5+
import { computed, h, onBeforeUnmount, ref, useTemplateRef } from 'vue'
66
import { getGroupMembers, getGroupMembersGrouped, resolveGroupPreferredChild } from '../../state/dock-settings'
77
import { setDocksGroupPanel, useDocksGroupPanel } from '../../state/floating-tooltip'
88
import { useSettings } from '../../state/settings-defaults'
@@ -75,6 +75,11 @@ function hidePanel() {
7575
setDocksGroupPanel(null)
7676
}
7777
78+
onBeforeUnmount(() => {
79+
if (docksGroupPanel.value?.el === groupButton.value)
80+
hidePanel()
81+
})
82+
7883
function togglePanel() {
7984
if (isPanelVisible.value)
8085
hidePanel()

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
import type { DevframeDockEntry } from '@devframes/hub'
33
import type { DocksContext } from '@devframes/hub/client'
44
import type { DevframeDockEntriesGrouped } from '../../state/dock-settings'
5+
import type { FloatingPopoverProps } from '../../state/floating-tooltip'
56
import { watchDebounced } from '@vueuse/core'
6-
import { computed, h, ref, useTemplateRef } from 'vue'
7+
import { computed, h, onBeforeUnmount, ref, useTemplateRef } from 'vue'
78
import { setDocksOverflowPanel, useDocksOverflowPanel } from '../../state/floating-tooltip'
89
import DockEntriesWithCategories from './DockEntriesWithCategories.vue'
910
import DockEntry from './DockEntry.vue'
@@ -13,6 +14,7 @@ const props = defineProps<{
1314
isVertical: boolean
1415
groups: DevframeDockEntriesGrouped
1516
selected: DevframeDockEntry | null
17+
placement?: FloatingPopoverProps['placement']
1618
}>()
1719
1820
const emit = defineEmits<{
@@ -38,6 +40,15 @@ function showOverflowPanel() {
3840
setDocksOverflowPanel({
3941
content: () => h('div', {
4042
class: 'flex gap-0 flex-wrap max-w-220px',
43+
// Edge menus also need room for the toolbar, popover padding and gap.
44+
// Float mode keeps its existing sizing when no placement is supplied.
45+
style: props.placement
46+
? {
47+
maxWidth: `min(220px, calc(100vw - ${props.placement === 'left' || props.placement === 'right' ? 80 : 36}px))`,
48+
maxHeight: `calc(100vh - ${props.placement === 'top' || props.placement === 'bottom' ? 80 : 36}px)`,
49+
overflow: 'auto',
50+
}
51+
: undefined,
4152
onMousemove: () => emit('activity'),
4253
}, [
4354
h(DockEntriesWithCategories, {
@@ -52,6 +63,7 @@ function showOverflowPanel() {
5263
}),
5364
]),
5465
el: overflowButton.value,
66+
placement: props.placement,
5567
})
5668
}
5769
@@ -75,6 +87,11 @@ function hideOverflowPanel() {
7587
isOverflowPanelVisible.value = false
7688
setDocksOverflowPanel(null)
7789
}
90+
91+
onBeforeUnmount(() => {
92+
if (docksOverflowPanel.value?.el === overflowButton.value)
93+
hideOverflowPanel()
94+
})
7895
</script>
7996

8097
<template>

‎packages/hub-ui/src/client/state/dock-settings.test.ts‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup } from '@devframes/hub'
22
import type { WhenContext } from 'devframe/utils/when'
33
import { describe, expect, it } from 'vitest'
4-
import { docksSplitGroupsWithCapacity, resolveGroupPreferredChild, resolveNextRecentDockId, resolveRecentDockEntry } from './dock-settings'
4+
import { docksSplitGroupsBySize, docksSplitGroupsWithCapacity, resolveGroupPreferredChild, resolveNextRecentDockId, resolveRecentDockEntry } from './dock-settings'
55

66
function iframe(id: string, extra: Partial<DevframeDockEntry> = {}): DevframeDockEntry {
77
return { id, type: 'iframe', url: '/', title: id.toUpperCase(), icon: 'ph:cube-duotone', ...extra } as DevframeDockEntry
@@ -23,6 +23,53 @@ function ids(groups: DevframeDockEntriesGrouped): string[] {
2323
const [a, b, c, d, e] = ['a', 'b', 'c', 'd', 'e'].map(id => iframe(id))
2424
const rail: DevframeDockEntriesGrouped = [['default', [a, b, c, d, e]]]
2525

26+
describe('docksSplitGroupsBySize', () => {
27+
const splitAt = (groups: DevframeDockEntriesGrouped, size: number) => docksSplitGroupsBySize(groups, size, 32, 2, 9.5)
28+
29+
it('restores every entry when the toolbar fits exactly, without reserving overflow', () => {
30+
expect(ids(splitAt(rail, 168).visible)).toEqual(['a', 'b', 'c', 'd', 'e'])
31+
expect(splitAt(rail, 168).overflow).toEqual([])
32+
expect(ids(splitAt(rail, 167).overflow)).toEqual(['d', 'e'])
33+
})
34+
35+
it('reserves the more button and preserves category order in a small toolbar', () => {
36+
const groups: DevframeDockEntriesGrouped = [['first', [a, b]], ['second', [c, d, e]]]
37+
const split = splitAt(groups, 145.5)
38+
expect(split.visible).toEqual([['first', [a, b]], ['second', [c]]])
39+
expect(split.overflow).toEqual([['second', [d, e]]])
40+
expect(ids(splitAt(groups, 145).visible)).toEqual(['a', 'b'])
41+
})
42+
43+
it('keeps a lone category overflow from folding back with an extra divider', () => {
44+
const groups: DevframeDockEntriesGrouped = [['first', [a, b]], ['second', [c]]]
45+
// Three icons fit (100px), but their category divider does not (111.5px).
46+
const split = splitAt(groups, 100)
47+
expect(ids(split.visible)).toEqual(['a', 'b'])
48+
expect(ids(split.overflow)).toEqual(['c'])
49+
expect(ids(splitAt(groups, 99.5).visible)).toEqual(['a'])
50+
expect(ids(splitAt(groups, 111.5).visible)).toEqual(['a', 'b', 'c'])
51+
expect(splitAt(groups, 111.5).overflow).toEqual([])
52+
})
53+
54+
it('can put all entries in the menu when only the more button fits', () => {
55+
const split = splitAt(rail, 32)
56+
expect(split.visible).toEqual([])
57+
expect(ids(split.overflow)).toEqual(['a', 'b', 'c', 'd', 'e'])
58+
})
59+
60+
it('fits the rendered entries, dividers and menu across resize boundaries without losing entries', () => {
61+
const groups: DevframeDockEntriesGrouped = [['first', [a, b]], ['second', [c]], ['third', [d, e]]]
62+
for (let available = 32; available <= 200; available += 0.5) {
63+
const split = splitAt(groups, available)
64+
const buttons = ids(split.visible).length + Number(split.overflow.length > 0)
65+
const dividers = Math.max(0, split.visible.length - 1)
66+
const renderedSize = buttons * 32 + dividers * 9.5 + Math.max(0, buttons + dividers - 1) * 2
67+
expect(renderedSize).toBeLessThanOrEqual(available)
68+
expect([...ids(split.visible), ...ids(split.overflow)]).toEqual(['a', 'b', 'c', 'd', 'e'])
69+
}
70+
})
71+
})
72+
2673
describe('docksSplitGroupsWithCapacity', () => {
2774
it('splits naturally without a recent entry', () => {
2875
const split = docksSplitGroupsWithCapacity(rail, 3)

‎packages/hub-ui/src/client/state/dock-settings.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,38 @@ export function docksSplitGroupsWithCapacity(
523523
return { visible: reduced.visible, overflow, recent: recentEntry }
524524
}
525525

526+
/** Split an edge toolbar's entries using its available main-axis space. */
527+
export function docksSplitGroupsBySize(
528+
groups: DevframeDockEntriesGrouped,
529+
availableSize: number,
530+
itemSize: number,
531+
gap: number,
532+
separatorSize: number,
533+
): SplitGroupsResult {
534+
const total = groups.reduce((count, [, items]) => count + items.length, 0)
535+
const separators = Math.max(0, groups.length - 1)
536+
const fullSize = total * itemSize + separators * separatorSize + Math.max(0, total + separators - 1) * gap
537+
if (fullSize <= availableSize)
538+
return docksSplitGroupsWithCapacity(groups, total)
539+
540+
// Reserve the overflow button first. Each visible entry/divider adds its
541+
// size plus a gap to the button or the next entry.
542+
let remaining = availableSize - itemSize
543+
let capacity = 0
544+
for (const [index, [, items]] of groups.entries()) {
545+
if (index > 0)
546+
remaining -= separatorSize + gap
547+
const count = Math.min(items.length, Math.max(0, Math.floor(remaining / (itemSize + gap))))
548+
capacity += count
549+
remaining -= count * (itemSize + gap)
550+
if (count < items.length)
551+
break
552+
}
553+
// Preserve a lone overflow entry: folding it inline can add a category
554+
// divider that exceeds the measured space.
555+
return { ...splitGroupsAt(groups, capacity), recent: null }
556+
}
557+
526558
/**
527559
* Resolve a persisted recent-dock id (`DockSessionStorage.recentDockId`) to
528560
* the entry the float bar can raise, or `null` when the id no longer maps to a

0 commit comments

Comments
 (0)