mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-11-09 17:06:13 -06:00
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:
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'>>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user