Skip to content

Commit 52e4971

Browse files
authored
fix(hub-ui): keep edge dock entries reachable in limited space (#399)
1 parent c621b1b commit 52e4971

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)