Merge remote-tracking branch 'origin/master' into mergebot/pr1389
# Conflicts: # apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md # packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx # packages/client/ui-conversation/src/client/chat/message-chrome.ts
This commit is contained in:
@@ -16,14 +16,8 @@
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
/* Scrollbar hides until the pointer is over the sidebar: rebind the
|
||||
ui-theme scrollbar indirection (styles/scrollbar.css) to transparent;
|
||||
the session list's scrollbar-gutter keeps layout stable either way. */
|
||||
--dsh-scrollbar-thumb: transparent;
|
||||
--dsh-scrollbar-thumb-hover: transparent;
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
/* The sidebar is elevated above the conversation surface, so a revealed
|
||||
scrollbar uses the l2 pair. .quietBars hides it without changing layout. */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
@@ -35,6 +29,19 @@
|
||||
padding: 18px 10px 6px;
|
||||
}
|
||||
|
||||
/* Scrollbars in the column are a pointer affordance: the shell adds this
|
||||
class whenever the pointer is not inside (SidebarRoot.tsx owns the linger),
|
||||
and rebinding ui-theme's indirection pair to `transparent` takes the thumb
|
||||
out of every scroll region nested under it — the workspace browser's
|
||||
session list today. `transparent` rather than `display: none` on the bar:
|
||||
the reservation (`scrollbar-gutter: stable` on the list) stays in force, so
|
||||
revealing the thumb never reflows a row. Rebinding contract and the two
|
||||
rendering paths it reaches: ui-theme's README. */
|
||||
.root.quietBars {
|
||||
--dsh-scrollbar-thumb: transparent;
|
||||
--dsh-scrollbar-thumb-hover: transparent;
|
||||
}
|
||||
|
||||
/* Collapse phase 1: the whole frozen-width content fades out in place over
|
||||
150ms; at settle the children unmount/snap to the rail layout. */
|
||||
.fading > * {
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
* button and the foot is the `sidebar.workspaces` registrant's, and the foot
|
||||
* is the `sidebar.settings` registrant's; the shell hands them the wide flag
|
||||
* (plus an expand request callback for the browser).
|
||||
*
|
||||
* The column also owns whether the scroll regions nested in it draw a
|
||||
* scrollbar at all: the shell tracks the pointer and rebinds ui-theme's
|
||||
* scrollbar indirection away while it is elsewhere, so a list the user is not
|
||||
* pointing at carries no bar.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -22,6 +27,14 @@ import css from './SidebarRoot.module.css'
|
||||
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
|
||||
const COLLAPSE_SETTLE_MS = 150
|
||||
|
||||
/**
|
||||
* How long the column's scrollbars stay drawn after the pointer leaves it.
|
||||
* The bar is a pointer affordance here, and hiding it on the leave event
|
||||
* itself makes it blink out while the pointer is only crossing the column's
|
||||
* edge — on the way to the conversation, or around a portalled menu.
|
||||
*/
|
||||
const SCROLLBAR_LINGER_MS = 2000
|
||||
|
||||
/**
|
||||
* Render the sidebar column shell.
|
||||
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
|
||||
@@ -56,10 +69,62 @@ export function SidebarRoot({
|
||||
const everWide = useRef(!collapsed)
|
||||
if (!collapsed) everWide.current = true
|
||||
|
||||
// Scrollbars in the column follow the pointer (.quietBars rebinds them
|
||||
// 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)
|
||||
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
|
||||
className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
|
||||
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={() => {
|
||||
cancelLinger()
|
||||
setPointerInside(true)
|
||||
}}
|
||||
onPointerLeave={() => { armLinger() }}
|
||||
>
|
||||
<div className={css.logoRow}>
|
||||
{/* Expanded, the wordmark doubles as a New Session shortcut; the
|
||||
|
||||
Reference in New Issue
Block a user