diff --git a/packages/app/e2e/regression/panel-edges.spec.ts b/packages/app/e2e/regression/panel-edges.spec.ts index 8c100a0d742a..46f71143f7fb 100644 --- a/packages/app/e2e/regression/panel-edges.spec.ts +++ b/packages/app/e2e/regression/panel-edges.spec.ts @@ -1,76 +1,119 @@ -import { expect, test, type Locator, type Page } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" import { seed, sessionHref } from "../utils/app" import { fixture, installStressSessionTabs, mockStressTimeline } from "../utils/session-fixture" test.use({ viewport: { width: 1440, height: 900 } }) -// Samples every frame of a click toggle until the edge has rested for 300 ms: the moving edge, and the gap from the -// element before it to the one after. -async function trace(page: Page, toggle: Locator, before: string, after: string) { +const sidebar = '[data-slot="vertical-tabs-sidebar"]' + +const sidebarPanel = `${sidebar} > div` + +const sidePanel = '[data-slot="session-side-panel-presence"]' + +// Samples every frame for 1.5 s after `toggle`: the content's edge beside the panel, and the panel's opacity and shift. +async function trace(page: Page, toggle: () => Promise, edge: string, panel: string) { const frames = page.evaluate( - ([before, after]) => - new Promise<{ edge: number; gap?: number }[]>((resolve) => { + ([edge, panel]) => + new Promise<{ edge: number; opacity: number; shift: number }[]>((resolve) => { const start = performance.now() - const out: { edge: number; gap?: number }[] = [] - let changed: number | undefined - - const tick = (now: number) => { - const a = document.querySelector(before)?.getBoundingClientRect() - const b = document.querySelector(after)?.getBoundingClientRect() - const edge = (a ?? b)!.right + const out: { edge: number; opacity: number; shift: number }[] = [] - if (out.length && edge !== out.at(-1)!.edge) changed = now - // A settled-closed panel leaves the row, so the gap only holds while it has width. - out.push({ edge, gap: a && b && b.width > 0 ? b.left - a.right : undefined }) + // The rAF timestamp follows the slowed animation timeline, so time the window with performance.now(). + const tick = () => { + const style = getComputedStyle(document.querySelector(panel)!) + out.push({ + edge: document.querySelector(edge)!.getBoundingClientRect().right, + opacity: Number(style.opacity), + shift: style.transform === "none" ? 0 : new DOMMatrix(style.transform).m41, + }) - if (changed === undefined ? now - start < 10_000 : now - changed < 300) return requestAnimationFrame(tick) + if (performance.now() - start < 1500) return requestAnimationFrame(tick) resolve(out) } requestAnimationFrame(tick) }), - [before, after] as const, + [edge, panel] as const, ) - await toggle.click({ force: true }) + await toggle() return frames } -test("the content beside a panel follows the panel's edge on every frame of a toggle", async ({ page }) => { +for (const direction of ["ltr", "rtl"] as const) { + test(`a click opens a panel with a fade and closes it at once, the content moving in one frame (${direction})`, async ({ + page, + }) => { + await mockStressTimeline(page) + await installStressSessionTabs(page) + await seed(page, { settings: { appearance: { tabLayout: "vertical", sidebarOpen: true } } }) + await page.goto(sessionHref(fixture.targetID)) + await page.evaluate((direction) => (document.documentElement.dir = direction), direction) + await expect(page.getByRole("heading", { name: fixture.expected.targetTitle })).toBeVisible() + // A fifth of the speed, so even a slow runner samples several frames of each fade. + const devtools = await page.context().newCDPSession(page) + await devtools.send("Animation.enable") + await devtools.send("Animation.setPlaybackRate", { playbackRate: 0.2 }) + + const sidebarToggle = page + .locator( + `${sidebar} button[aria-controls="vertical-tabs-sidebar"], main button[aria-controls="vertical-tabs-sidebar"]`, + ) + .locator("visible=true") + .first() + + const reviewToggle = page.locator('[data-slot="session-review-toggle"] button') + + // Each panel shifts toward its own edge: the sidebar toward the start, the side panel toward the end. + const start = direction === "ltr" ? -1 : 1 + + const chat = '[data-slot="session-chat-panel"]' + + // The sidebar starts open and the side panel closed. + const runs = [ + { toggle: sidebarToggle, edge: sidebar, panel: sidebarPanel, shift: 10 * start, opens: false }, + { toggle: sidebarToggle, edge: sidebar, panel: sidebarPanel, shift: 10 * start, opens: true }, + { toggle: reviewToggle, edge: chat, panel: sidePanel, shift: -12 * start, opens: true }, + { toggle: reviewToggle, edge: chat, panel: sidePanel, shift: -12 * start, opens: false }, + ] + + for (const run of runs) { + const frames = await trace(page, () => run.toggle.click({ force: true }), run.edge, run.panel) + // The layout jumps straight from where it was to where it lands. + expect(new Set(frames.map((frame) => Math.round(frame.edge))).size).toBeLessThanOrEqual(2) + + if (!run.opens) { + expect(frames.every((frame) => frame.opacity === 1 && frame.shift === 0)).toBe(true) + continue + } + + // An opening panel fades over several frames from 0.15 and shifts in from at most its offset toward its edge. + const opacities = frames.map((frame) => frame.opacity).filter((opacity) => opacity < 1) + expect(new Set(opacities).size).toBeGreaterThan(2) + expect(Math.min(...opacities)).toBeGreaterThan(0.14) + frames.forEach((frame) => { + expect(Math.abs(frame.shift)).toBeLessThanOrEqual(Math.abs(run.shift) + 0.5) + expect(frame.shift * run.shift).toBeGreaterThanOrEqual(0) + }) + } + }) +} + +test("keyboard toggles switch the panels without motion", async ({ page }) => { await mockStressTimeline(page) await installStressSessionTabs(page) - // Half speed, so even a slow runner draws several frames of each flight. - await seed(page, { - settings: { appearance: { tabLayout: "vertical", sidebarOpen: true } }, - storage: { "opencode.motion.v2": { speed: 0.5 } }, - }) + await seed(page, { settings: { appearance: { tabLayout: "vertical", sidebarOpen: true } } }) await page.goto(sessionHref(fixture.targetID)) await expect(page.getByRole("heading", { name: fixture.expected.targetTitle })).toBeVisible() - const sidebar = page - .locator( - '[data-slot="vertical-tabs-sidebar"] button[aria-controls="vertical-tabs-sidebar"], main button[aria-controls="vertical-tabs-sidebar"]', - ) - .locator("visible=true") - .first() - - const review = page.locator('[data-slot="session-review-toggle"] button') - - const runs = [ - { toggle: sidebar, before: '[data-slot="vertical-tabs-sidebar"] > div > div', after: "main", gap: 0 }, - { toggle: sidebar, before: '[data-slot="vertical-tabs-sidebar"] > div > div', after: "main", gap: 0 }, - { toggle: review, before: '[data-slot="session-chat-panel"]', after: "#review-panel", gap: 8 }, - { toggle: review, before: '[data-slot="session-chat-panel"]', after: "#review-panel", gap: 8 }, - ] - - for (const run of runs) { - const frames = await trace(page, run.toggle, run.before, run.after) - const edges = frames.map((frame) => frame.edge) - // The toggle animates over several frames rather than jumping to its end, and never doubles back. - expect(new Set(edges.map(Math.round)).size).toBeGreaterThan(3) - const steps = edges.slice(1).map((edge, index) => edge - edges[index]) - expect(steps.every((step) => step >= -0.5) || steps.every((step) => step <= 0.5)).toBe(true) - frames.flatMap((frame) => frame.gap ?? []).forEach((gap) => expect(gap).toBeCloseTo(run.gap, 0)) + for (const [shortcut, panel] of [ + ["ControlOrMeta+Shift+B", sidebarPanel], + ["ControlOrMeta+Shift+B", sidebarPanel], + ["ControlOrMeta+Shift+R", sidePanel], + ["ControlOrMeta+Shift+R", sidePanel], + ] as const) { + const frames = await trace(page, () => page.keyboard.press(shortcut), sidebar, panel) + expect(frames.every((frame) => frame.opacity === 1 && frame.shift === 0)).toBe(true) } }) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 5cfb8b78c4c2..3271b8238c2f 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -85,8 +85,6 @@ ); } - /* The review panel's visible width, which the header fade below tracks while the panel animates. Its fixed-width - content holds nothing else that reads it. */ #review-panel, #bottom-panel { container-type: inline-size; diff --git a/packages/app/src/runtime/extension/side-region.tsx b/packages/app/src/runtime/extension/side-region.tsx index b032289ed682..900135762942 100644 --- a/packages/app/src/runtime/extension/side-region.tsx +++ b/packages/app/src/runtime/extension/side-region.tsx @@ -29,7 +29,7 @@ import { type SessionPanelMenuItem, } from "@opencode/gui-extensions/sdk" import { useCommand } from "@/shell/commands/command" -import { guardClosingFocus } from "@/shell/panel-motion" +import { releaseClosedFocus } from "@/shell/panel-motion" import { useLanguage } from "@/runtime/i18n/language" import { useLayout } from "@/shell/state/layout" import { useSessionLayout } from "@/session/session-layout" @@ -51,7 +51,6 @@ export function SideRegion(props: { screen: SessionScreen region: Region sidebar: PanelSidebar - present?: boolean size: Sizing drag: PaneDrag }) { @@ -61,10 +60,9 @@ export function SideRegion(props: { const isDesktop = createMediaQuery("(min-width: 768px)") const tabsOpen = createMemo(() => isDesktop() && view().side.opened()) - const tabsVisible = createMemo(() => tabsOpen() || !!props.present) // Once shown, the contents stay mounted while the region is hidden, so reopening doesn't rebuild them mid-flight. - const contentMounted = createMemo((mounted) => mounted || tabsVisible(), false) - const panelWidth = createMemo(() => (tabsVisible() ? "auto" : "0px")) + const contentMounted = createMemo((mounted) => mounted || tabsOpen(), false) + const panelWidth = createMemo(() => (tabsOpen() ? "auto" : "0px")) const menu = createSessionPanelMenu() const primary = createMemo(() => menu().find((item) => item.primary)) @@ -99,9 +97,9 @@ export function SideRegion(props: { const lowerHeight = createMemo(() => Math.round(column.height * (1 - view().split.ratio()))) - guardClosingFocus({ + releaseClosedFocus({ root, - closing: () => !tabsOpen() && tabsVisible(), + open: tabsOpen, target: () => document.querySelector('[data-slot="session-review-toggle"] button') ?? undefined, }) @@ -114,12 +112,9 @@ export function SideRegion(props: { aria-label={language.t("session.panel.reviewAndFiles")} aria-hidden={!tabsOpen()} class="relative h-full shrink-0 min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)] outline-none" - classList={{ "flex-1": tabsVisible() }} + classList={{ "flex-1": tabsOpen() }} style={{ width: panelWidth() }} > - {/* Takes the pointer while the panel closes. Inherited pointer-events or inert would restyle every element in - the panel when they flip; once closed, the screen skips the region with content-visibility instead. */} -
!props.size.active()} placeholder={index() === 0} actions={index() === 0} diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index b0b6f9205deb..60f044da7f36 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -44,7 +44,7 @@ import { SessionIdentityHeader, SessionLocation } from "./session-identity-heade import { useServer } from "@/runtime/server/current" import { SessionReviewToggle } from "./header/session-header-actions" import { createTimelineCache } from "./timeline/cache" -import { createPanelMotion, replayPanelToggle } from "@/shell/panel-motion" +import { createPanelMotion } from "@/shell/panel-motion" export function SessionScreenView(props: { session: SessionModel }) { // The timeline cache captures its owner when created, so link handling must be provided above it. @@ -86,12 +86,9 @@ function SessionScreenContent(props: { closed: attachment.closed, open: (key) => attachment.openTab(key, props.view()), side: () => session.layout.view().side, - // Closing the last tab closes the side region, which opens with a new tab next time. The user's own hand closed - // it, so it goes at once. + // Closing the last tab closes the side region, which opens with a new tab next time. onEmptied: () => { - if (!session.layout.view().side.opened()) return - session.layout.view().side.toggle() - sideMotion.sync(false) + if (session.layout.view().side.opened()) session.layout.view().side.toggle() }, settled: (run) => sideMotion.whenSettled(run), }) @@ -146,34 +143,15 @@ function SessionScreenContent(props: { }), } - // The chat keeps its open width as the far end of a close's travel. - const chatOpenWidth = createMemo((previous) => (sideVisible() ? screen.panel.width() : previous), "100%") - let chat: HTMLDivElement | undefined + let sidePresence: HTMLDivElement | undefined - // Once opened, the side region stays mounted and is skipped by content-visibility when a close settles, so neither - // direction mounts its contents mid-flight, and a reopen reuses their style and layout. + // Once opened, the side region stays mounted and is skipped by content-visibility when a close settles, so a reopen + // reuses its style and layout. const sideMotion = createPanelMotion({ open: sideVisible, + element: () => sidePresence, + offset: 12, identity: session.layout.tabKey, - // The chat's real width moves the side panel's edge, whose contents keep their open width pinned to it. - paint: (closed) => { - if (!chat) return - const open = chatOpenWidth() - - if (closed === 1) { - chat.style.width = "100%" - - return - } - - chat.style.width = closed === 0 ? open : `calc(${open} + (100% - ${open}) * ${closed})` - }, - }) - - replayPanelToggle({ - id: "side", - label: "Toggle the side panel", - toggle: () => session.layout.view().side.toggle(), }) const hideTimelineScrollbar = () => setStore("timelineScrollbarHidden", true) @@ -415,10 +393,10 @@ function SessionScreenContent(props: {
- +
-
-
- -
- -
-
-
+
+ +
+ +
+
- +
{/* Hidden rather than unmounted while the user hides it, so its terminals keep their screens. */} diff --git a/packages/app/src/shell/panel-motion.ts b/packages/app/src/shell/panel-motion.ts index ff26ce949e53..dbf42c5f31a7 100644 --- a/packages/app/src/shell/panel-motion.ts +++ b/packages/app/src/shell/panel-motion.ts @@ -1,185 +1,122 @@ import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js" -import { frameLoop, prefersReducedMotion, Spring } from "@opencode/ui/motion" -import { motionTuning } from "@opencode/ui/motion-tuning" +import { prefersReducedMotion, Spring } from "@opencode/ui/motion" -const group = "Sidebars" - -// Panels in flight, across the app. Native views, which no DOM box clips, stay hidden while any panel moves. +// Panels opening, across the app. Native views, which can't fade with the DOM, stay hidden until the panel lands. const [flights, setFlights] = createSignal(0) export const panelsMoving = () => flights() > 0 -// The left sidebar and the side panel share one spring, so they move as one system. -const spring = motionTuning.spring("sidebars", { - label: "Spring", - group, - shipped: "smooth", - presets: { - snappy: { duration: 0.2, bounce: 0 }, - smooth: { duration: 0.3, bounce: 0 }, - calm: { duration: 0.45, bounce: 0 }, - }, -}) - -const style = motionTuning.choice("sidebars.style", { - label: "Motion", - group, - value: "spring", - labels: { spring: "Spring", instant: "Instant" }, -}) - -/** Adds a button that toggles a panel to the Motion workspace's Sidebars section while the panel is mounted. */ -export function replayPanelToggle(input: { id: string; label: string; toggle: () => void }) { - onCleanup(motionTuning.action({ id: `sidebars.${input.id}`, label: input.label, group, run: input.toggle })) +// Layout changes at once; an opening panel shifts in a few pixels from its edge and fades up. Both run as Web +// Animations on transform and opacity, so the compositor plays them through main-thread work like mounting the panel. +const shift = springEasing(0.24) + +const fade = { duration: 180, easing: "cubic-bezier(0.44, 0.75, 0.63, 1.03)" } + +// Shortcuts toggle panels many times a day, so a toggle from the keyboard skips the motion. +let keyboard = false + +if (typeof window !== "undefined") { + window.addEventListener("keydown", () => (keyboard = true), true) + window.addEventListener("pointerdown", () => (keyboard = false), true) } /** - * Opens and closes an edge panel on the shared sidebar spring. `paint` receives how far closed the panel is, from 0 - * (open) to 1 (closed), every frame of a flight and whenever a signal it reads changes at rest. It sizes the panel's - * real layout box, so the neighbouring content's edge follows the panel's edge on every frame. `present` stays true - * until a close settles. `whenSettled` defers work that would force a layout, like moving focus, until the flight - * lands; `sync` shows a state at once, for changes the user makes by hand. + * Opens an edge panel by shifting it `offset` pixels in from its edge (negative for the inline-start edge; mirrored in + * RTL) while it fades up from 0.15. Closing is instant and cancels an opening in flight. `whenSettled` defers work + * until the opening lands. */ export function createPanelMotion(input: { open: Accessor - paint: (closed: number) => void + element: Accessor + offset: number /** A change shows the new state without motion, e.g. switching to another session's layout. */ identity?: Accessor - onClosed?: () => void }) { - const [present, setPresent] = createSignal(input.open()) - // Percent closed, so the spring's rest threshold stays well under a pixel. - const motion = new Spring(input.open() ? 0 : 100) - let stop: (() => void) | undefined + let animations: Animation[] = [] let landed: (() => void)[] = [] - // A state `sync` asked to show at once, for when the change hasn't reached this motion yet, e.g. inside a batch. - let snapTo: boolean | undefined - let flying = false - - const fly = (next: boolean) => { - if (flying === next) return - flying = next - setFlights((count) => count + (next ? 1 : -1)) - } - - const paint = () => input.paint(Math.min(1, Math.max(0, motion.value / 100))) const settle = () => { - stop?.() - stop = undefined - fly(false) + if (animations.length > 0) setFlights((count) => count - 1) + animations.forEach((animation) => animation.cancel()) + animations = [] const queued = landed landed = [] - - if (!input.open()) { - setPresent(false) - input.onClosed?.() - } - queued.forEach((run) => run()) } - const snap = (open: boolean) => { - motion.snap(open ? 0 : 100) - paint() - settle() - } - createEffect( on( () => [input.open(), input.identity?.()] as const, ([open, identity], previous) => { - const target = open ? 0 : 100 - // Work queued for the previous direction no longer applies. - landed = [] - const now = snapTo === open - snapTo = undefined - - if (open) setPresent(true) - - if (previous === undefined || previous[1] !== identity) { - stop?.() - stop = undefined - fly(false) - motion.snap(target) - paint() - - if (!open) setPresent(false) - - return - } - - if (now || prefersReducedMotion() || style() === "instant") return snap(open) - - const tuning = spring() - motion.omega = (2 * Math.PI) / tuning.duration - motion.zeta = 1 - tuning.bounce - // Retargeting keeps the current position and velocity, so a second toggle mid-flight turns around smoothly. - motion.set(target) - fly(true) - // Writes only; anything reading layout this frame does so in the loop's read phase, before this runs. - stop ??= frameLoop.add((dt) => { - motion.step(dt) - paint() - - if (!motion.resting) return true - stop = undefined - settle() - - return false - }) + // Work queued for an opening that a close interrupted no longer applies. + if (!open) landed = [] + settle() + const element = input.element() + + const instant = !previous || previous[1] !== identity || keyboard || prefersReducedMotion() + + if (!open || instant || !element?.animate) return + + const x = input.offset * (element.matches(":dir(rtl)") ? -1 : 1) + setFlights((count) => count + 1) + animations = [ + element.animate({ transform: [`translateX(${x}px)`, "none"] }, shift), + element.animate({ opacity: [0.15, 1] }, fade), + ] + const current = animations + void Promise.all(current.map((animation) => animation.finished)).then( + () => current === animations && settle(), + () => undefined, + ) }, ), ) - // Repaints at rest when the open size changes, e.g. a resize drag or a window resize. - createEffect(paint) - - onCleanup(() => { - stop?.() - fly(false) - }) + onCleanup(settle) return { - present, - sync: (open: boolean) => { - if (stop && input.open() === open) return snap(open) - snapTo = open - }, whenSettled: (run: () => void) => { - if (stop) return void landed.push(run) + if (animations.length > 0) return void landed.push(run) run() }, } } /** - * Keeps focus out of a closing panel without `inert`, which would restyle every element in it: focus inside moves to - * `target` when the close starts and whenever it enters during the flight. A settled panel is skipped, so nothing in - * it can take focus. + * A critically damped spring of SwiftUI-style `duration` seconds, sampled per frame into a CSS `linear()` easing. It + * ends once within 0.5% of rest, a twentieth of a pixel on these shifts. */ -export function guardClosingFocus(input: { - root: Accessor - closing: Accessor - target: () => HTMLElement | undefined -}) { - const evict = () => { - const root = input.root() +function springEasing(duration: number) { + const spring = Spring.fromDuration(0, { duration, bounce: 0 }).set(1) + const points = [0] - if (!input.closing() || !root?.contains(document.activeElement)) return - const target = input.target() + while (points.at(-1)! < 0.995) points.push(Math.round(spring.step(1 / 60) * 1000) / 1000) + points[points.length - 1] = 1 - if (target) return target.focus({ preventScroll: true }) + return { duration: ((points.length - 1) * 1000) / 60, easing: `linear(${points.join(", ")})` } +} - if (document.activeElement instanceof HTMLElement) document.activeElement.blur() - } +/** + * Moves focus out of a panel when it closes, to `target`, without `inert`, which would restyle every element in it. + * The closed panel is skipped by content-visibility, so nothing in it can take focus afterwards. + */ +export function releaseClosedFocus(input: { + root: Accessor + open: Accessor + target: () => HTMLElement | undefined +}) { + createEffect( + on( + input.open, + (open) => { + if (open || !input.root()?.contains(document.activeElement)) return + const target = input.target() - createEffect(on(input.closing, evict, { defer: true })) - createEffect(() => { - const root = input.root() + if (target) return target.focus({ preventScroll: true }) - if (!root) return - root.addEventListener("focusin", evict) - onCleanup(() => root.removeEventListener("focusin", evict)) - }) + if (document.activeElement instanceof HTMLElement) document.activeElement.blur() + }, + { defer: true }, + ), + ) } diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index 483987c0ae71..6932a6cb75f3 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -13,8 +13,8 @@ import { ExtensionServerCover } from "@/runtime/extension/server-shell" import { ExtensionSlot } from "@/runtime/extension/render" import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" -import { CollapsedSidebarToggle, paintCollapsedSlots } from "@/shell/sidebar-toggle" -import { createPanelMotion, guardClosingFocus, replayPanelToggle } from "@/shell/panel-motion" +import { CollapsedSidebarToggle } from "@/shell/sidebar-toggle" +import { createPanelMotion, releaseClosedFocus } from "@/shell/panel-motion" export default function Layout(props: ParentProps) { const platform = usePlatform() @@ -28,32 +28,21 @@ export default function Layout(props: ParentProps) { tabsWidth: number tabsMount: HTMLElement | undefined tabsAside: HTMLElement | undefined + tabsPanel: HTMLElement | undefined }>({ tabsWidth: 260, tabsMount: undefined, tabsAside: undefined, + tabsPanel: undefined, }) const verticalTabs = () => preferences.appearance.tabLayout() !== "horizontal" && !mobile() - const toggleSidebar = () => preferences.appearance.setSidebarOpen(!preferences.appearance.sidebarOpen()) + createPanelMotion({ open: preferences.appearance.sidebarOpen, element: () => state.tabsPanel, offset: -10 }) - const sidebarMotion = createPanelMotion({ - open: preferences.appearance.sidebarOpen, - // The aside's real width moves main's edge with the sidebar's; its contents keep their width, pinned to that edge. - paint: (closed) => { - const aside = state.tabsAside - - if (aside) aside.style.width = `${state.tabsWidth * (1 - closed)}px` - paintCollapsedSlots(closed) - }, - }) - - replayPanelToggle({ id: "sidebar", label: "Toggle the left sidebar", toggle: toggleSidebar }) - - guardClosingFocus({ + releaseClosedFocus({ root: () => state.tabsAside, - closing: () => !preferences.appearance.sidebarOpen() && sidebarMotion.present(), + open: preferences.appearance.sidebarOpen, // The toggle that reopens the sidebar, outside it. target: () => [...document.querySelectorAll('button[aria-controls="vertical-tabs-sidebar"]')].find( @@ -69,7 +58,7 @@ export default function Layout(props: ParentProps) { title: language.t("command.sidebar.toggle"), category: language.t("command.category.view"), keybind: "mod+shift+b", - onSelect: toggleSidebar, + onSelect: () => preferences.appearance.setSidebarOpen(!preferences.appearance.sidebarOpen()), }, ] : [], @@ -104,12 +93,14 @@ export default function Layout(props: ParentProps) { data-state={preferences.appearance.sidebarOpen() ? "open" : "closed"} ref={(element) => setState("tabsAside", element)} class="relative z-10 h-full min-h-0 shrink-0" + style={{ width: preferences.appearance.sidebarOpen() ? `${state.tabsWidth}px` : "0px" }} > {/* Skipped rather than display: none or inert, so neither direction restyles the sidebar's contents and a reopen reuses their layout. */}
setState("tabsPanel", element)} class="absolute inset-0 overflow-hidden [contain:layout_style_paint]" - classList={{ "[content-visibility:hidden]": !sidebarMotion.present() }} + classList={{ "[content-visibility:hidden]": !preferences.appearance.sidebarOpen() }} >
setState("tabsMount", element)} @@ -120,11 +111,6 @@ export default function Layout(props: ParentProps) { }} />
- {/* Takes the pointer while the sidebar closes, without restyling its contents. */} -
command.keybindParts("sidebar.toggle") - const side = props.handoff - const ref = side && collapsedRef((button, closed) => (button.style.opacity = `${handoff(side, closed)}`)) return ( - } + icon={} /> ) @@ -104,62 +92,21 @@ function useCollapsedSlot() { const slotClass = "flex shrink-0 justify-end overflow-hidden" -// How far closed the left sidebar is, from 0 to 1. The shell paints it on every frame of the sidebar's motion; the -// collapsed toggle and the space headers keep for it grow with it, so header contents travel with the sidebar's edge. -const collapsed = { closed: 0, slots: new Set<() => void>() } - -export function paintCollapsedSlots(closed: number) { - collapsed.closed = closed - collapsed.slots.forEach((paint) => paint()) -} - -/** A ref whose element `paint` styles from how far closed the sidebar is, now and on every frame of its motion. */ -function collapsedRef(paint: (element: HTMLElement, closed: number) => void) { - let target: HTMLElement | undefined - - const repaint = () => { - if (target) paint(target, collapsed.closed) - } - - collapsed.slots.add(repaint) - onCleanup(() => collapsed.slots.delete(repaint)) - createEffect(repaint) - - return (element: HTMLElement) => { - target = element - repaint() - } -} - -/** A ref for a slot that is `width` wide while the sidebar is closed. */ -function slotRef(width: () => number) { - return collapsedRef((slot, closed) => (slot.style.width = `${width() * closed}px`)) -} - -// The collapsed toggle rides the content's edge, so the two never meet: the sliding one fades out as it passes the -// collapsed slot, then the collapsed one fades up, and only one shows at a time. -function handoff(side: "out" | "in", closed: number) { - if (side === "out") return 1 - Math.min(1, Math.max(0, (closed - 0.55) / 0.2)) - - return Math.min(1, Math.max(0, (closed - 0.75) / 0.25)) -} - // Floats over the top of every screen, centered on the session header's 48px row. Appears at the content's start edge // when the sidebar closes. export function CollapsedSidebarToggle() { const slot = useCollapsedSlot() - const ref = slotRef(slot.width) return (
- +
@@ -170,11 +117,14 @@ export function CollapsedSidebarToggle() { // the space begins, in pixels from the content area's start edge, so every header's contents line up after the toggle. export function CollapsedSidebarToggleSpace(props: { start?: number; class?: string }) { const slot = useCollapsedSlot() - const ref = slotRef(() => slot.width() + contentStart - (props.start ?? contentStart)) return ( -