fix(web): decide the sidebar's pointer leave by geometry, and tighten the gate

Review findings from the first round:

- ui-settings renders its full-viewport panel as a fixed-position DESCENDANT
  of the sidebar column, so `pointerleave` never fires when the pointer moves
  onto it and the bars stayed drawn after it closed. Leaving is now decided
  against the column's box from a document-level pointermove that exists only
  while the bars are drawn; the element's own leave stays for the pointer that
  leaves the window.
- The rebind gate judges the rule rather than each declaration, so a pair that
  mixes `transparent` with an l2 hover no longer passes, and the elevation
  half compares whole values instead of token shape.
- Hiding no longer exempts a sheet from the elevated-surface rebind check.
- The e2e polls the reveal before reading a colour for the golden, and pins
  that a pointerless scroll draws no thumb.
This commit is contained in:
creatixchu
2026-08-04 15:42:39 +08:00
parent a030397aca
commit 10ce8d8a45
7 changed files with 182 additions and 43 deletions

View File

@@ -73,25 +73,58 @@ export function SidebarRoot({
// away): drawn while it is inside, and for SCROLLBAR_LINGER_MS after it
// leaves. A pointer that returns within that window cancels the pending
// hide rather than restarting from a hidden bar.
const column = useRef<HTMLDivElement>(null)
const [pointerInside, setPointerInside] = useState(false)
const lingerTimer = useRef<number | undefined>(undefined)
useEffect(() => () => { window.clearTimeout(lingerTimer.current) }, [])
const armLinger = (): void => {
if (lingerTimer.current !== undefined) return
lingerTimer.current = window.setTimeout(() => {
lingerTimer.current = undefined
setPointerInside(false)
}, SCROLLBAR_LINGER_MS)
}
const cancelLinger = (): void => {
window.clearTimeout(lingerTimer.current)
lingerTimer.current = undefined
}
// Leaving is decided by the column's BOX, not by DOM containment, and only
// while the bars are drawn. ui-settings renders its full-viewport panel as a
// fixed-position DESCENDANT of this column, so a pointer moved onto that
// panel — or onto the conversation once it closes — fires no `pointerleave`
// here, and the bars would stay drawn over a column nobody is pointing at.
// The element's own leave stays as the one signal geometry cannot give: a
// pointer that leaves the window emits no further moves.
useEffect(() => {
if (!pointerInside) return
const onMove = (event: PointerEvent): void => {
const rect = column.current?.getBoundingClientRect()
/* v8 ignore next -- the listener only exists while the column is mounted and revealed. */
if (rect === undefined) return
const inside = event.clientX >= rect.left && event.clientX < rect.right
&& event.clientY >= rect.top && event.clientY < rect.bottom
if (inside) cancelLinger()
else armLinger()
}
document.addEventListener('pointermove', onMove)
return () => {
document.removeEventListener('pointermove', onMove)
cancelLinger()
}
}, [pointerInside])
return (
<div
ref={column}
className={clsx(
css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn,
collapsed && wide && css.fading, !pointerInside && css.quietBars,
)}
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
onPointerEnter={() => {
window.clearTimeout(lingerTimer.current)
cancelLinger()
setPointerInside(true)
}}
onPointerLeave={() => {
window.clearTimeout(lingerTimer.current)
lingerTimer.current = window.setTimeout(() => { setPointerInside(false) }, SCROLLBAR_LINGER_MS)
}}
onPointerLeave={() => { armLinger() }}
>
<div className={css.logoRow}>
{/* Expanded, the wordmark doubles as a New Session shortcut; the

View File

@@ -11,6 +11,10 @@ import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
import { en } from '../src/client/locales.ts'
/** Pinned column box; the shell compares pointer coordinates against it. */
const COLUMN_WIDTH = 280
const COLUMN_HEIGHT = 600
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
@@ -36,6 +40,14 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } {
)
const column = view.container.firstElementChild
if (!(column instanceof HTMLElement)) throw new Error('sidebar column not rendered')
// jsdom lays nothing out, and the leave decision is geometric: pin the box
// the shell reads so a coordinate can be inside or outside it.
Object.defineProperty(column, 'getBoundingClientRect', {
value: () => ({
left: 0, top: 0, right: COLUMN_WIDTH, bottom: COLUMN_HEIGHT,
x: 0, y: 0, width: COLUMN_WIDTH, height: COLUMN_HEIGHT, toJSON: () => ({}),
}),
})
// 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')) }
@@ -54,6 +66,16 @@ function movePointer(column: HTMLElement, direction: 'in' | 'out'): void {
else fireEvent.pointerOut(column, { relatedTarget: outside })
}
/**
* Move the pointer over the document, as a pointer crossing a fixed overlay
* that is a DOM descendant of the column does.
* @param x - client x coordinate.
* @param y - client y coordinate.
*/
function movePointerOverDocument(x: number, y: number): void {
fireEvent.pointerMove(document, { clientX: x, clientY: y })
}
describe('SidebarRoot pointer-revealed scrollbars', () => {
it('draws them only while the pointer is inside, and lingers on the way out', () => {
vi.useFakeTimers()
@@ -83,6 +105,45 @@ describe('SidebarRoot pointer-revealed scrollbars', () => {
expect(quiet()).toBe(false)
})
it('hides when the pointer moves outside the column box without leaving its subtree', () => {
// ui-settings renders its full-viewport panel as a fixed-position
// DESCENDANT of the column, so DOM containment reports the pointer as
// still inside while it is visually somewhere else entirely.
vi.useFakeTimers()
const { column, quiet } = mountColumn()
movePointer(column, 'in')
expect(quiet()).toBe(false)
movePointerOverDocument(COLUMN_WIDTH + 400, 300)
act(() => { vi.advanceTimersByTime(2000) })
expect(quiet()).toBe(true)
})
it('does not restart the window when the pointer keeps moving outside', () => {
vi.useFakeTimers()
const { column, quiet } = mountColumn()
movePointer(column, 'in')
movePointer(column, 'out')
act(() => { vi.advanceTimersByTime(1500) })
// A pending hide is left alone rather than re-armed: otherwise a pointer
// resting outside the column would keep pushing the bars' disappearance
// out, one move at a time.
movePointerOverDocument(COLUMN_WIDTH + 400, 300)
act(() => { vi.advanceTimersByTime(600) })
expect(quiet()).toBe(true)
})
it('keeps them drawn while the pointer moves inside the column box', () => {
vi.useFakeTimers()
const { column, quiet } = mountColumn()
movePointer(column, 'in')
movePointer(column, 'out')
// A move landing back inside the box cancels the pending hide, the same
// way re-entering the element does.
movePointerOverDocument(COLUMN_WIDTH - 10, 300)
act(() => { vi.advanceTimersByTime(5000) })
expect(quiet()).toBe(false)
})
it('drops the pending hide when the column unmounts', () => {
vi.useFakeTimers()
const { column } = mountColumn()