feat(web-core): make the side panel resizable (#10725)

* feat(web-core): make the side panel resizable

- Resize the side panel like the treeview, with a shared `VtsResizeHandle`
  component and `useResizableWidth` composable
- Default width of the treeview and the side panel is 1/5 of the screen
  (280px minimum), max width is 1/3 of the screen (400px minimum)
- Double-click (or Enter) on the handle restores the default width
- The handle is keyboard accessible (arrows, Home, End) and exposed as an
  ARIA separator

* docs: add changelog entries
This commit is contained in:
Joris K
2026-11-06 18:27:57 +01:00
committed by GitHub
parent 93dc079f71
commit 2e586f0369
13 changed files with 229 additions and 106 deletions

View File

@@ -1,5 +1,9 @@
# ChangeLog
## **next**
- [Side panels] Side panels can be resized like the treeview, whose default width now adapts to the screen size (PR [#10725](https://github.com/vatesfr/xen-orchestra/pull/10725))
## **0.27.0** (2026-10-29)
- [New network] Choose between no NBD, secure NBD and insecure NBD (not encrypted) when creating a network (PR [#10540](https://github.com/vatesfr/xen-orchestra/pull/10540))

View File

@@ -4,6 +4,7 @@
:class="{
mobile: uiStore.isSmall,
'has-panel-column': hasPanelColumn,
toggling: isToggling,
}"
>
<slot />
@@ -11,30 +12,40 @@
</template>
<script setup lang="ts">
import { usePanelStore } from '@core/stores/panel.store.ts'
import { PANEL_TRANSITION_DURATION, usePanelStore } from '@core/stores/panel.store.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { computed } from 'vue'
import { refAutoReset } from '@vueuse/core'
import { computed, watch } from 'vue'
const panelStore = usePanelStore()
const uiStore = useUiStore()
const hasPanelColumn = computed(() => (panelStore.isLocked || panelStore.isExpanded) && !uiStore.isSmall)
const isToggling = refAutoReset(false, PANEL_TRANSITION_DURATION)
watch(hasPanelColumn, () => {
isToggling.value = true
})
const columnTransition = `grid-template-columns ${PANEL_TRANSITION_DURATION}ms`
</script>
<style scoped lang="postcss">
.vts-content-side-panel {
--side-panel-width: 40rem;
&:not(.mobile) {
display: grid;
grid-template-columns: minmax(0, 1fr) 0;
align-items: start;
overflow-x: clip;
min-height: min-content;
transition: grid-template-columns 0.25s;
&.has-panel-column {
grid-template-columns: minmax(0, 1fr) var(--side-panel-width);
grid-template-columns: minmax(0, 1fr) v-bind('panelStore.cssWidth');
}
&.toggling {
transition: v-bind(columnTransition);
}
}
}

View File

@@ -1,11 +1,6 @@
<template>
<div :class="className" class="vts-layout-sidebar">
<div
v-if="!ui.isSmall"
:class="{ active: sidebar.isResizing }"
class="resize-handle"
@mousedown="sidebar.startResize"
/>
<VtsResizeHandle v-if="showResizeHandle" :position="resizeHandlePosition" :resizable="sidebar" />
<div v-if="slots.header || (showLock && !ui.isSmall)" class="header">
<div v-if="slots.header" class="start">
<slot name="header" />
@@ -36,6 +31,7 @@
</template>
<script lang="ts" setup>
import VtsResizeHandle from '@core/components/resize-handle/VtsResizeHandle.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { type SidebarSide, useLeftSidebarStore, useRightSidebarStore } from '@core/packages/sidebar'
@@ -66,6 +62,10 @@ const sidebar = computed(() => (side === 'right' ? rightSidebar : leftSidebar))
const lockTooltipPosition = computed(() => (side === 'left' ? 'right' : 'left'))
const showResizeHandle = computed(() => !ui.isSmall && sidebar.value.isExpanded)
const resizeHandlePosition = computed(() => (side === 'left' ? 'end' : 'start'))
const className = computed(() => [
{
locked: sidebar.value.isLocked && !ui.isSmall,
@@ -154,34 +154,5 @@ const className = computed(() => [
flex: 1;
overflow: auto;
}
.resize-handle {
position: absolute;
inset-block: 0;
width: 0.8rem;
background-color: transparent;
cursor: col-resize;
transition: background-color 0.4s;
user-select: none;
z-index: 1;
&:hover,
&.active {
background-color: var(--color-neutral-border);
transition: background-color 0.05s;
}
}
&.side--left {
.resize-handle {
inset-inline-end: 0;
}
}
&.side--right {
.resize-handle {
inset-inline-start: 0;
}
}
}
</style>

View File

@@ -1,6 +1,7 @@
<!-- v5 -->
<template>
<div class="vts-side-panel-sticky" :class="{ mobile: uiStore.isSmall }">
<VtsResizeHandle v-if="showResizeHandle" position="start" :resizable="panelStore" />
<VtsPanel
:closable="hasSelection"
:close-icon="uiStore.isSmall ? 'fa:angle-left' : 'action:close-cancel-clear'"
@@ -42,12 +43,13 @@
<script setup lang="ts">
import VtsPanel from '@core/components/panel/VtsPanel.vue'
import VtsResizeHandle from '@core/components/resize-handle/VtsResizeHandle.vue'
import VtsStateHero from '@core/components/state-hero/VtsStateHero.vue'
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import { usePanelStore } from '@core/stores/panel.store.ts'
import { PANEL_TRANSITION_DURATION, usePanelStore } from '@core/stores/panel.store.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { watch } from 'vue'
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { hasSelection } = defineProps<{
@@ -70,6 +72,10 @@ const uiStore = useUiStore()
const { t } = useI18n()
const showResizeHandle = computed(() => !uiStore.isSmall && panelStore.isExpanded)
const transformTransition = `transform ${PANEL_TRANSITION_DURATION}ms`
watch(
[() => hasSelection, () => panelStore.isLocked, () => uiStore.isSmall],
([hasSelection]) => {
@@ -99,8 +105,8 @@ function handleClose() {
}
.vts-side-panel {
width: var(--side-panel-width, 40rem);
transition: transform 0.25s;
width: v-bind('panelStore.cssWidth');
transition: v-bind(transformTransition);
&:not(.mobile) {
--panel-viewport-height: calc(100dvh - 5.6rem);

View File

@@ -0,0 +1,76 @@
<template>
<div
:class="className"
class="vts-resize-handle"
role="separator"
aria-orientation="vertical"
tabindex="0"
:aria-label="t('action:resize')"
:aria-valuenow="ariaValueNow"
:aria-valuemin="resizable.minWidth"
:aria-valuemax="ariaValueMax"
@mousedown="resizable.startResize"
@dblclick="resizable.resetWidth()"
@keydown.left.prevent="handleArrowKey('left')"
@keydown.right.prevent="handleArrowKey('right')"
@keydown.home.prevent="resizable.setWidth(resizable.minWidth)"
@keydown.end.prevent="resizable.setWidth(resizable.maxWidth)"
@keydown.enter.prevent="resizable.resetWidth()"
/>
</template>
<script setup lang="ts">
import type { ResizableWidth } from '@core/composables/resizable-width.composable.ts'
import { toVariants } from '@core/utils/to-variants.util.ts'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { position, resizable } = defineProps<{
position: 'start' | 'end'
resizable: ResizableWidth
}>()
const RESIZE_STEP = 10
const { t } = useI18n()
const className = computed(() => toVariants({ position, active: resizable.isResizing }))
const ariaValueNow = computed(() => Math.round(resizable.width))
const ariaValueMax = computed(() => Math.round(resizable.maxWidth))
function handleArrowKey(direction: 'left' | 'right') {
const isGrowing = (direction === 'right') === (position === 'end')
resizable.setWidth(resizable.width + (isGrowing ? RESIZE_STEP : -RESIZE_STEP))
}
</script>
<style scoped lang="postcss">
.vts-resize-handle {
position: absolute;
inset-block: 0;
width: 0.8rem;
background-color: transparent;
cursor: col-resize;
transition: background-color 0.4s;
user-select: none;
z-index: 1;
&:hover,
&:focus-visible,
&.active {
background-color: var(--color-neutral-border);
transition: background-color 0.05s;
}
&.position--start {
inset-inline-start: 0;
}
&.position--end {
inset-inline-end: 0;
}
}
</style>

View File

@@ -0,0 +1,93 @@
import { ifElse } from '@core/utils/if-else.utils.ts'
import { useLocalStorage, useRafFn, useStyleTag, useWindowSize } from '@vueuse/core'
import { computed, ref, type UnwrapRef } from 'vue'
const MIN_WIDTH = 280
const MAX_WIDTH_FLOOR = 400
const MAX_WIDTH_RATIO = 1 / 3
const DEFAULT_WIDTH_RATIO = 1 / 5
export type ResizableSide = 'left' | 'right'
export type ResizableWidth = UnwrapRef<ReturnType<typeof useResizableWidth>>
export function useResizableWidth(side: ResizableSide, storageKey: string) {
const customWidth = useLocalStorage<number | null>(storageKey, null, {
serializer: {
read: value => {
const width = Number.parseFloat(value)
return Number.isFinite(width) ? width : null
},
write: value => String(value),
},
})
const isResizing = ref(false)
const { width: windowWidth } = useWindowSize()
const maxWidth = computed(() => Math.max(MAX_WIDTH_FLOOR, windowWidth.value * MAX_WIDTH_RATIO))
const defaultWidth = computed(() => Math.max(MIN_WIDTH, windowWidth.value * DEFAULT_WIDTH_RATIO))
const width = computed(() => Math.min(maxWidth.value, customWidth.value ?? defaultWidth.value))
let initialX: number
let initialWidth: number
let clientX: number
function startResize(event: MouseEvent) {
clientX = event.clientX
initialX = clientX
initialWidth = width.value
isResizing.value = true
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
function setWidth(value: number) {
customWidth.value = Math.min(maxWidth.value, Math.max(MIN_WIDTH, value))
}
function resetWidth() {
customWidth.value = null
}
function handleMouseMove(event: MouseEvent) {
clientX = event.clientX
}
function handleMouseUp() {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
const { load, unload } = useStyleTag('body { cursor: col-resize !important; }', { immediate: false })
const resizeDelta = () => (side === 'left' ? initialWidth + clientX - initialX : initialWidth + initialX - clientX)
const { pause, resume } = useRafFn(
() => {
if (clientX === initialX) {
return
}
setWidth(resizeDelta())
},
{ immediate: false }
)
ifElse(isResizing, [load, resume], [pause, unload])
return {
width,
minWidth: MIN_WIDTH,
maxWidth,
startResize,
setWidth,
resetWidth,
isResizing,
}
}

View File

@@ -138,6 +138,7 @@
"action:reset-all-networks": "Reset all networks",
"action:reset-all-srs": "Reset all SRs",
"action:reset-to-default": "Reset to default",
"action:resize": "Resize",
"action:restart-toolstack": "Restart toolstack",
"action:resume": "Resume",
"action:revert-to-snapshot": "Revert to snapshot",

View File

@@ -138,6 +138,7 @@
"action:reset-all-networks": "Réinitialiser tous les réseaux",
"action:reset-all-srs": "Réinitialiser tous les SRs",
"action:reset-to-default": "Rétablir la valeur par défaut",
"action:resize": "Redimensionner",
"action:restart-toolstack": "Redémarrer le toolstack",
"action:resume": "Reprendre",
"action:revert-to-snapshot": "Revenir à l'instantané",

View File

@@ -1,5 +1,5 @@
import { useResizableWidth } from '@core/composables/resizable-width.composable.ts'
import { SIDEBAR_DEFAULTS, type SidebarSide } from '@core/packages/sidebar/types.ts'
import { useSidebarResize } from '@core/packages/sidebar/use-sidebar-resize.ts'
import { useSidebarResponsiveExpand } from '@core/packages/sidebar/use-sidebar-responsive-expand.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { useLocalStorage, useToggle } from '@vueuse/core'
@@ -13,15 +13,14 @@ function createSidebar(side: SidebarSide) {
const isExpanded = useLocalStorage(`${prefix}.expanded`, defaults.isExpanded)
const isLocked = useLocalStorage(`${prefix}.locked`, defaults.isLocked)
const width = useLocalStorage(`${prefix}.width`, defaults.width)
const toggleExpand = useToggle(isExpanded)
const toggleLock = useToggle(isLocked)
const { startResize, isResizing } = useSidebarResize(side, width)
const resizableWidth = useResizableWidth(side, `${prefix}.custom-width`)
useSidebarResponsiveExpand(isExpanded)
const cssWidth = computed(() => (uiStore.isSmall ? '100%' : `${width.value}px`))
const cssWidth = computed(() => (uiStore.isSmall ? '100%' : `${resizableWidth.width.value}px`))
const cssLockedOffset = computed(() => (isExpanded.value ? '0' : `-${cssWidth.value}`))
@@ -36,14 +35,12 @@ function createSidebar(side: SidebarSide) {
return {
isExpanded,
isLocked,
width,
toggleExpand,
toggleLock,
startResize,
isResizing,
cssWidth,
cssLockedOffset,
cssUnlockedOffset,
...resizableWidth,
}
}

View File

@@ -1,21 +1,20 @@
import type { ResizableWidth } from '@core/composables/resizable-width.composable.ts'
export const SIDES = ['left', 'right'] as const
export type SidebarSide = (typeof SIDES)[number]
export type SidebarState = {
export type SidebarState = ResizableWidth & {
isExpanded: boolean
isLocked: boolean
width: number
toggleExpand: (value?: boolean) => boolean
toggleLock: (value?: boolean) => boolean
startResize: (event: MouseEvent) => void
isResizing: boolean
cssWidth: string
cssLockedOffset: string | number
cssUnlockedOffset: string | number
}
export const SIDEBAR_DEFAULTS = {
left: { isExpanded: true, isLocked: true, width: 350 },
right: { isExpanded: false, isLocked: false, width: 350 },
} satisfies Record<SidebarSide, Pick<SidebarState, 'isExpanded' | 'isLocked' | 'width'>>
left: { isExpanded: true, isLocked: true },
right: { isExpanded: false, isLocked: false },
} satisfies Record<SidebarSide, Pick<SidebarState, 'isExpanded' | 'isLocked'>>

View File

@@ -1,47 +0,0 @@
import type { SidebarSide } from '@core/packages/sidebar/types.ts'
import { ifElse } from '@core/utils/if-else.utils.ts'
import { useRafFn, useStyleTag } from '@vueuse/core'
import { ref, type Ref } from 'vue'
export function useSidebarResize(side: SidebarSide, width: Ref<number>) {
const isResizing = ref(false)
let initialX: number
let initialWidth: number
let clientX: number
function startResize(event: MouseEvent) {
clientX = event.clientX
initialX = clientX
initialWidth = width.value
isResizing.value = true
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
function handleMouseMove(event: MouseEvent) {
clientX = event.clientX
}
function handleMouseUp() {
isResizing.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
const { load, unload } = useStyleTag('body { cursor: col-resize !important; }', { immediate: false })
const resizeDelta = () => (side === 'left' ? initialWidth + clientX - initialX : initialWidth + initialX - clientX)
const { pause, resume } = useRafFn(
() => (width.value = Math.min(500, window.innerWidth * 0.3, Math.max(200, resizeDelta()))),
{ immediate: false }
)
ifElse(isResizing, [load, resume], [pause, unload])
return {
startResize,
isResizing,
}
}

View File

@@ -1,13 +1,20 @@
import { useResizableWidth } from '@core/composables/resizable-width.composable.ts'
import { useUiStore } from '@core/stores/ui.store.ts'
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, watch } from 'vue'
export const PANEL_TRANSITION_DURATION = 250
export const usePanelStore = defineStore('panel', () => {
const uiStore = useUiStore()
const isExpanded = useLocalStorage('panel.expanded', true)
const isLocked = useLocalStorage('panel.locked', true)
const resizableWidth = useResizableWidth('right', 'panel.custom-width')
const cssWidth = computed(() => `${resizableWidth.width.value}px`)
const cssHorizontalOffset = computed(() => (isExpanded.value ? 0 : '100%'))
/* Panel closes automatically when it has no content (true on small UI or when not locked) */
const syncsOpenStateWithSelection = computed(() => uiStore.isSmall || !isLocked.value)
@@ -59,5 +66,7 @@ export const usePanelStore = defineStore('panel', () => {
toggleLock,
syncWithSelection,
cssHorizontalOffset,
cssWidth,
...resizableWidth,
}
})

View File

@@ -11,6 +11,8 @@
> Users must be able to say: "Nice enhancement, I'm eager to test it"
- [Side panels] Side panels can be resized like the treeview, whose default width now adapts to the screen size (PR [#10725](https://github.com/vatesfr/xen-orchestra/pull/10725))
### Bug fixes
> Users must be able to say: "I had this issue, happy to know it's fixed"