Anchor edge-peek tip under the right-sidebar toggle

Point the one-shot hint at the titlebar toggle icon, hang it left so it
stays on-screen, and mention Settings for opt-out.
This commit is contained in:
Brennan Benson
2026-07-16 17:37:27 -07:00
parent fe26a9654c
commit 7f93469b61
3 changed files with 118 additions and 17 deletions
+3
View File
@@ -2161,6 +2161,9 @@ function App(): React.JSX.Element {
className="sidebar-toggle mr-2"
onClick={actions.toggleRightSidebar}
aria-label={translate('auto.App.9e0b441a91', 'Toggle right sidebar')}
// Why: one-shot edge-peek tip anchors to this titlebar control after
// the first close (the control reappears when the sidebar is closed).
data-right-sidebar-toggle=""
>
<PanelRight size={16} />
</button>
@@ -8,9 +8,16 @@ import { RIGHT_SIDEBAR_EDGE_PEEK_TIP_VISIBLE_MS } from './right-sidebar-edge-pee
const initialAppState = useAppStore.getInitialState()
function clearToggleAnchors(): void {
for (const el of document.querySelectorAll('[data-right-sidebar-toggle]')) {
el.remove()
}
}
beforeEach(() => {
vi.useFakeTimers()
cleanup()
clearToggleAnchors()
useAppStore.setState({
...initialAppState,
rightSidebarOpen: true,
@@ -25,12 +32,33 @@ beforeEach(() => {
afterEach(() => {
cleanup()
clearToggleAnchors()
vi.useRealTimers()
useAppStore.setState(initialAppState, true)
})
function mountToggleAnchor(): void {
clearToggleAnchors()
const btn = document.createElement('button')
btn.setAttribute('data-right-sidebar-toggle', '')
btn.getBoundingClientRect = () =>
({
top: 8,
left: 1600,
width: 32,
height: 24,
bottom: 32,
right: 1632,
x: 1600,
y: 8,
toJSON: () => ({})
}) as DOMRect
document.body.appendChild(btn)
}
describe('RightSidebarEdgePeekTipHost', () => {
it('shows a brief edge tip on the first open→closed transition', () => {
it('shows a brief tip under the toggle on the first open→closed transition', () => {
mountToggleAnchor()
const { rerender } = render(<RightSidebarEdgePeekTipHost />)
expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull()
@@ -39,12 +67,13 @@ describe('RightSidebarEdgePeekTipHost', () => {
})
rerender(<RightSidebarEdgePeekTipHost />)
expect(screen.getByTestId('right-sidebar-edge-peek-tip').textContent).toContain(
'Hover the right edge to peek'
)
const tip = screen.getByTestId('right-sidebar-edge-peek-tip')
expect(tip.textContent).toContain('Hover the right edge to peek')
expect(tip.textContent).toContain('Turn this off in Settings')
})
it('auto-hides after the visible duration and marks the tip dismissed', () => {
mountToggleAnchor()
const updateSettings = vi.fn().mockResolvedValue(undefined)
useAppStore.setState({ updateSettings })
const { rerender } = render(<RightSidebarEdgePeekTipHost />)
@@ -64,6 +93,7 @@ describe('RightSidebarEdgePeekTipHost', () => {
})
it('does not show when the tip was already dismissed', () => {
mountToggleAnchor()
useAppStore.setState({
settings: {
rightSidebarEdgePeekEnabled: true,
@@ -79,4 +109,15 @@ describe('RightSidebarEdgePeekTipHost', () => {
expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull()
})
it('does not show without a measurable toggle anchor', () => {
const { rerender } = render(<RightSidebarEdgePeekTipHost />)
act(() => {
useAppStore.setState({ rightSidebarOpen: false })
})
rerender(<RightSidebarEdgePeekTipHost />)
expect(screen.queryByTestId('right-sidebar-edge-peek-tip')).toBeNull()
})
})
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type JSX } from 'react'
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type JSX } from 'react'
import { useAppStore } from '@/store'
import { translate } from '@/i18n/i18n'
import {
@@ -8,8 +8,24 @@ import {
} from './right-sidebar-edge-peek-tip'
import { isRightSidebarEdgePeekEnabled } from './right-sidebar-edge-peek-preference'
const TOGGLE_SELECTOR = '[data-right-sidebar-toggle]'
type AnchorRect = { top: number; left: number; width: number; height: number }
function measureToggleAnchor(): AnchorRect | null {
const el = document.querySelector(TOGGLE_SELECTOR)
if (!(el instanceof HTMLElement)) {
return null
}
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) {
return null
}
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
}
/**
* One-shot tip: a quiet tooltip-style chip on the right edge after the first
* One-shot tip anchored under the right-sidebar titlebar toggle after the first
* close while peek is enabled. Auto-hides after ~1s. Successful peeks also mark
* the tip seen.
*/
@@ -19,6 +35,7 @@ export function RightSidebarEdgePeekTipHost(): JSX.Element | null {
const settings = useAppStore((s) => s.settings)
const updateSettings = useAppStore((s) => s.updateSettings)
const [visible, setVisible] = useState(false)
const [anchor, setAnchor] = useState<AnchorRect | null>(null)
// Why: only fire on a true open→closed transition, not on first mount when
// the sidebar happens to start closed (no prior close gesture to teach from).
const prevOpenRef = useRef(rightSidebarOpen)
@@ -79,28 +96,68 @@ export function RightSidebarEdgePeekTipHost(): JSX.Element | null {
}
}, [settings, visible])
if (!visible) {
// Why: measure after paint so the closed-state titlebar toggle has laid out;
// re-measure on resize while the tip is up so a window drag doesn't leave it
// floating off the icon.
useLayoutEffect(() => {
if (!visible) {
setAnchor(null)
return
}
const update = (): void => {
setAnchor(measureToggleAnchor())
}
update()
// Titlebar toggle mounts in the same close transition; retry once if the
// first paint still has the open-sidebar control (which unmounts).
const retry = window.setTimeout(update, 0)
window.addEventListener('resize', update)
return () => {
window.clearTimeout(retry)
window.removeEventListener('resize', update)
}
}, [visible, rightSidebarOpen])
if (!visible || !anchor) {
return null
}
// Why: hang left from the toggle so a near-edge titlebar icon doesn't clip
// the tip off-screen; caret sits under the icon center.
const caretOffsetPx = 14
const style: CSSProperties = {
position: 'fixed',
top: anchor.top + anchor.height + 8,
left: anchor.left + anchor.width / 2 + caretOffsetPx,
transform: 'translateX(-100%)'
}
return (
<div
role="status"
aria-live="polite"
data-testid="right-sidebar-edge-peek-tip"
// Why: fixed to the right edge mid-window so the hint points at the
// gesture target (not the titlebar toggle the user just used).
className="pointer-events-none fixed top-1/2 right-3 z-[90] -translate-y-1/2 animate-in fade-in-0 zoom-in-95 slide-in-from-right-2 duration-150 motion-reduce:animate-none"
style={style}
className="pointer-events-none z-[90] w-max max-w-[240px] animate-in fade-in-0 zoom-in-95 slide-in-from-top-1 duration-150 motion-reduce:animate-none"
>
<div className="relative rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background shadow-xs">
{translate(
'auto.components.right.sidebar.edge.peek.tip.title',
'Hover the right edge to peek'
)}
{/* Tooltip-style caret pointing at the window edge. */}
<div className="relative rounded-md bg-foreground px-3 py-1.5 text-xs leading-snug text-background shadow-xs">
<div className="whitespace-nowrap">
{translate(
'auto.components.right.sidebar.edge.peek.tip.title',
'Hover the right edge to peek'
)}
</div>
<div className="whitespace-nowrap opacity-80">
{translate(
'auto.components.right.sidebar.edge.peek.tip.settings',
'Turn this off in Settings'
)}
</div>
{/* Tooltip-style caret pointing up at the toggle icon. */}
<span
aria-hidden
className="absolute top-1/2 -right-1 size-2 -translate-y-1/2 rotate-45 rounded-[2px] bg-foreground"
className="absolute -top-1 size-2 rotate-45 rounded-[2px] bg-foreground"
style={{ right: caretOffsetPx - 4 }}
/>
</div>
</div>