feat(web): draw the sidebar's scrollbar only under the pointer

The session list overflows after a handful of sessions, and its scrollbar
was drawn permanently in a column that is at rest most of the time.

SidebarRoot now tracks the pointer over the whole column and rebinds
ui-theme's scrollbar indirection pair to `transparent` while it is
outside, keeping the thumb for 2s after the pointer leaves so it does not
blink out on the way past. Rebinding colour leaves the list's
`scrollbar-gutter: stable` reservation in force, so revealing the bar
moves no row.

ui-theme's gate now states the widened contract: a rebind targets an -l2
token pair or `transparent`, and nothing else.
This commit is contained in:
creatixchu
2026-08-04 15:05:32 +08:00
parent 3c436d781e
commit a030397aca
20 changed files with 430 additions and 28 deletions

View File

@@ -5,7 +5,7 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad
data-slot="sidebar"
>
<div
class="root collapsed railIn"
class="root collapsed railIn quietBars"
style=""
>
<div
@@ -65,7 +65,7 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul
data-slot="sidebar"
>
<div
class="root"
class="root quietBars"
style="width: 300px;"
>
<div
@@ -135,7 +135,7 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo
data-slot="sidebar"
>
<div
class="root"
class="root quietBars"
style="width: 300px;"
>
<div

View File

@@ -0,0 +1,96 @@
// @vitest-environment jsdom
/**
* Pointer-revealed scrollbars, the shell's half: which class state the column
* carries as the pointer crosses it. The stylesheet rule that state drives is
* asserted in scrollbar-quiet-styles.spec.ts (node environment — a jsdom spec
* has no file: module URL to read the sheet through).
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
import { en } from '../src/client/locales.ts'
const t: SidebarRootComponentProps['t'] = key => (en as Record<string, string>)[key] ?? key
/** The shell never reads the global hooks; the props share carries them regardless. */
const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
afterEach(() => {
cleanup()
vi.useRealTimers()
})
/**
* Render the shell and expose its column element.
* @returns the column element and whether it currently carries the quiet state.
*/
function mountColumn(): { column: HTMLElement; quiet: () => boolean } {
const view = render(
<SidebarRoot
collapsed={false} width={300}
useSessions={neverHook} useWorkspaces={neverHook}
startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>
<div data-testid="region" data-wide={owner.wide} />) as SidebarRootComponentProps['renderSlot']}
/>,
)
const column = view.container.firstElementChild
if (!(column instanceof HTMLElement)) throw new Error('sidebar column not rendered')
// CSS-module locals are hashed in this bench, so the state is read as a
// substring of the class list rather than as an exact local name.
return { column, quiet: () => [...column.classList].some(name => name.includes('quietBars')) }
}
/**
* Cross the pointer into or out of the column. React synthesizes
* `pointerenter`/`pointerleave` from `pointerover`/`pointerout`, so the raw
* enter and leave events it does not listen to would assert nothing.
* @param column - the sidebar column element.
* @param direction - `in` to enter the column, `out` to leave it.
*/
function movePointer(column: HTMLElement, direction: 'in' | 'out'): void {
const outside = document.body
if (direction === 'in') fireEvent.pointerOver(column, { relatedTarget: outside })
else fireEvent.pointerOut(column, { relatedTarget: outside })
}
describe('SidebarRoot pointer-revealed scrollbars', () => {
it('draws them only while the pointer is inside, and lingers on the way out', () => {
vi.useFakeTimers()
const { column, quiet } = mountColumn()
// At rest — the pointer has never been over the column — the bars are off.
expect(quiet()).toBe(true)
movePointer(column, 'in')
expect(quiet()).toBe(false)
movePointer(column, 'out')
// The linger: still drawn just before the window closes, gone just after.
act(() => { vi.advanceTimersByTime(1999) })
expect(quiet()).toBe(false)
act(() => { vi.advanceTimersByTime(1) })
expect(quiet()).toBe(true)
})
it('cancels a pending hide when the pointer comes back', () => {
vi.useFakeTimers()
const { column, quiet } = mountColumn()
movePointer(column, 'in')
movePointer(column, 'out')
act(() => { vi.advanceTimersByTime(1000) })
movePointer(column, 'in')
// The first leave's timer would fire here; a cancelled one leaves the bars
// drawn, which is what keeps a pointer skirting the edge from blinking them.
act(() => { vi.advanceTimersByTime(5000) })
expect(quiet()).toBe(false)
})
it('drops the pending hide when the column unmounts', () => {
vi.useFakeTimers()
const { column } = mountColumn()
movePointer(column, 'in')
movePointer(column, 'out')
cleanup()
// A timer surviving the unmount would call setState on a dead component.
expect(() => { vi.advanceTimersByTime(5000) }).not.toThrow()
expect(vi.getTimerCount()).toBe(0)
})
})

View File

@@ -0,0 +1,33 @@
/**
* The quiet-column rule as CSS text: the state SidebarRoot toggles
* (pointer-scrollbars.spec.tsx) hides a scrollbar only through this rule, and
* ui-theme's gate checks the rebinding contract's shape without knowing which
* sheet states which half.
*/
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const css = readFileSync(fileURLToPath(new URL('../src/client/SidebarRoot.module.css', import.meta.url)), 'utf8')
/** Declarations only: the sheet's prose names the properties it explains. */
const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
describe('SidebarRoot.module.css quiet column', () => {
it('rebinds the ui-theme indirection pair to transparent', () => {
// The pair, not the resting thumb alone: rebinding one leaves the other
// painting its base-surface colour the moment the pointer reaches the bar.
const rule = /\.root\.quietBars\s*\{([^{}]*)\}/.exec(declarationText)
expect(rule).not.toBeNull()
const declarations = (rule![1] ?? '').split(';').map(part => part.trim()).filter(Boolean).sort()
expect(declarations).toEqual([
'--dsh-scrollbar-thumb-hover: transparent',
'--dsh-scrollbar-thumb: transparent',
].sort())
})
it('leaves the gutter reservation to the scrolling region', () => {
// Hiding the thumb must not move a row: the reservation lives on the list
// (ui-workspace), so the column states colour only.
expect(declarationText).not.toMatch(/scrollbar-gutter/)
})
})