From 66c07b2a631a61994421741bdd674a3d891fb14b Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Fri, 18 Sep 2026 17:09:03 +0200 Subject: [PATCH] feat: snap-collapse the sidebar by dragging it to the left edge --- .../src/routes/(root)/(logged)/+layout.svelte | 66 +++++++++++-------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index d14cd2c2dc..607d550481 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -142,10 +142,11 @@ // same way the old `w-52`/`w-12` classes did — `:root` jumps to 18px past 1760px // wide (app.css), which grows the rem-based button content; a fixed-px rail would // not grow with it and the content would overflow. SIDEBAR_MIN_REM is the default - // expanded width (the old w-52); the handle only resizes when expanded and only - // widens from there — collapsing is the toggle button's job, not the drag's. + // expanded width (the old w-52); the handle only widens from there. Dragging the + // pointer into the snap zone at the screen's left edge collapses the rail. const SIDEBAR_MIN_REM = 13 const SIDEBAR_COLLAPSED_REM = 3 + const SIDEBAR_SNAP_COLLAPSE_REM = 6 // Root font-size in px, used to convert the pointer's clientX (px) into rem. function rootFontPx(): number { if (!BROWSER) return 16 @@ -168,11 +169,14 @@ // Width (in rem) the content offset must track: the icon strip when collapsed, // the user-chosen width otherwise. let railWidth = $derived(isCollapsed ? SIDEBAR_COLLAPSED_REM : sidebarWidth) - // Width transition shared by the rail and the content offset: none for the whole - // drag (the rail tracks the pointer 1:1 and hits the min as a hard wall, no - // friction), a plain ease only for the collapse/expand toggle. + // True for one transition's length after a drag crosses the snap zone's edge, so + // the collapse/expand eases before the rail goes back to tracking the pointer. + let sidebarSnapping = $state(false) + let sidebarSnapTimer: ReturnType | undefined + // Width transition shared by the rail and the content offset: none while a drag + // tracks the pointer 1:1, a plain ease for the toggle and the snap. let sidebarTransitionClass = $derived( - resizingSidebar ? '' : 'transition-all duration-200 ease-in-out' + resizingSidebar && !sidebarSnapping ? '' : 'transition-all duration-200 ease-in-out' ) // Set while a drag is live so it can be torn down if the layout unmounts // mid-drag (otherwise the window listeners would leak). @@ -191,12 +195,22 @@ handle.setPointerCapture(e.pointerId) } catch {} resizingSidebar = true + // Re-expanding a snap-collapsed rail restores the width it had before the drag. + const widthAtStart = sidebarWidth + const collapsedAtStart = isCollapsed // The rail is fixed at left:0, so the pointer's clientX is the width — in px. - // Convert to rem (the unit the rail is sized in) via the root font-size. Pure - // resize: clamp at the min so the rail stops there like a wall (dragging left - // never collapses — that's the toggle button's job). + // Convert to rem (the unit the rail is sized in) via the root font-size. The + // rail clamps at the min like a wall, until the pointer reaches the snap zone. const onMove = (ev: PointerEvent) => { - sidebarWidth = Math.max(SIDEBAR_MIN_REM, ev.clientX / rootFontPx()) + const x = ev.clientX / rootFontPx() + const collapse = x < SIDEBAR_SNAP_COLLAPSE_REM + if (collapse !== isCollapsed) { + isCollapsed = collapse + sidebarSnapping = true + clearTimeout(sidebarSnapTimer) + sidebarSnapTimer = setTimeout(() => (sidebarSnapping = false), 200) + } + sidebarWidth = collapse ? widthAtStart : Math.max(SIDEBAR_MIN_REM, x) } // pointercancel (and unmount, via onDestroy) must clear the state too, or // `resizingSidebar` sticks true — the overlay and handle highlight stay up @@ -204,7 +218,10 @@ const stop = () => { if (!resizingSidebar) return resizingSidebar = false + clearTimeout(sidebarSnapTimer) + sidebarSnapping = false widthPref.val = sidebarWidth + if (isCollapsed !== collapsedAtStart) collapsePref.val = isCollapsed window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', stop) window.removeEventListener('pointercancel', stop) @@ -1168,22 +1185,19 @@ class="flex-1 flex flex-col min-h-0 h-screen shadow-[inset_-1px_0_0_0_rgb(var(--color-border-light))] dark:shadow-[inset_-1px_0_0_0_#374151] [html.github-dark_&]:shadow-[inset_-1px_0_0_0_rgb(var(--color-border-light))]" style:background-color={darkMode ? SIDEBAR_BG_DARK : SIDEBAR_BG} > - {#if !isCollapsed} - - - {/if} + +