From 75603a745121058064f75e4dd282198c375197d2 Mon Sep 17 00:00:00 2001 From: Jay George Date: Wed, 25 Mar 2026 14:35:27 +0000 Subject: [PATCH 01/13] Make the sidebar resizeable --- resources/css/core/layout.css | 70 ++++++++++++++++++++++++++--- resources/js/components/nav/Nav.vue | 43 ++++++++++++++++++ 2 files changed, 107 insertions(+), 6 deletions(-) diff --git a/resources/css/core/layout.css b/resources/css/core/layout.css index 8299d321b77..3807bbdcdf9 100644 --- a/resources/css/core/layout.css +++ b/resources/css/core/layout.css @@ -1,10 +1,15 @@ +:root { + --nav-width: 12rem; +} + /* GROUP THE MAIN NAV (LEFT SIDEBAR) =================================================== */ .nav-main { @apply flex flex-col gap-6 py-6 px-2 sm:px-3 text-sm antialiased select-none; /* Same as the main element, accounting for the header with a class of h-14, which is the same as 3.5rem */ @apply h-[calc(100vh-3.5rem)]; - @apply overflow-y-auto fixed top-14 start-0 w-48; + @apply overflow-y-auto fixed top-14 start-0; + width: var(--nav-width); @apply [&_svg]:text-gray-500 dark:[&_svg]:text-gray-500/85; /* Wait for the full page to load before allowing this transition otherwise you see the Sidebar animate in/out on load in Firefox (and sometimes Safari) */ .page-fully-loaded & { @@ -109,10 +114,8 @@ } } - - - -/* Mobile nav behavior */ +/* GROUP THE MAIN NAV (LEFT SIDEBAR) / MOBILE BEHAVIOR +=================================================== */ @media (width < theme(--breakpoint-lg)) { .nav-main { /* Always visible but off-screen by default */ @@ -128,7 +131,10 @@ main { - @apply ps-0 lg:ps-46; + padding-inline-start: 0; + @media (width >= theme(--breakpoint-lg)) { + padding-inline-start: var(--nav-width); + } /* Wait for the full page to load before allowing this transition otherwise you see the Sidebar animate in/out on load in Firefox (and sometimes Safari) */ .page-fully-loaded & { /* Only padding because we don't wand to transition the color when we switch between light/dark mode */ @@ -146,6 +152,58 @@ main.nav-closed { @apply lg:ps-0; } +/* GROUP RESIZEABLE NAVS / RESIZE NEEDLE +=================================================== */ +.nav-resize-handle { + --resize-width: 10px; + --needle-width: 5px; + + position: absolute; + z-index: var(--z-index-above); + top: 0; + inset-inline-end: 0; + /* inset-inline-end: 0; */ + width: var(--resize-width); + height: 100%; + cursor: col-resize; + @apply hidden lg:block; + + &::after { + content: ''; + position: absolute; + top: 0; + inset-inline-end: 0; + width: var(--needle-width); + height: 100%; + @apply bg-transparent transition-colors; + } + + &:hover::after { + background: var(--theme-color-global-header-bg); + } +} + +.nav-resizing { + cursor: col-resize; + /* Prevents any text selection while dragging (otherwise you could end up selecting menu text while dragging the resize handle). */ + user-select: none; + + & * { + /* Ensures that if you move the pointer over a child element inside the nav (icons, links, spans), the cursor doesn't revert back to the default pointer/hand. */ + cursor: col-resize; + } + + & main, + & .nav-main { + /* Disables transitions while dragging, specifically to prevent jank/animated layout changes from the existing sidebar + main transitions in layout.css (the sidebar open/close transition and the main padding transition). */ + transition: none; + } + + & .nav-resize-handle::after { + background: var(--theme-color-global-header-bg); + } +} + /* ========================================================================== DRAGGABLE MIRRORS ========================================================================== */ diff --git a/resources/js/components/nav/Nav.vue b/resources/js/components/nav/Nav.vue index 490446b8b1f..435a92d32ab 100644 --- a/resources/js/components/nav/Nav.vue +++ b/resources/js/components/nav/Nav.vue @@ -151,6 +151,44 @@ Statamic.$keys.bind(['command+\\', ['[']], (e) => { }); Statamic.$events.$on('nav.toggle', toggle); + +// Resizable sidebar +const navWidthKey = 'statamic.nav.width'; +const MIN_NAV_WIDTH = 150; +const MAX_NAV_WIDTH = 400; + +const savedNavWidth = localStorage.getItem(navWidthKey); +if (savedNavWidth) { + document.documentElement.style.setProperty('--nav-width', savedNavWidth + 'px'); +} + +function startResize(event) { + const dir = getComputedStyle(document.documentElement).direction; + document.documentElement.classList.add('nav-resizing'); + + const onPointerMove = (e) => { + const rect = navRef.value.getBoundingClientRect(); + const width = dir === 'rtl' ? rect.right - e.clientX : e.clientX - rect.left; + const clamped = Math.min(Math.max(width, MIN_NAV_WIDTH), MAX_NAV_WIDTH); + document.documentElement.style.setProperty('--nav-width', clamped + 'px'); + }; + + const onPointerUp = () => { + document.documentElement.classList.remove('nav-resizing'); + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + const currentWidth = Math.round(navRef.value.getBoundingClientRect().width); + localStorage.setItem(navWidthKey, currentWidth); + }; + + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); +} + +function resetWidth() { + localStorage.removeItem(navWidthKey); + document.documentElement.style.removeProperty('--nav-width'); +} From 08e832c669837b46809083a1a6bf639d28299f16 Mon Sep 17 00:00:00 2001 From: Jay George Date: Wed, 25 Mar 2026 15:56:13 +0000 Subject: [PATCH 02/13] Move the resizable handles to a better position --- resources/css/core/layout.css | 13 ++-- resources/js/components/nav/Nav.vue | 43 ------------ resources/js/pages/layout/Layout.vue | 100 ++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 52 deletions(-) diff --git a/resources/css/core/layout.css b/resources/css/core/layout.css index 3807bbdcdf9..716f2825299 100644 --- a/resources/css/core/layout.css +++ b/resources/css/core/layout.css @@ -154,15 +154,14 @@ main.nav-closed { /* GROUP RESIZEABLE NAVS / RESIZE NEEDLE =================================================== */ -.nav-resize-handle { - --resize-width: 10px; +.content-card-resize-handle { + --resize-width: 12px; --needle-width: 5px; position: absolute; z-index: var(--z-index-above); top: 0; - inset-inline-end: 0; - /* inset-inline-end: 0; */ + inset-inline-start: 0; width: var(--resize-width); height: 100%; cursor: col-resize; @@ -172,10 +171,10 @@ main.nav-closed { content: ''; position: absolute; top: 0; - inset-inline-end: 0; + inset-inline-start: 0; width: var(--needle-width); height: 100%; - @apply bg-transparent transition-colors; + @apply bg-transparent transition-colors rounded-lg; } &:hover::after { @@ -199,7 +198,7 @@ main.nav-closed { transition: none; } - & .nav-resize-handle::after { + & .content-card-resize-handle::after { background: var(--theme-color-global-header-bg); } } diff --git a/resources/js/components/nav/Nav.vue b/resources/js/components/nav/Nav.vue index 435a92d32ab..490446b8b1f 100644 --- a/resources/js/components/nav/Nav.vue +++ b/resources/js/components/nav/Nav.vue @@ -151,44 +151,6 @@ Statamic.$keys.bind(['command+\\', ['[']], (e) => { }); Statamic.$events.$on('nav.toggle', toggle); - -// Resizable sidebar -const navWidthKey = 'statamic.nav.width'; -const MIN_NAV_WIDTH = 150; -const MAX_NAV_WIDTH = 400; - -const savedNavWidth = localStorage.getItem(navWidthKey); -if (savedNavWidth) { - document.documentElement.style.setProperty('--nav-width', savedNavWidth + 'px'); -} - -function startResize(event) { - const dir = getComputedStyle(document.documentElement).direction; - document.documentElement.classList.add('nav-resizing'); - - const onPointerMove = (e) => { - const rect = navRef.value.getBoundingClientRect(); - const width = dir === 'rtl' ? rect.right - e.clientX : e.clientX - rect.left; - const clamped = Math.min(Math.max(width, MIN_NAV_WIDTH), MAX_NAV_WIDTH); - document.documentElement.style.setProperty('--nav-width', clamped + 'px'); - }; - - const onPointerUp = () => { - document.documentElement.classList.remove('nav-resizing'); - document.removeEventListener('pointermove', onPointerMove); - document.removeEventListener('pointerup', onPointerUp); - const currentWidth = Math.round(navRef.value.getBoundingClientRect().width); - localStorage.setItem(navWidthKey, currentWidth); - }; - - document.addEventListener('pointermove', onPointerMove); - document.addEventListener('pointerup', onPointerUp); -} - -function resetWidth() { - localStorage.removeItem(navWidthKey); - document.documentElement.style.removeProperty('--nav-width'); -} diff --git a/resources/js/pages/layout/Layout.vue b/resources/js/pages/layout/Layout.vue index 2d08c279163..8771e39435d 100644 --- a/resources/js/pages/layout/Layout.vue +++ b/resources/js/pages/layout/Layout.vue @@ -32,6 +32,94 @@ provide('layout', { // Focus management: focus main element if no input has auto-focus let navigationListener = null; +// Resizable sidebar (handle lives on the left edge of the content card) +const navWidthStorageKey = 'statamic.nav.width'; +const MIN_NAV_WIDTH = 150; +const MAX_NAV_WIDTH = 400; +const mainContentRef = ref(null); +const contentCardRef = ref(null); + +let isResizing = false; +let currentWidthPx = null; +let contentInsetPx = 0; +let pointerMoveListener = null; +let pointerUpListener = null; + +function clampNavWidthPx(widthPx) { + return Math.min(Math.max(widthPx, MIN_NAV_WIDTH), MAX_NAV_WIDTH); +} + +function setNavWidthPx(widthPx) { + document.documentElement.style.setProperty('--nav-width', `${widthPx}px`); +} + +function restoreSavedNavWidth() { + const saved = localStorage.getItem(navWidthStorageKey); + if (!saved) return; + + const widthPx = Number(saved); + if (!Number.isFinite(widthPx)) return; + + setNavWidthPx(clampNavWidthPx(widthPx)); +} + +function stopResize({ persist = true } = {}) { + if (!isResizing) return; + + isResizing = false; + document.documentElement.classList.remove('nav-resizing'); + + if (pointerMoveListener) document.removeEventListener('pointermove', pointerMoveListener); + if (pointerUpListener) document.removeEventListener('pointerup', pointerUpListener); + pointerMoveListener = null; + pointerUpListener = null; + + if (persist && currentWidthPx !== null) { + localStorage.setItem(navWidthStorageKey, Math.round(currentWidthPx)); + } + + currentWidthPx = null; +} + +function startResize(event) { + if (isResizing || !mainContentRef.value || !contentCardRef.value) return; + + isResizing = true; + document.documentElement.classList.add('nav-resizing'); + + // Prevent losing the drag if the pointer leaves the handle. + event?.currentTarget?.setPointerCapture?.(event.pointerId); + + const dir = getComputedStyle(document.documentElement).direction; + const isRtl = dir === 'rtl'; + + const mainContentRect = mainContentRef.value.getBoundingClientRect(); + const contentCardRect = contentCardRef.value.getBoundingClientRect(); + contentInsetPx = isRtl + ? (mainContentRect.right - contentCardRect.right) + : (contentCardRect.left - mainContentRect.left); + + pointerMoveListener = (e) => { + const proposedWidth = isRtl + ? (window.innerWidth - e.clientX - contentInsetPx) + : (e.clientX - contentInsetPx); + + currentWidthPx = clampNavWidthPx(proposedWidth); + setNavWidthPx(currentWidthPx); + }; + + pointerUpListener = () => stopResize({ persist: true }); + + document.addEventListener('pointermove', pointerMoveListener); + document.addEventListener('pointerup', pointerUpListener); +} + +function resetNavWidth() { + stopResize({ persist: false }); + localStorage.removeItem(navWidthStorageKey); + document.documentElement.style.removeProperty('--nav-width'); +} + function focusMain() { // Wait for components to mount and autofocus to process nextTick(() => { @@ -63,6 +151,7 @@ function focusMain() { onMounted(() => { navigationListener = router.on('success', focusMain); + restoreSavedNavWidth(); focusMain(); }); @@ -70,6 +159,8 @@ onUnmounted(() => { if (navigationListener) { navigationListener(); } + + stopResize({ persist: false }); }); @@ -82,8 +173,13 @@ onUnmounted(() => {