From 2e907e1309d456d9554ac2b6a287c600af397e05 Mon Sep 17 00:00:00 2001 From: Brandon Oxendine Date: Wed, 16 Sep 2026 11:43:11 +0200 Subject: [PATCH] Cross the sidebar's two views instead of morphing --- src/components/AppSidebar.svelte | 411 ++++++++++++++------------ src/components/SidebarRepoList.svelte | 26 +- 2 files changed, 226 insertions(+), 211 deletions(-) diff --git a/src/components/AppSidebar.svelte b/src/components/AppSidebar.svelte index f774eff9c..48a796e8b 100644 --- a/src/components/AppSidebar.svelte +++ b/src/components/AppSidebar.svelte @@ -3,13 +3,11 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { onDestroy, onMount, tick } from "svelte"; - import { backOut } from "svelte/easing"; + import { backOut, cubicOut } from "svelte/easing"; import { fade, scale } from "svelte/transition"; import { boolean } from "zod"; import { checkRadicleCLI } from "@app/lib/checkRadicleCLI.svelte"; - import type { FlipSnapshot } from "@app/lib/flip"; - import { captureFlip, playFlip } from "@app/lib/flip"; import { hints } from "@app/lib/hints"; import { dynamicInterval } from "@app/lib/interval"; import { invoke } from "@app/lib/invoke"; @@ -50,35 +48,40 @@ const { sidebarData, activeRepo = undefined }: Props = $props(); + const COLLAPSE_MS = 200; + + // The rail and the expanded sidebar cross as whole views. Each is pinned to a + // width for the crossing -- the one leaving to what it had, the one arriving + // to where it lands -- so the frame moving underneath cannot drag it sideways. + const SWAP_SCALE_FROM = 0.9; + + function swap( + node: Element, + { + duration, + rise = 0, + width, + }: { duration: number; rise?: number; width?: number }, + ) { + const pinned = width ?? (node as HTMLElement).offsetWidth; + const scale = (t: number) => SWAP_SCALE_FROM + (1 - SWAP_SCALE_FROM) * t; + + return { + duration, + easing: cubicOut, + css: (t: number, u: number) => + `opacity: ${t}; + width: ${pinned}px; + transform: scale(${scale(t)}) translateY(${u * rise}px); + transform-origin: top left;`, + }; + } + const toggleShortcut = isMac() ? "⌘B" : "Ctrl+B"; const dragStripHeight = isMac() ? "2rem" : "1.75rem"; const mini = $derived(collapsed.value); - // The window controls move between a row in the drag strip when expanded and - // a column on the rail when collapsed. FLIP carries both the move and the - // height their row gives up, which lays out to `auto` and so can't be a CSS - // transition. - let controlsEl = $state(); - let controlsFirst: FlipSnapshot | undefined; - const CONTROLS_FLIP = { animateHeight: true }; - - $effect.pre(() => { - // Measure before the DOM reflows for the new state. - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - collapsed.value; - - controlsFirst = captureFlip(controlsEl, CONTROLS_FLIP); - }); - - $effect(() => { - // ...and play once it has. - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - collapsed.value; - - playFlip(controlsEl, controlsFirst, CONTROLS_FLIP); - }); - // How far below the minimum width the drag has to go before it collapses, // rather than the edge just sticking at the minimum. const COLLAPSE_DRAG_SLACK_REM = 2; @@ -89,6 +92,12 @@ const RAIL_STRETCH_FACTOR = 0.35; let railStretch = $state(0); + // What `.slot` is animating towards. + const bodyTargetWidth = $derived( + (mini ? RAIL_WIDTH_REM + railStretch : renderedSidebarWidth.value) * + rootFontSize(), + ); + // Crossing the threshold is a jump between two widths rather than tracking // the pointer, so the transition is switched back on to carry it. Matches the // width transition on .slot. @@ -332,25 +341,31 @@ } .sidebar { - /* Containing block for the window controls and the resize edge. Isolated so - both layer against the sidebar, not against portalled popovers. */ + /* Containing block for the resize edge. Isolated so it layers against the + sidebar, not against portalled popovers. */ position: relative; isolation: isolate; width: 100%; height: 100%; - display: flex; - flex-direction: column; min-height: 0; border-right: 1px solid var(--color-border-subtle); background-color: var(--color-surface-base); transition: background-color 0.2s ease; + /* One cell the two views stack in. Tracks are sized from the sidebar, not + from its content: an `auto` track would hold both at the wider width. */ + display: grid; + grid-template: minmax(0, 1fr) / minmax(0, 1fr); + overflow: hidden; } .sidebar.mini { background-color: var(--color-surface-canvas); } - /* Carries the square-up below. */ - .sidebar :global(.button) { - transition: width 0.2s ease; + .view { + grid-area: 1 / 1; + position: relative; + display: flex; + flex-direction: column; + min-height: 0; } /* The collapse contract: `.label` anywhere in the sidebar is text that gives up its space on the rail. Opted into by class alone, so a child that wants @@ -359,11 +374,10 @@ .sidebar :global(.label) { white-space: nowrap; pointer-events: none; - transition: opacity 0.2s ease; } /* A label still fading at full width would push its icon off the rail's centre line, so it gives up the space too. */ - .sidebar.mini :global(.label) { + .view.mini :global(.label) { opacity: 0; width: 0; min-width: 0; @@ -391,7 +405,7 @@ /* Expanded, the controls lift into the drag strip beside the traffic lights. They stay in the row's DOM so the same elements survive the toggle and can glide into the rail's column. */ - .sidebar:not(.mini) .window-controls { + .view:not(.mini) .window-controls { position: absolute; top: 0; right: 0.5rem; @@ -403,11 +417,11 @@ } /* Nothing left in flow once they lift, so dropping the padding collapses the row to nothing and gives the space back to the nav. */ - .sidebar:not(.mini) .controls { + .view:not(.mini) .controls { padding: 0; } - .sidebar.mini .controls, - .sidebar.mini .window-controls { + .view.mini .controls, + .view.mini .window-controls { flex-direction: column; gap: 0.125rem; align-items: center; @@ -439,18 +453,17 @@ gap: 0.25rem; } /* The rail is one centred column. */ - .sidebar.mini .nav { + .view.mini .nav { align-items: center; } /* The rail carries navigation only. */ - .sidebar.mini .bottom { + .view.mini .bottom { display: none; } - /* One 2rem square per item, so the hover/selected highlight is a uniform - square rather than a full-width bar. */ - .sidebar.mini :global(.nav-item), - .sidebar.mini :global(.button), - .sidebar.mini :global(.filter-button) { + /* One 2rem square per row, so the hover/selected highlight is a uniform + square rather than a full-width bar. Rows only: a `min-width` here would + outrank the 1.5rem squares the icon buttons set inline. */ + .view.mini :global(.nav-item) { width: 2rem; min-width: 2rem; height: 2rem; @@ -459,14 +472,13 @@ /* Otherwise held open by the zero-width label. */ gap: 0; } - /* Nav items are left out: their 0.5rem padding either side of a 1rem icon - already centres it in the 2rem square. */ - .sidebar.mini :global(.button), - .sidebar.mini :global(.filter-button) { + .view.mini :global(.button), + .view.mini :global(.filter-button) { justify-content: center; + gap: 0; } /* `margin-left: auto` on a centred square drags the icon off centre. */ - .sidebar.mini :global(.update-badge) { + .view.mini :global(.update-badge) { display: none; } .nav-item { @@ -481,7 +493,6 @@ width: 100%; text-decoration: none; white-space: nowrap; - transition: width 0.2s ease; } .nav-item:hover { background-color: var(--color-surface-subtle); @@ -575,153 +586,163 @@ ? `${RAIL_WIDTH_REM + railStretch}rem` : `${renderedSidebarWidth.value}rem`}>