mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-11 16:52:16 +03:00
feat(app): peek the sidebar from the window edge (#58244)
This commit is contained in:
committed by
GitHub
Unverified
parent
6aa0d5c203
commit
60bcd8aab9
@@ -58,8 +58,8 @@ test("the content beside a panel follows the panel's edge on every frame of a to
|
||||
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: sidebar, before: '[data-slot="vertical-tabs-mount"]', after: "main", gap: 0 },
|
||||
{ toggle: sidebar, before: '[data-slot="vertical-tabs-mount"]', 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 },
|
||||
]
|
||||
|
||||
@@ -29,6 +29,9 @@ const spring = motionTuning.spring("sidebars", {
|
||||
},
|
||||
})
|
||||
|
||||
/** The shared spring, for motion that must read as the sidebar's own, like its peek. */
|
||||
export const panelSpring = spring
|
||||
|
||||
const style = motionTuning.choice("sidebars.style", {
|
||||
label: "Motion",
|
||||
group,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { prefersReducedMotion } from "@opencode/ui/motion"
|
||||
import { Titlebar } from "@/shell/titlebar/titlebar"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ToastRegion } from "@/shell/notifications/toast"
|
||||
@@ -16,11 +17,20 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { CollapsedSidebarToggle, measureSidebarToggles, paintCollapsedSlots } from "@/shell/sidebar-toggle"
|
||||
import {
|
||||
CollapsedSidebarToggle,
|
||||
collapsedSidebarToggleEnd,
|
||||
measureSidebarToggles,
|
||||
paintCollapsedSlots,
|
||||
} from "@/shell/sidebar-toggle"
|
||||
import { createPanelMotion, guardClosingFocus, panelOpacity, replayPanelToggle } from "@/shell/panel-motion"
|
||||
import { createSidebarPeek } from "@/shell/sidebar-peek"
|
||||
import { windowDragEnabled } from "@/session/header/drag-area"
|
||||
|
||||
// How far Home steps along a peek's header to clear the collapsed toggle: to the toggle's end, from the sidebar's 10px
|
||||
// start padding.
|
||||
const homeClearance = collapsedSidebarToggleEnd - 10
|
||||
|
||||
export const sidebarToggleCommand = { id: "sidebar.toggle", keybind: "mod+shift+b" }
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
@@ -51,20 +61,65 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const toggleSidebar = () => preferences.appearance.setSidebarOpen(!preferences.appearance.sidebarOpen())
|
||||
|
||||
// How far closed the docked sidebar is, and how far in its peek has it, undefined while it isn't peeking.
|
||||
const sidebar: { closed: number; peek: number | undefined } = {
|
||||
closed: preferences.appearance.sidebarOpen() ? 0 : 1,
|
||||
peek: undefined,
|
||||
}
|
||||
|
||||
let peekSurface: HTMLDivElement | undefined
|
||||
let peekHome: HTMLElement | null | undefined
|
||||
|
||||
// The docked sidebar and its peek are one sidebar, shown as far in as whichever has it further.
|
||||
const sidebarHidden = () => (sidebar.peek === undefined ? sidebar.closed : Math.min(sidebar.closed, 1 - sidebar.peek))
|
||||
|
||||
// A peek slides the contents in its own box, where docking slides them by sizing the aside. Reduced motion fades
|
||||
// them in place instead.
|
||||
const paintSidebarContents = () => {
|
||||
const mount = state.tabsMount
|
||||
|
||||
if (!mount) return
|
||||
const peeking = sidebar.peek !== undefined
|
||||
const hidden = sidebarHidden()
|
||||
const fade = peeking && prefersReducedMotion()
|
||||
const rtl = document.documentElement.dir === "rtl"
|
||||
const shift = peeking && !fade && hidden > 0 ? `translateX(${(rtl ? 100 : -100) * hidden}%)` : ""
|
||||
mount.style.opacity = fade ? (hidden === 0 ? "" : `${1 - hidden}`) : panelOpacity(hidden)
|
||||
mount.style.transform = shift
|
||||
|
||||
if (peekSurface) {
|
||||
// Lifted while it floats, and settling as a dock lands; under reduced motion it fades with the contents.
|
||||
peekSurface.style.opacity = peeking ? `${sidebar.closed * (fade ? 1 - hidden : 1)}` : ""
|
||||
peekSurface.style.transform = shift
|
||||
}
|
||||
|
||||
// Without traffic lights, Home starts the sidebar's header right where the collapsed toggle floats over a peek, so
|
||||
// it steps past the toggle until a dock carries the toggle to the header's end.
|
||||
if (peeking && peekHome === undefined)
|
||||
peekHome = mount.querySelector<HTMLElement>('[data-slot="sidebar-header"][data-home] > :first-child')
|
||||
|
||||
if (peekHome)
|
||||
peekHome.style.transform = peeking ? `translateX(${(rtl ? -1 : 1) * homeClearance * sidebar.closed}px)` : ""
|
||||
|
||||
if (!peeking) peekHome = undefined
|
||||
}
|
||||
|
||||
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) => {
|
||||
sidebar.closed = closed
|
||||
const aside = state.tabsAside
|
||||
|
||||
if (aside) aside.style.width = `${state.tabsWidth * (1 - closed)}px`
|
||||
|
||||
// Dims the contents, background included, as they leave and brightens them as they arrive.
|
||||
if (state.tabsMount) state.tabsMount.style.opacity = panelOpacity(closed)
|
||||
paintSidebarContents()
|
||||
paintCollapsedSlots(closed)
|
||||
},
|
||||
measure: () => measureSidebarToggles(state.tabsWidth),
|
||||
element: () => state.tabsPanel,
|
||||
measure: () => measureSidebarToggles(state.tabsWidth * sidebarHidden()),
|
||||
// A dock from a peek already shows the sidebar, so reduced motion has nothing to fade in.
|
||||
element: () => (peek.handedOff() ? undefined : state.tabsPanel),
|
||||
offset: () => -10,
|
||||
})
|
||||
|
||||
@@ -73,7 +128,13 @@ export default function Layout(props: ParentProps) {
|
||||
open: preferences.appearance.sidebarOpen,
|
||||
aside: () => state.tabsAside,
|
||||
panel: () => state.tabsPanel,
|
||||
content: () => state.tabsMount,
|
||||
paint: (shown) => {
|
||||
sidebar.peek = shown
|
||||
paintSidebarContents()
|
||||
},
|
||||
// James opens the docked panel with its own shift and fade.
|
||||
continues: () => !sidebarMotion.pinned(),
|
||||
whenDocked: sidebarMotion.whenSettled,
|
||||
})
|
||||
|
||||
replayPanelToggle({ id: "sidebar", label: "Toggle the left sidebar", toggle: toggleSidebar })
|
||||
@@ -180,6 +241,15 @@ export default function Layout(props: ParentProps) {
|
||||
// Under James the sidebar keeps its landing width (start edge held) while the layout moves.
|
||||
style={{ width: sidebarMotion.pinned() ? `${state.tabsWidth}px` : undefined }}
|
||||
>
|
||||
{/* Under a peek's contents, so they dim over the sidebar's own background, as when docking, rather than
|
||||
showing the content beneath through them. */}
|
||||
<div
|
||||
ref={peekSurface}
|
||||
data-slot="vertical-tabs-peek-surface"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-y-0 end-0 bg-v2-background-bg-deep shadow-[var(--v2-elevation-floating)]"
|
||||
style={{ width: `${state.tabsWidth}px` }}
|
||||
/>
|
||||
<div
|
||||
ref={(element) => setState("tabsMount", element)}
|
||||
data-slot="vertical-tabs-mount"
|
||||
|
||||
@@ -1,41 +1,22 @@
|
||||
/* The closed sidebar floats as a card below the session header, as tall as its rows up to the window's bottom gap,
|
||||
without resizing its zero-width aside. */
|
||||
/* The closed sidebar peeks in the docked sidebar's own box, over the content and without resizing its zero-width aside,
|
||||
so docking from a peek only has to move the layout. */
|
||||
[data-slot="vertical-tabs-sidebar"][data-peek] > [data-slot="vertical-tabs-panel"] {
|
||||
/* Past the shell's 8px frame inset, so the card's edges sit inside the frame's. */
|
||||
--sidebar-peek-gap: 12px;
|
||||
--sidebar-peek-start: 16px;
|
||||
/* Below the session header, which ends 48px past the shell's top inset. */
|
||||
--sidebar-peek-top: calc(var(--shell-top-inset, 8px) + 48px + var(--sidebar-peek-gap));
|
||||
inset: var(--sidebar-peek-top) auto auto;
|
||||
inset-inline: var(--sidebar-peek-start) auto;
|
||||
inset-inline-end: auto;
|
||||
width: var(--sidebar-peek-width);
|
||||
max-height: calc(100% - var(--sidebar-peek-top) - var(--sidebar-peek-gap));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
contain: none;
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="vertical-tabs-mount"] {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
width: 100% !important;
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
padding-block: 8px !important;
|
||||
padding-inline: 8px 4px !important;
|
||||
contain: layout style;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--v2-elevation-floating);
|
||||
}
|
||||
|
||||
/* The header only spaces the traffic lights and holds the docked toggle; Home, where it leads, stays as a row. */
|
||||
[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="sidebar-header"]:not([data-home]),
|
||||
[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="sidebar-header"] > :last-child {
|
||||
/* Backs the peek and lifts it off the content along its end edge. */
|
||||
[data-slot="vertical-tabs-peek-surface"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="sidebar-header"] {
|
||||
padding-top: 0;
|
||||
[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="vertical-tabs-peek-surface"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The collapsed toggle is the one in reach while the sidebar peeks; the docked one's spot stays empty. */
|
||||
[data-slot="vertical-tabs-sidebar"][data-peek] [data-slot="sidebar-header"] > :last-child {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { createEffect, createSignal, on, onCleanup, untrack, type Accessor } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { prefersReducedMotion, Spring } from "@opencode/ui/motion"
|
||||
import { frameLoop, prefersReducedMotion, Spring } from "@opencode/ui/motion"
|
||||
import { motionTuning } from "@opencode/ui/motion-tuning"
|
||||
import { panelSpring } from "@/shell/panel-motion"
|
||||
import "./sidebar-peek.css"
|
||||
|
||||
export const sidebarPeekEnabled = motionTuning.toggle("sidebar.peek", {
|
||||
@@ -17,42 +18,103 @@ const toggles = 'button[aria-controls="vertical-tabs-sidebar"]'
|
||||
|
||||
const menus = '[data-component="menu-v2-content"], [role="menu"]'
|
||||
|
||||
// How long the pointer rests on the toggle before the sidebar peeks, so passing over it doesn't flash it.
|
||||
const intent = 500
|
||||
// A drag in progress, or a dialog over the app, which no peek should open under.
|
||||
const busy = '[data-dragging="true"], [role="dialog"], [role="alertdialog"]'
|
||||
|
||||
// How long the pointer rests before the sidebar peeks, in ms. Both sit under the ~100–150ms where a response still reads
|
||||
// as immediate, and above how long a pointer spends crossing: through the 8px edge strip in well under a frame, over the
|
||||
// 28px toggle in ~30–60ms on the way to the breadcrumb or the traffic lights. The toggle waits longer because the header
|
||||
// row is crossed far more often than the bare window edge, and a peek that starts just before a click docks smoothly.
|
||||
const dwell = { edge: 100, toggle: 150 }
|
||||
|
||||
// The strip along the window's start edge that peeks the sidebar: the shell's 8px frame gutter, outside the content's
|
||||
// card, so no control, scrollbar or text lives in it. It starts below the session header's 56px band, which holds the
|
||||
// traffic lights and the toggle.
|
||||
const edge = 8
|
||||
|
||||
const header = 56
|
||||
|
||||
// How far past the sidebar and toggle the pointer may stray, and for how long, before the peek closes.
|
||||
const margin = 15
|
||||
|
||||
const grace = 100
|
||||
|
||||
// Reduced motion fades the peek in place, in about the time it would take to slide.
|
||||
const fade = { duration: 0.15, bounce: 0 }
|
||||
|
||||
type Point = { x: number; y: number }
|
||||
|
||||
type Box = { left: number; right: number; top: number; bottom: number }
|
||||
|
||||
/**
|
||||
* Experiment: while the left sidebar is closed, hovering its toggle floats the sidebar over the app without moving the
|
||||
* layout. The peek holds while the pointer stays within the box spanning the toggle and the sidebar, plus a margin, so
|
||||
* the way from the toggle into the sidebar never closes it. Closing reads the pointer's last known position on every
|
||||
* move, scroll and wheel, and on a slow interval, rather than trusting enter and leave events on elements.
|
||||
* Experiment: while the left sidebar is closed, resting the pointer on the window's start edge or on the sidebar's
|
||||
* toggle slides the real sidebar in over the app without moving the layout, on the docked sidebar's own spring and in
|
||||
* its own box. Docking from a peek continues from where it is: the sidebar stays and the layout makes room.
|
||||
*
|
||||
* The peek holds while the pointer stays within the box spanning the toggle and the sidebar, plus a margin, so the way
|
||||
* from the toggle into the sidebar never closes it. Closing reads the pointer's last known position on every move,
|
||||
* scroll and wheel, and on a slow interval, rather than trusting enter and leave events on elements, against boxes read
|
||||
* once per layout change rather than on every move. Its listeners only run while the experiment is on.
|
||||
*/
|
||||
export function createSidebarPeek(input: {
|
||||
/** Whether a peek may start: the sidebar is fully closed. */
|
||||
available: Accessor<boolean>
|
||||
open: Accessor<boolean>
|
||||
aside: Accessor<HTMLElement | undefined>
|
||||
/** The floating box, which doesn't move while the content inside it slides. */
|
||||
/** The sidebar's box while it peeks, which doesn't move while the content inside it slides. */
|
||||
panel: Accessor<HTMLElement | undefined>
|
||||
content: Accessor<HTMLElement | undefined>
|
||||
/** Paints how far in the peek has the sidebar, from 0 to 1, on every frame it moves; undefined once it isn't peeking. */
|
||||
paint: (shown: number | undefined) => void
|
||||
/** Whether docking continues from the peek; otherwise the docked sidebar's own motion takes over at once. */
|
||||
continues: Accessor<boolean>
|
||||
/** Runs `run` once the docked sidebar's flight lands. */
|
||||
whenDocked: (run: () => void) => void
|
||||
}) {
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const [enabled, setEnabled] = createSignal(sidebarPeekEnabled())
|
||||
// Percent shown, so the spring's rest threshold stays well under a pixel.
|
||||
const motion = new Spring(0)
|
||||
let stop: (() => void) | undefined
|
||||
let toggle: HTMLElement | undefined
|
||||
let animation: Animation | undefined
|
||||
let opening: ReturnType<typeof setTimeout> | undefined
|
||||
let opening: { from: "edge" | "toggle"; timer: ReturnType<typeof setTimeout> } | undefined
|
||||
let closing: ReturnType<typeof setTimeout> | undefined
|
||||
let watch: ReturnType<typeof setInterval> | undefined
|
||||
// The pointer's last position in the window, or undefined once it has left the window.
|
||||
let pointer: { x: number; y: number } | undefined
|
||||
let pointer: Point | undefined
|
||||
let pressed = false
|
||||
let keyboard = false
|
||||
// Whether the sidebar last docked from a peek, which already shows it.
|
||||
let handedOff = false
|
||||
let viewport = window.innerWidth
|
||||
// Where the edge strip starts, and the box spanning the toggle and the sidebar, until the layout next changes.
|
||||
let edgeTop: number | undefined
|
||||
let corridor: Box | undefined
|
||||
|
||||
const paint = () => input.paint(Math.min(1, Math.max(0, motion.value / 100)))
|
||||
|
||||
const step = (dt: number) => {
|
||||
motion.step(dt)
|
||||
paint()
|
||||
|
||||
if (!motion.resting) return true
|
||||
stop = undefined
|
||||
|
||||
if (motion.target === 0) end()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Retargeting keeps the current position and velocity, so a peek that turns around mid-flight stays smooth.
|
||||
const run = (target: number) => {
|
||||
const tuning = prefersReducedMotion() ? fade : panelSpring()
|
||||
motion.omega = (2 * Math.PI) / tuning.duration
|
||||
motion.zeta = 1 - tuning.bounce
|
||||
motion.set(target)
|
||||
stop ??= frameLoop.add(step)
|
||||
}
|
||||
|
||||
const cancelOpen = () => {
|
||||
clearTimeout(opening)
|
||||
clearTimeout(opening?.timer)
|
||||
opening = undefined
|
||||
}
|
||||
|
||||
@@ -64,27 +126,17 @@ export function createSidebarPeek(input: {
|
||||
const show = () => {
|
||||
cancelOpen()
|
||||
cancelClose()
|
||||
const content = input.content()
|
||||
|
||||
if (!content) return
|
||||
setShown(true)
|
||||
watch ??= setInterval(check, 150)
|
||||
if (!input.panel()) return
|
||||
|
||||
if (animation) {
|
||||
if (animation.playbackRate < 0) animation.reverse()
|
||||
animation.onfinish = null
|
||||
return
|
||||
// Painted out of view before the peek's box lays out, so its first frame doesn't show the sidebar in place.
|
||||
if (!shown()) {
|
||||
paint()
|
||||
setShown(true)
|
||||
}
|
||||
|
||||
const direction = document.documentElement.dir === "rtl" ? 1 : -1
|
||||
// Filled forward, so it also lifts the closed sidebar's dimmed opacity while the peek lasts.
|
||||
animation = content.animate(
|
||||
[
|
||||
{ opacity: 0, transform: `translateX(calc(${direction} * (100% + 16px)))` },
|
||||
{ opacity: 1, transform: "none" },
|
||||
],
|
||||
{ ...(prefersReducedMotion() ? { duration: 0 } : timing()), fill: "both" },
|
||||
)
|
||||
watch ??= setInterval(check, 150)
|
||||
run(100)
|
||||
}
|
||||
|
||||
const end = () => {
|
||||
@@ -92,33 +144,49 @@ export function createSidebarPeek(input: {
|
||||
cancelClose()
|
||||
clearInterval(watch)
|
||||
watch = undefined
|
||||
animation?.cancel()
|
||||
animation = undefined
|
||||
stop?.()
|
||||
stop = undefined
|
||||
motion.snap(0)
|
||||
toggle = undefined
|
||||
corridor = undefined
|
||||
|
||||
if (!shown()) return
|
||||
setShown(false)
|
||||
input.paint(undefined)
|
||||
}
|
||||
|
||||
const hide = (focus?: boolean) => {
|
||||
cancelClose()
|
||||
|
||||
if (!shown()) return
|
||||
if (!shown() || input.open()) return
|
||||
clearInterval(watch)
|
||||
watch = undefined
|
||||
const aside = input.aside()
|
||||
|
||||
if (aside?.contains(document.activeElement)) {
|
||||
if (focus && toggle?.isConnected) toggle.focus({ preventScroll: true })
|
||||
|
||||
if (!focus && document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
}
|
||||
|
||||
if (!animation || prefersReducedMotion()) return end()
|
||||
const current = animation
|
||||
current.reverse()
|
||||
current.onfinish = () => {
|
||||
if (current.playbackRate < 0) end()
|
||||
run(0)
|
||||
}
|
||||
|
||||
// Both dwells share one path: a press, a drag, a dialog or a resize clears whichever peek is pending.
|
||||
const start = (from: "edge" | "toggle", still: () => boolean) => {
|
||||
cancelOpen()
|
||||
opening = {
|
||||
from,
|
||||
timer: setTimeout(() => {
|
||||
opening = undefined
|
||||
|
||||
if (enabled() && input.available() && !interrupted() && still()) show()
|
||||
}, dwell[from]),
|
||||
}
|
||||
}
|
||||
|
||||
const interrupted = () => pressed || !!document.querySelector(busy)
|
||||
|
||||
// A visible menu, a drag, or typing in one of the sidebar's fields keeps it open wherever the pointer goes.
|
||||
const held = () => {
|
||||
const active = document.activeElement
|
||||
@@ -132,20 +200,48 @@ export function createSidebarPeek(input: {
|
||||
return pressed || typing || menu || !!document.querySelector('[data-dragging="true"]')
|
||||
}
|
||||
|
||||
const inside = (point: { x: number; y: number }) => {
|
||||
const inside = (point: Point) => {
|
||||
const box = (corridor ??= measureCorridor())
|
||||
|
||||
return (
|
||||
!!box &&
|
||||
point.x >= box.left - margin &&
|
||||
point.x <= box.right + margin &&
|
||||
point.y >= box.top - margin &&
|
||||
point.y <= box.bottom + margin
|
||||
)
|
||||
}
|
||||
|
||||
// The box spanning the toggle and the sheet is the corridor from one into the other. The sheet holds still while the
|
||||
// sidebar slides inside it.
|
||||
const measureCorridor = () => {
|
||||
const panel = input.panel()
|
||||
|
||||
if (!panel) return false
|
||||
if (!panel) return
|
||||
const sheet = panel.getBoundingClientRect()
|
||||
const button = toggle?.isConnected ? toggle.getBoundingClientRect() : sheet
|
||||
|
||||
// The box spanning both is the corridor from the toggle down to the sheet's top.
|
||||
return (
|
||||
point.x >= Math.min(sheet.left, button.left) - margin &&
|
||||
point.x <= Math.max(sheet.right, button.right) + margin &&
|
||||
point.y >= Math.min(sheet.top, button.top) - margin &&
|
||||
point.y <= Math.max(sheet.bottom, button.bottom) + margin
|
||||
)
|
||||
return {
|
||||
left: Math.min(sheet.left, button.left),
|
||||
right: Math.max(sheet.right, button.right),
|
||||
top: Math.min(sheet.top, button.top),
|
||||
bottom: Math.max(sheet.bottom, button.bottom),
|
||||
}
|
||||
}
|
||||
|
||||
const atEdge = (point: Point) => {
|
||||
const rtl = document.documentElement.dir === "rtl"
|
||||
|
||||
if (rtl ? point.x < viewport - edge : point.x > edge) return false
|
||||
edgeTop ??= (input.aside()?.getBoundingClientRect().top ?? Infinity) + header
|
||||
|
||||
return point.y >= edgeTop
|
||||
}
|
||||
|
||||
const relayout = () => {
|
||||
viewport = window.innerWidth
|
||||
edgeTop = undefined
|
||||
corridor = undefined
|
||||
}
|
||||
|
||||
function check() {
|
||||
@@ -159,86 +255,127 @@ export function createSidebarPeek(input: {
|
||||
}, grace)
|
||||
}
|
||||
|
||||
// Starts the edge's dwell while the pointer rests at the window's start edge, and cancels it once the pointer leaves.
|
||||
const approach = (event: PointerEvent | WheelEvent) => {
|
||||
if (shown() && motion.target === 100) return
|
||||
const target = event.target
|
||||
|
||||
const near =
|
||||
!pressed &&
|
||||
!!pointer &&
|
||||
input.available() &&
|
||||
atEdge(pointer) &&
|
||||
// Within the shell's own layout, not a dialog or popover portaled over it.
|
||||
target instanceof Node &&
|
||||
!!input.aside()?.parentElement?.contains(target)
|
||||
|
||||
if (!near) {
|
||||
if (opening?.from === "edge") cancelOpen()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Back at the edge while it slides out: it turns around at once.
|
||||
if (shown()) return show()
|
||||
|
||||
if (opening) return
|
||||
start("edge", () => !!pointer && !held() && atEdge(pointer))
|
||||
}
|
||||
|
||||
const track = (event: PointerEvent | WheelEvent) => {
|
||||
if (event instanceof PointerEvent && event.pointerType === "touch") return
|
||||
|
||||
if (event instanceof PointerEvent) pressed = event.buttons !== 0
|
||||
pointer = { x: event.clientX, y: event.clientY }
|
||||
approach(event)
|
||||
check()
|
||||
}
|
||||
|
||||
makeEventListener(
|
||||
document,
|
||||
"pointerover",
|
||||
(event) => {
|
||||
if (event.pointerType === "touch" || !(event.target instanceof Element)) return
|
||||
const button = event.target.closest<HTMLElement>(toggles)
|
||||
|
||||
if (!button || input.aside()?.contains(button)) return
|
||||
toggle = button
|
||||
|
||||
if (shown()) return show()
|
||||
|
||||
if (!sidebarPeekEnabled() || !input.available() || opening) return
|
||||
opening = setTimeout(() => {
|
||||
opening = undefined
|
||||
|
||||
if (sidebarPeekEnabled() && input.available() && toggle?.matches(":hover")) show()
|
||||
}, intent)
|
||||
},
|
||||
{ passive: true },
|
||||
)
|
||||
makeEventListener(
|
||||
document,
|
||||
"pointerout",
|
||||
(event) => {
|
||||
// Leaving the window, or into an iframe or native view, reports no related target.
|
||||
if (!event.relatedTarget) {
|
||||
pointer = undefined
|
||||
check()
|
||||
}
|
||||
|
||||
if (!opening || !(event.target instanceof Element)) return
|
||||
const button = event.target.closest(toggles)
|
||||
|
||||
if (button && !(event.relatedTarget instanceof Node && button.contains(event.relatedTarget))) cancelOpen()
|
||||
},
|
||||
{ passive: true },
|
||||
)
|
||||
makeEventListener(document, "pointermove", track, { passive: true, capture: true })
|
||||
makeEventListener(document, "wheel", track, { passive: true, capture: true })
|
||||
makeEventListener(document, "scroll", () => check(), { passive: true, capture: true })
|
||||
makeEventListener(
|
||||
document,
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
keyboard = false
|
||||
pressed = event.pointerType !== "touch"
|
||||
},
|
||||
{ passive: true, capture: true },
|
||||
)
|
||||
makeEventListener(window, "pointerup", () => (pressed = false), { passive: true, capture: true })
|
||||
makeEventListener(window, "pointercancel", () => (pressed = false), { passive: true, capture: true })
|
||||
makeEventListener(document.documentElement, "pointerleave", () => {
|
||||
pointer = undefined
|
||||
check()
|
||||
})
|
||||
makeEventListener(document, "keydown", (event) => {
|
||||
keyboard = true
|
||||
|
||||
if (event.key !== "Escape" || !shown() || input.open() || event.defaultPrevented || held()) return
|
||||
hide(true)
|
||||
})
|
||||
makeEventListener(window, "blur", () => {
|
||||
pressed = false
|
||||
hide()
|
||||
})
|
||||
|
||||
// Keyboard focus leaving the peek closes it; pointer clicks elsewhere already close it by leaving.
|
||||
// Only while the experiment is on, so turning it off leaves nothing listening.
|
||||
createEffect(() => {
|
||||
if (!enabled()) return untrack(end)
|
||||
|
||||
makeEventListener(
|
||||
document,
|
||||
"pointerover",
|
||||
(event) => {
|
||||
if (event.pointerType === "touch" || !(event.target instanceof Element)) return
|
||||
const button = event.target.closest<HTMLElement>(toggles)
|
||||
|
||||
if (!button || input.aside()?.contains(button)) return
|
||||
|
||||
if (toggle !== button) corridor = undefined
|
||||
toggle = button
|
||||
|
||||
if (shown()) return show()
|
||||
|
||||
if (!input.available() || opening?.from === "toggle") return
|
||||
start("toggle", () => !!toggle?.matches(":hover"))
|
||||
},
|
||||
{ passive: true },
|
||||
)
|
||||
makeEventListener(
|
||||
document,
|
||||
"pointerout",
|
||||
(event) => {
|
||||
// Leaving the window, or into an iframe or native view, reports no related target.
|
||||
if (!event.relatedTarget) {
|
||||
pointer = undefined
|
||||
check()
|
||||
}
|
||||
|
||||
if (opening?.from !== "toggle" || !(event.target instanceof Element)) return
|
||||
const button = event.target.closest(toggles)
|
||||
|
||||
if (button && !(event.relatedTarget instanceof Node && button.contains(event.relatedTarget))) cancelOpen()
|
||||
},
|
||||
{ passive: true },
|
||||
)
|
||||
makeEventListener(document, "pointermove", track, { passive: true, capture: true })
|
||||
makeEventListener(document, "wheel", track, { passive: true, capture: true })
|
||||
makeEventListener(document, "scroll", () => check(), { passive: true, capture: true })
|
||||
makeEventListener(
|
||||
document,
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
keyboard = false
|
||||
pressed = event.pointerType !== "touch"
|
||||
cancelOpen()
|
||||
},
|
||||
{ passive: true, capture: true },
|
||||
)
|
||||
makeEventListener(window, "pointerup", () => (pressed = false), { passive: true, capture: true })
|
||||
makeEventListener(window, "pointercancel", () => (pressed = false), { passive: true, capture: true })
|
||||
makeEventListener(document.documentElement, "pointerleave", () => {
|
||||
pointer = undefined
|
||||
check()
|
||||
})
|
||||
// Resizing the window from its start edge is no reason to peek.
|
||||
makeEventListener(window, "resize", () => {
|
||||
relayout()
|
||||
cancelOpen()
|
||||
})
|
||||
makeEventListener(document, "dragstart", cancelOpen, { passive: true, capture: true })
|
||||
makeEventListener(document, "keydown", (event) => {
|
||||
keyboard = true
|
||||
|
||||
if (event.key !== "Escape" || !shown() || input.open() || event.defaultPrevented || held()) return
|
||||
hide(true)
|
||||
})
|
||||
makeEventListener(window, "blur", () => {
|
||||
pressed = false
|
||||
hide()
|
||||
})
|
||||
|
||||
const aside = input.aside()
|
||||
|
||||
if (!aside) return
|
||||
// A titlebar or panel that moves the sidebar's row changes where the edge strip starts.
|
||||
const observer = new ResizeObserver(relayout)
|
||||
observer.observe(aside.parentElement ?? aside)
|
||||
onCleanup(() => observer.disconnect())
|
||||
|
||||
// Keyboard focus leaving the peek closes it; pointer clicks elsewhere already close it by leaving.
|
||||
const leave = (event: FocusEvent) => {
|
||||
const next = event.relatedTarget
|
||||
|
||||
@@ -247,34 +384,57 @@ export function createSidebarPeek(input: {
|
||||
if (aside.contains(next) || toggle?.contains(next) || next.closest(menus)) return
|
||||
hide()
|
||||
}
|
||||
|
||||
aside.addEventListener("focusout", leave)
|
||||
onCleanup(() => aside.removeEventListener("focusout", leave))
|
||||
})
|
||||
|
||||
// Docking hands over to the docked sidebar's own motion.
|
||||
createEffect(on(input.open, (open) => open && end(), { defer: true }))
|
||||
// Docking keeps the peeked sidebar where it is, sliding the rest of the way in if it hadn't arrived, while the layout
|
||||
// makes room; the docked sidebar takes over once it lands. Closing again before then slides the peek back out.
|
||||
createEffect(
|
||||
on(
|
||||
input.open,
|
||||
(open) => {
|
||||
if (!open) {
|
||||
handedOff = false
|
||||
|
||||
createEffect(() => {
|
||||
if (!sidebarPeekEnabled()) end()
|
||||
})
|
||||
return hide()
|
||||
}
|
||||
|
||||
handedOff = shown()
|
||||
|
||||
if (!handedOff) return end()
|
||||
cancelOpen()
|
||||
cancelClose()
|
||||
clearInterval(watch)
|
||||
watch = undefined
|
||||
run(100)
|
||||
// After the docked sidebar's own effect has started its flight, which effects don't order.
|
||||
queueMicrotask(() => {
|
||||
if (!input.open() || !shown()) return
|
||||
|
||||
if (!input.continues()) {
|
||||
handedOff = false
|
||||
|
||||
return end()
|
||||
}
|
||||
|
||||
input.whenDocked(() => {
|
||||
if (input.open()) end()
|
||||
})
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(motionTuning.subscribe(() => setEnabled(sidebarPeekEnabled())))
|
||||
|
||||
onCleanup(end)
|
||||
|
||||
return { shown }
|
||||
}
|
||||
|
||||
// The peek's spring, 0.15s without bounce, sampled per 60 Hz frame into a CSS `linear()` easing.
|
||||
function timing() {
|
||||
const motion = Spring.fromDuration(0, { duration: 0.15, bounce: 0 }).set(1)
|
||||
const points = [0]
|
||||
|
||||
while (points.length < 240) {
|
||||
points.push(Math.round(motion.step(1 / 60) * 1000) / 1000)
|
||||
|
||||
if (Math.abs(1 - motion.value) < 0.005 && Math.abs(motion.velocity) < 0.05) break
|
||||
return {
|
||||
shown,
|
||||
/** Whether the sidebar last docked from a peek, which already shows it. */
|
||||
handedOff: () => handedOff,
|
||||
}
|
||||
|
||||
points.push(1)
|
||||
|
||||
return { duration: ((points.length - 1) * 1000) / 60, easing: `linear(${points.join(", ")})` }
|
||||
}
|
||||
|
||||
@@ -11,14 +11,12 @@ import { useSettings } from "@/settings/model"
|
||||
import { motionTuning } from "@opencode/ui/motion-tuning"
|
||||
import { sidebarPeekEnabled, sidebarUnread } from "@/shell/sidebar-peek"
|
||||
|
||||
// The Sidebar peek experiment shows the closed sidebar's toggle as a menu.
|
||||
// Whether the closed sidebar peeks, which the toggle's unread dot and tooltip follow.
|
||||
const [peek, setPeek] = createSignal(sidebarPeekEnabled())
|
||||
|
||||
motionTuning.subscribe(() => setPeek(sidebarPeekEnabled()))
|
||||
|
||||
const closedIcon = () => (peek() ? "menu" : "layout-left")
|
||||
|
||||
// The menu icon's dot for sidebar sessions with unread activity, shown while the sidebar is closed.
|
||||
// The toggle's dot for sidebar sessions with unread activity, shown while the sidebar is closed.
|
||||
function UnreadBadge(props: { show: boolean; ring: "base" | "deep" }) {
|
||||
return (
|
||||
<Show when={props.show}>
|
||||
@@ -58,6 +56,9 @@ const slotWidth = 32
|
||||
// The shell's 8px inline inset plus the session header's 10px start padding.
|
||||
const contentStart = 18
|
||||
|
||||
/** Where the collapsed toggle's slot ends without traffic lights, in pixels from the window's start edge. */
|
||||
export const collapsedSidebarToggleEnd = contentStart + slotWidth
|
||||
|
||||
/**
|
||||
* `handoff` makes this one of the two vertical-tabs toggles that read as one icon: "out" sits in the sidebar's header
|
||||
* and shows only while the sidebar rests open; "in" shows otherwise, travelling from the header's spot to the
|
||||
@@ -124,7 +125,7 @@ export function SidebarToggle(props: { handoff?: "out" | "in" }) {
|
||||
<span class="relative flex">
|
||||
<Icon ref={full} name="layout-left-full" />
|
||||
<span ref={outline} class="absolute inset-0 flex">
|
||||
<Icon name={closedIcon()} />
|
||||
<Icon name="layout-left" />
|
||||
<UnreadBadge show={unread()} ring="base" />
|
||||
</span>
|
||||
</span>
|
||||
@@ -132,7 +133,7 @@ export function SidebarToggle(props: { handoff?: "out" | "in" }) {
|
||||
<span class="relative flex">
|
||||
<Icon
|
||||
name={
|
||||
side === "out" || (!side && settings.appearance.sidebarOpen()) ? "layout-left-full" : closedIcon()
|
||||
side === "out" || (!side && settings.appearance.sidebarOpen()) ? "layout-left-full" : "layout-left"
|
||||
}
|
||||
/>
|
||||
<UnreadBadge show={!side && unread()} ring="deep" />
|
||||
@@ -189,14 +190,14 @@ const travel: { header?: HTMLElement; toggle?: HTMLElement; x: number; y: number
|
||||
|
||||
/**
|
||||
* Measures, in the frame loop's read phase of the sidebar's motion, how far the header toggle rests from the collapsed
|
||||
* one. The header toggle rides the sidebar's edge, `width × closed` from where it rests open.
|
||||
* one. The header toggle rides the sidebar's contents, `offset` pixels short of where it rests open.
|
||||
*/
|
||||
export function measureSidebarToggles(width: number) {
|
||||
export function measureSidebarToggles(offset: number) {
|
||||
if (!travel.header?.isConnected || !travel.toggle?.isConnected) return
|
||||
const header = travel.header.getBoundingClientRect()
|
||||
const toggle = travel.toggle.getBoundingClientRect()
|
||||
const direction = document.documentElement.dir === "rtl" ? -1 : 1
|
||||
travel.x = header.left + direction * width * collapsed.closed - (toggle.left - travel.dx)
|
||||
travel.x = header.left + direction * offset - (toggle.left - travel.dx)
|
||||
travel.y = header.top - (toggle.top - travel.dy)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, expect, it } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { motionTuning } from "@opencode/ui/motion-tuning"
|
||||
import { createSidebarPeek, sidebarPeekEnabled } from "../src/shell/sidebar-peek"
|
||||
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
document.body.innerHTML = ""
|
||||
setEnabled(true)
|
||||
})
|
||||
|
||||
function setEnabled(on: boolean) {
|
||||
const knob = motionTuning.list().find((item) => item.id === "sidebar.peek")
|
||||
|
||||
if (knob?.kind === "toggle") motionTuning.set(knob, on)
|
||||
}
|
||||
|
||||
function mount() {
|
||||
const row = document.createElement("div")
|
||||
const aside = document.createElement("aside")
|
||||
const panel = document.createElement("div")
|
||||
const content = document.createElement("main")
|
||||
aside.append(panel)
|
||||
row.append(aside, content)
|
||||
document.body.append(row)
|
||||
|
||||
const peek = createRoot((dispose) => {
|
||||
cleanups.push(dispose)
|
||||
|
||||
return createSidebarPeek({
|
||||
available: () => true,
|
||||
open: () => false,
|
||||
aside: () => aside,
|
||||
panel: () => panel,
|
||||
paint: () => {},
|
||||
continues: () => true,
|
||||
whenDocked: (run) => run(),
|
||||
})
|
||||
})
|
||||
|
||||
return { peek, content }
|
||||
}
|
||||
|
||||
// Rests the pointer on the window's start edge, below the header band.
|
||||
function restOnEdge(target: Element) {
|
||||
target.dispatchEvent(
|
||||
new PointerEvent("pointermove", { bubbles: true, clientX: 2, clientY: 300, pointerType: "mouse" }),
|
||||
)
|
||||
}
|
||||
|
||||
const afterDwell = () => Bun.sleep(160)
|
||||
|
||||
it("peeks once the pointer rests on the window's start edge", async () => {
|
||||
const view = mount()
|
||||
restOnEdge(view.content)
|
||||
await afterDwell()
|
||||
expect(view.peek.shown()).toBe(true)
|
||||
})
|
||||
|
||||
it("a press, a drag, a dialog or a resize clears the pending peek", async () => {
|
||||
const interruptions = [
|
||||
() => document.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" })),
|
||||
() => document.dispatchEvent(new Event("dragstart", { bubbles: true })),
|
||||
() => window.dispatchEvent(new Event("resize")),
|
||||
() => {
|
||||
const dialog = document.createElement("div")
|
||||
dialog.setAttribute("role", "dialog")
|
||||
document.body.append(dialog)
|
||||
},
|
||||
]
|
||||
|
||||
for (const interrupt of interruptions) {
|
||||
const view = mount()
|
||||
restOnEdge(view.content)
|
||||
interrupt()
|
||||
await afterDwell()
|
||||
expect(view.peek.shown()).toBe(false)
|
||||
|
||||
for (const cleanup of cleanups.splice(0)) cleanup()
|
||||
document.body.innerHTML = ""
|
||||
}
|
||||
})
|
||||
|
||||
it("listens to nothing while the experiment is off", async () => {
|
||||
setEnabled(false)
|
||||
expect(sidebarPeekEnabled()).toBe(false)
|
||||
const view = mount()
|
||||
restOnEdge(view.content)
|
||||
await afterDwell()
|
||||
expect(view.peek.shown()).toBe(false)
|
||||
})
|
||||
Reference in New Issue
Block a user