Rebuild contextual tour positioning on floating-ui; fix hosted dialog placement and arrow seam (#5154)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-06-10 21:55:49 -07:00 committed by GitHub
parent a4a1b12d80
commit 10411fd39c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 587 additions and 686 deletions

View File

@ -84,6 +84,7 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@floating-ui/dom": "1.7.6",
"@linear/sdk": "^82.1.0",
"@parcel/watcher": "^2.5.6",
"@xterm/addon-serialize": "0.15.0-beta.285",

View File

@ -22,6 +22,9 @@ importers:
'@electron-toolkit/utils':
specifier: ^4.0.0
version: 4.0.0(electron@42.3.3)
'@floating-ui/dom':
specifier: 1.7.6
version: 1.7.6
'@linear/sdk':
specifier: ^82.1.0
version: 82.1.0(graphql@16.13.2)

View File

@ -3036,51 +3036,20 @@ html.onboarding-tour-start-transition::view-transition-new(root) {
margin-top: 4px;
}
/* Why: a tour panel must read as elevated above the chrome it is teaching about
without resorting to color. A 1px inner top highlight plus the documented
floating shadow keeps it above the surface in both light and dark modes. */
/* Why: tours need a distinct item surface without highlighting the underlying target. */
.orca-contextual-tour-panel {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--foreground) 4%, var(--popover)) 0%,
var(--popover) 12%
);
--contextual-tour-panel-surface: color-mix(in srgb, var(--foreground) 7%, var(--popover));
--contextual-tour-panel-border: color-mix(in srgb, var(--foreground) 14%, var(--border));
background: var(--contextual-tour-panel-surface);
border-color: var(--contextual-tour-panel-border);
box-shadow:
inset 0 1px 0 0 color-mix(in srgb, var(--foreground) 10%, transparent),
0 10px 24px rgba(0, 0, 0, 0.18);
}
.dark .orca-contextual-tour-panel {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--foreground) 4%, var(--popover)) 0%,
var(--popover) 14%
);
box-shadow:
inset 0 1px 0 0 color-mix(in srgb, var(--foreground) 5%, transparent),
0 10px 24px rgba(0, 0, 0, 0.18);
}
/* Why: a tour panel must read as elevated above the chrome it is teaching about
without resorting to color. A 1px inner top highlight plus the documented
floating shadow keeps it above the surface in both light and dark modes. */
.orca-contextual-tour-panel {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--foreground) 4%, var(--popover)) 0%,
var(--popover) 12%
);
box-shadow:
inset 0 1px 0 0 color-mix(in srgb, var(--foreground) 10%, transparent),
0 10px 24px rgba(0, 0, 0, 0.18);
}
.dark .orca-contextual-tour-panel {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--foreground) 4%, var(--popover)) 0%,
var(--popover) 14%
);
--contextual-tour-panel-surface: color-mix(in srgb, var(--foreground) 10%, var(--popover));
--contextual-tour-panel-border: color-mix(in srgb, var(--foreground) 16%, var(--border));
box-shadow:
inset 0 1px 0 0 color-mix(in srgb, var(--foreground) 5%, transparent),
0 10px 24px rgba(0, 0, 0, 0.18);

View File

@ -1,66 +1,50 @@
import type { CSSProperties, JSX } from 'react'
import type { ContextualTourPanelPlacement } from './contextual-tour-panel-position'
import type { CSSProperties, JSX, RefObject } from 'react'
import {
CONTEXTUAL_TOUR_ARROW_SIZE,
CONTEXTUAL_TOUR_PANEL_BORDER_WIDTH,
type ContextualTourPanelPlacement
} from './contextual-tour-floating-position'
const ARROW_WIDTH = CONTEXTUAL_TOUR_ARROW_SIZE.width
const ARROW_HEIGHT = CONTEXTUAL_TOUR_ARROW_SIZE.height
// Why: CSS rotation pivots on the svg center, so horizontal placements must
// also shift by (width - height) / 2 to keep the rotated arrow flush with the
// panel edge instead of half-swallowed by it.
const PLACEMENT_TRANSFORM = {
top: 'rotate(0deg)',
bottom: 'rotate(180deg)',
left: `translateX(${(ARROW_WIDTH - ARROW_HEIGHT) / 2}px) rotate(-90deg)`,
right: `translateX(${(ARROW_HEIGHT - ARROW_WIDTH) / 2}px) rotate(90deg)`
} satisfies Record<ContextualTourPanelPlacement, string>
export function ContextualTourArrow({
placement
arrowRef,
placement,
style
}: {
arrowRef: RefObject<SVGSVGElement | null>
placement: ContextualTourPanelPlacement
style: CSSProperties
}): JSX.Element {
// Why: a small triangle pointing at the target makes the panel/target
// relationship readable when the user's eye starts on the panel.
const offsetCss = 'var(--contextual-tour-arrow-offset, 50%)'
const horizontal = placement === 'top' || placement === 'bottom'
const longSide = 12
const shortSide = 6
const wrapperStyle: CSSProperties = horizontal
? {
width: longSide,
height: shortSide,
left: offsetCss,
transform: 'translateX(-50%)',
...(placement === 'top' ? { top: '100%' } : { bottom: '100%' })
}
: {
width: shortSide,
height: longSide,
top: offsetCss,
transform: 'translateY(-50%)',
...(placement === 'left' ? { left: '100%' } : { right: '100%' })
}
const path =
placement === 'top'
? 'M0 0 L6 6 L12 0'
: placement === 'bottom'
? 'M0 6 L6 0 L12 6'
: placement === 'left'
? 'M0 0 L6 6 L0 12'
: 'M6 0 L0 6 L6 12'
const maskPath =
placement === 'top'
? 'M0 0 L12 0'
: placement === 'bottom'
? 'M0 6 L12 6'
: placement === 'left'
? 'M0 0 L0 12'
: 'M6 0 L6 12'
return (
<span aria-hidden="true" className="absolute block" style={wrapperStyle}>
<svg
viewBox={horizontal ? '0 0 12 6' : '0 0 6 12'}
width={horizontal ? longSide : shortSide}
height={horizontal ? shortSide : longSide}
className="overflow-visible"
preserveAspectRatio="none"
>
<path
d={path}
className="fill-popover stroke-border"
strokeWidth={1}
strokeLinejoin="round"
/>
{/* Why: hide the join with the panel border so the panel edge reads as continuous. */}
<path d={maskPath} className="stroke-popover" strokeWidth={1.5} fill="none" />
</svg>
</span>
<svg
ref={arrowRef}
aria-hidden="true"
width={ARROW_WIDTH}
height={ARROW_HEIGHT}
viewBox={`0 0 ${ARROW_WIDTH} ${ARROW_HEIGHT}`}
className="absolute block overflow-visible fill-(--contextual-tour-panel-surface) stroke-(--contextual-tour-panel-border)"
style={{ ...style, transform: PLACEMENT_TRANSFORM[placement] }}
>
{/* Why: an open path fills as a triangle but strokes only the two slanted
edges; a closed polygon (Radix Arrow) also strokes the base, drawing a
seam across the panel border. Stroke width must match the 1px panel
border so the outline reads as continuous. */}
<path
d={`M0,0 L${ARROW_WIDTH / 2},${ARROW_HEIGHT} L${ARROW_WIDTH},0`}
strokeWidth={CONTEXTUAL_TOUR_PANEL_BORDER_WIDTH}
/>
</svg>
)
}

View File

@ -42,12 +42,6 @@ function AutoRenameBranchFromWorkControl(): JSX.Element {
'Auto-name from first message'
)}
</div>
<div className="mt-0.5 text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.contextual.tours.ContextualTourControl.02e8373219',
'Auto-generates a new name when you leave this text box empty.'
)}
</div>
</div>
<button
type="button"

View File

@ -1,6 +1,9 @@
import { Children, isValidElement, type ReactElement, type ReactNode, type RefObject } from 'react'
// @vitest-environment happy-dom
import { act, type ReactElement, type RefObject } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ContextualTourId } from '../../../../shared/contextual-tours'
import { getContextualTourCleanupOutcome } from './ContextualTourOverlay'
import {
@ -12,12 +15,6 @@ import {
import { getContextualTourPanelHost } from './contextual-tour-gate'
import { useAppStore } from '@/store'
type ClickableElementProps = {
children?: ReactNode
onClick?: () => void
'aria-label'?: string
}
const baseRenderState: ActiveTourRenderState = {
rect: {
left: 10,
@ -27,9 +24,9 @@ const baseRenderState: ActiveTourRenderState = {
width: 100,
height: 60
} as DOMRect,
targetElement: {
closest: () => null
} as unknown as Element,
// Why: autoUpdate reads real element geometry, so the fixture must be a DOM
// node rather than a closest() stub.
targetElement: document.createElement('div'),
progress: { current: 1, total: 3 },
title: 'Choose the work source',
body: 'Switch between connected providers and project filters without changing pages.',
@ -38,7 +35,20 @@ const baseRenderState: ActiveTourRenderState = {
panelHost: null
}
let container: HTMLDivElement
let root: Root
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
@ -53,84 +63,46 @@ function renderSurface(
} = {}
): ReactElement {
const renderState = { ...baseRenderState, ...overrides }
return ContextualTourOverlaySurface({
activeTourId: 'tasks',
renderState,
panelRef: { current: null } as RefObject<HTMLElement | null>,
panelPosition: { left: 130, top: 20, '--contextual-tour-arrow-offset': '40px' },
panelPlacement: 'right',
panelHost: renderState.panelHost,
onSkip: callbacks.onSkip ?? vi.fn(),
onBack: callbacks.onBack ?? vi.fn(),
onNext: callbacks.onNext ?? vi.fn(),
onStepAction: callbacks.onStepAction ?? vi.fn(),
onOverlayKeyDownCapture: handleContextualTourOverlayKeyDown
return (
<ContextualTourOverlaySurface
activeTourId="tasks"
renderState={renderState}
panelRef={{ current: null } as RefObject<HTMLElement | null>}
panelHost={renderState.panelHost}
onSkip={callbacks.onSkip ?? vi.fn()}
onBack={callbacks.onBack ?? vi.fn()}
onNext={callbacks.onNext ?? vi.fn()}
onStepAction={callbacks.onStepAction ?? vi.fn()}
onOverlayKeyDownCapture={handleContextualTourOverlayKeyDown}
/>
)
}
function renderSurfaceInDom(
overrides: Partial<ActiveTourRenderState> = {},
callbacks: Parameters<typeof renderSurface>[1] = {}
): void {
act(() => {
root.render(renderSurface(overrides, callbacks))
})
}
function findElementByText(
node: ReactNode,
text: string
): ReactElement<ClickableElementProps> | null {
if (Array.isArray(node)) {
for (const child of node) {
const match = findElementByText(child, text)
if (match) {
return match
}
}
return null
function getButtonByText(text: string): HTMLButtonElement {
const button = Array.from(container.querySelectorAll<HTMLButtonElement>('button')).find(
(element) => element.textContent?.includes(text)
)
if (!button) {
throw new Error(`button not rendered: ${text}`)
}
if (!isValidElement(node)) {
return null
}
const props = node.props as ClickableElementProps
const childrenArray = Children.toArray(props.children)
if (childrenArray.some((child) => child === text)) {
return node as ReactElement<ClickableElementProps>
}
for (const child of childrenArray) {
const match = findElementByText(child, text)
if (match) {
return match
}
}
return null
return button
}
function findElementByAriaLabel(
node: ReactNode,
label: string
): ReactElement<ClickableElementProps> | null {
if (Array.isArray(node)) {
for (const child of node) {
const match = findElementByAriaLabel(child, label)
if (match) {
return match
}
}
return null
function getButtonByAriaLabel(label: string): HTMLButtonElement {
const button = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
if (!button) {
throw new Error(`button not rendered: ${label}`)
}
if (!isValidElement(node)) {
return null
}
const props = node.props as ClickableElementProps
if (props['aria-label'] === label) {
return node as ReactElement<ClickableElementProps>
}
for (const child of Children.toArray(props.children)) {
const match = findElementByAriaLabel(child, label)
if (match) {
return match
}
}
return null
return button
}
describe('ContextualTourOverlaySurface', () => {
@ -206,23 +178,20 @@ describe('ContextualTourOverlaySurface', () => {
it('shows the Back button on later steps and wires the callback', () => {
const onBack = vi.fn()
const element = renderSurface(
{ progress: { current: 2, total: 3 }, isFirstStep: false },
{ onBack }
)
const backNode = findElementByText(element, 'Back')
expect(backNode).not.toBeNull()
backNode?.props.onClick?.()
renderSurfaceInDom({ progress: { current: 2, total: 3 }, isFirstStep: false }, { onBack })
getButtonByText('Back').click()
expect(onBack).toHaveBeenCalledTimes(1)
})
it('wires Skip and Next callbacks', () => {
const onSkip = vi.fn()
const onNext = vi.fn()
const element = renderSurface({}, { onSkip, onNext })
renderSurfaceInDom({}, { onSkip, onNext })
findElementByAriaLabel(element, 'Skip tour')?.props.onClick?.()
findElementByText(element, 'Next')?.props.onClick?.()
getButtonByAriaLabel('Skip tour').click()
getButtonByText('Next').click()
expect(onSkip).toHaveBeenCalledWith('tasks')
expect(onNext).toHaveBeenCalledTimes(1)
@ -232,7 +201,7 @@ describe('ContextualTourOverlaySurface', () => {
const onStepAction = vi.fn()
const primaryAction = { kind: 'split-terminal-pane' as const, label: 'Split terminal' }
const secondaryAction = { kind: 'next' as const, label: 'Skip' }
const element = renderSurface(
renderSurfaceInDom(
{
primaryAction,
secondaryAction
@ -240,8 +209,8 @@ describe('ContextualTourOverlaySurface', () => {
{ onStepAction }
)
findElementByText(element, 'Split terminal')?.props.onClick?.()
findElementByText(element, 'Skip')?.props.onClick?.()
getButtonByText('Split terminal').click()
getButtonByText('Skip').click()
expect(onStepAction).toHaveBeenCalledWith(primaryAction)
expect(onStepAction).toHaveBeenCalledWith(secondaryAction)

View File

@ -15,7 +15,6 @@ import {
getContextualTourCleanupOutcome,
measureContextualTourOverlayRenderState
} from './contextual-tour-overlay-measurement'
import { getContextualTourOverlayPanelPosition } from './contextual-tour-overlay-position'
import {
ContextualTourOverlaySurface,
getContextualTourFocusableElements,
@ -336,25 +335,11 @@ export function ContextualTourOverlay(): JSX.Element | null {
})
}
const viewport = {
width: typeof window === 'undefined' ? 1024 : window.innerWidth,
height: typeof window === 'undefined' ? 768 : window.innerHeight
}
const { panelPosition, panelPlacement } = getContextualTourOverlayPanelPosition({
targetRect: renderState.rect,
panelElement: panelRef.current,
panelHost: renderState.panelHost,
preferredPlacement: renderState.preferredPlacement,
viewport
})
return (
<ContextualTourOverlaySurface
activeTourId={activeTourId}
renderState={renderState}
panelRef={panelRef}
panelPosition={panelPosition}
panelPlacement={panelPlacement}
panelHost={renderState.panelHost}
onSkip={(id) => {
emitContextualTourOutcome('skipped')

View File

@ -1,5 +1,13 @@
import { createPortal } from 'react-dom'
import { type CSSProperties, type JSX, type KeyboardEvent, type RefObject } from 'react'
import {
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type JSX,
type KeyboardEvent,
type RefObject
} from 'react'
import { ArrowLeft, ArrowRight, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@ -13,7 +21,10 @@ import type {
import { ContextualTourArrow } from './ContextualTourArrow'
import { ContextualTourControl } from './ContextualTourControl'
import { ContextualTourProgressDots } from './ContextualTourProgressDots'
import type { ContextualTourPanelPlacement } from './contextual-tour-panel-position'
import {
watchContextualTourFloatingPosition,
type ContextualTourFloatingPosition
} from './contextual-tour-floating-position'
import { translate } from '@/i18n/i18n'
const FOCUSABLE_SELECTOR =
@ -37,16 +48,10 @@ export type ActiveTourRenderState = {
panelHost: HTMLElement | null
}
type PanelPositionStyle = CSSProperties & {
'--contextual-tour-arrow-offset'?: string
}
type ContextualTourOverlaySurfaceProps = {
activeTourId: ContextualTourId
renderState: ActiveTourRenderState
panelRef: RefObject<HTMLElement | null>
panelPosition: PanelPositionStyle
panelPlacement: ContextualTourPanelPlacement | null
panelHost: HTMLElement | null
onSkip: (id: ContextualTourId) => void
onBack: () => void
@ -74,8 +79,6 @@ export function ContextualTourOverlaySurface({
activeTourId,
renderState,
panelRef,
panelPosition,
panelPlacement,
panelHost,
onSkip,
onBack,
@ -83,6 +86,10 @@ export function ContextualTourOverlaySurface({
onStepAction,
onOverlayKeyDownCapture
}: ContextualTourOverlaySurfaceProps): JSX.Element {
const arrowRef = useRef<SVGSVGElement | null>(null)
const [floatingPosition, setFloatingPosition] = useState<ContextualTourFloatingPosition | null>(
null
)
const panelHostSlot = panelHost?.getAttribute('data-slot')
const hostedPanelClass = cn(
PANEL_BASE_CLASSES,
@ -113,6 +120,32 @@ export function ContextualTourOverlaySurface({
height: renderState.rect.height
} satisfies CSSProperties)
: undefined
const unresolvedPanelPosition = {
left: 0,
top: 0,
visibility: 'hidden'
} satisfies CSSProperties
useLayoutEffect(() => {
const panelElement = panelRef.current
const arrowElement = arrowRef.current
if (!panelElement || !arrowElement) {
setFloatingPosition(null)
return
}
// Why: hide only until the new step's first measurement; autoUpdate then
// tracks the target continuously, so the panel never blinks mid-step.
setFloatingPosition(null)
return watchContextualTourFloatingPosition({
arrowElement,
floatingElement: panelElement,
panelHost,
preferredPlacement: renderState.preferredPlacement,
targetElement: renderState.targetElement,
onPosition: setFloatingPosition
})
}, [panelHost, panelRef, renderState.preferredPlacement, renderState.targetElement])
const panel = (
<section
@ -120,13 +153,17 @@ export function ContextualTourOverlaySurface({
aria-live="polite"
aria-label={renderState.title}
data-contextual-tour-panel=""
data-placement={panelPlacement ?? undefined}
data-placement={floatingPosition?.panelPlacement ?? undefined}
role="dialog"
tabIndex={-1}
className={panelHost ? hostedPanelClass : floatingPanelClass}
style={panelPosition}
style={floatingPosition?.panelPosition ?? unresolvedPanelPosition}
>
{panelPlacement ? <ContextualTourArrow placement={panelPlacement} /> : null}
<ContextualTourArrow
arrowRef={arrowRef}
placement={floatingPosition?.panelPlacement ?? renderState.preferredPlacement ?? 'right'}
style={floatingPosition?.arrowPosition ?? { visibility: 'hidden' }}
/>
<div key={stepKey} className="animate-in fade-in-0 duration-150 ease-out p-4">
<Button
type="button"

View File

@ -0,0 +1,274 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it } from 'vitest'
import {
getContextualTourFloatingPosition,
watchContextualTourFloatingPosition,
type ContextualTourFloatingPosition,
type ContextualTourPanelPlacement
} from './contextual-tour-floating-position'
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 960 })
Object.defineProperty(document.documentElement, 'clientWidth', {
configurable: true,
value: 1280
})
Object.defineProperty(document.documentElement, 'clientHeight', {
configurable: true,
value: 960
})
})
function elementWithRect(
rect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>
): HTMLElement {
const element = document.createElement('div')
element.style.width = `${rect.width}px`
element.style.height = `${rect.height}px`
Object.defineProperty(element, 'getBoundingClientRect', {
value: () => ({ ...rect, x: rect.left, y: rect.top })
})
Object.defineProperty(element, 'offsetWidth', { value: rect.width })
Object.defineProperty(element, 'offsetHeight', { value: rect.height })
// Why: happy-dom does no layout, so client dimensions default to 0 and a
// collision boundary element would otherwise read as zero-sized.
Object.defineProperty(element, 'clientWidth', { value: rect.width })
Object.defineProperty(element, 'clientHeight', { value: rect.height })
document.body.appendChild(element)
return element
}
function arrowElement(): SVGSVGElement {
const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
Object.defineProperty(element, 'getBoundingClientRect', {
value: () => ({ left: 0, right: 18, top: 0, bottom: 8, width: 18, height: 8, x: 0, y: 0 })
})
document.body.appendChild(element)
return element
}
function expectedStaticArrowSide(placement: ContextualTourPanelPlacement): string {
return {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right'
}[placement]
}
describe('contextual tour floating position', () => {
it('places the panel by the preferred side and returns arrow coordinates', async () => {
const host = elementWithRect({
left: 0,
right: 1024,
top: 0,
bottom: 768,
width: 1024,
height: 768
})
const target = elementWithRect({
left: 100,
right: 200,
top: 200,
bottom: 240,
width: 100,
height: 40
})
const panel = elementWithRect({
left: 0,
right: 320,
top: 0,
bottom: 180,
width: 320,
height: 180
})
const position = await getContextualTourFloatingPosition({
arrowElement: arrowElement(),
floatingElement: panel,
panelHost: host,
targetElement: target
})
expect(Number.isFinite(Number(position.panelPosition.left))).toBe(true)
expect(Number.isFinite(Number(position.panelPosition.top))).toBe(true)
// Why: -(height - 1) overlaps the panel by its 1px border so the arrow
// fill covers the border segment beneath its base.
expect(position.arrowPosition[expectedStaticArrowSide(position.panelPlacement)]).toBe(-7)
})
// Regression: a tall step panel inside a small dialog host fit no placement,
// so it overflowed the host and overflow-hidden clipped its buttons.
it('keeps the panel inside the host when no placement fits, overlapping the target', async () => {
const host = elementWithRect({
left: 300,
right: 820,
top: 120,
bottom: 420,
width: 520,
height: 300
})
host.style.position = 'fixed'
const target = elementWithRect({
left: 400,
right: 500,
top: 330,
bottom: 370,
width: 100,
height: 40
})
host.appendChild(target)
const panel = elementWithRect({
left: 0,
right: 320,
top: 0,
bottom: 180,
width: 320,
height: 180
})
panel.style.position = 'absolute'
Object.defineProperty(panel, 'offsetParent', { configurable: true, value: host })
host.appendChild(panel)
const position = await getContextualTourFloatingPosition({
arrowElement: arrowElement(),
floatingElement: panel,
panelHost: host,
preferredPlacement: 'bottom',
targetElement: target
})
const left = Number(position.panelPosition.left)
const top = Number(position.panelPosition.top)
expect(left).toBeGreaterThanOrEqual(0)
expect(left + 320).toBeLessThanOrEqual(520)
expect(top).toBeGreaterThanOrEqual(0)
expect(top + 180).toBeLessThanOrEqual(300)
})
it('delivers positions continuously while watching and stops after cleanup', async () => {
const target = elementWithRect({
left: 100,
right: 200,
top: 200,
bottom: 240,
width: 100,
height: 40
})
const panel = elementWithRect({
left: 0,
right: 320,
top: 0,
bottom: 180,
width: 320,
height: 180
})
const positions: ContextualTourFloatingPosition[] = []
const stopWatching = watchContextualTourFloatingPosition({
arrowElement: arrowElement(),
floatingElement: panel,
panelHost: null,
targetElement: target,
onPosition: (position) => positions.push(position)
})
await new Promise((resolve) => setTimeout(resolve, 50))
expect(positions.length).toBeGreaterThan(0)
expect(Number.isFinite(Number(positions[0].panelPosition.left))).toBe(true)
stopWatching()
const deliveredBeforeStop = positions.length
await new Promise((resolve) => setTimeout(resolve, 50))
expect(positions.length).toBe(deliveredBeforeStop)
})
it.each([
['top', { left: 390, top: 208 }],
['bottom', { left: 390, top: 452 }],
['left', { left: 168, top: 330 }],
['right', { left: 612, top: 330 }]
] as const)(
'computes exact viewport coordinates for unhosted %s placement',
async (placement, expected) => {
const target = elementWithRect({
left: 500,
right: 600,
top: 400,
bottom: 440,
width: 100,
height: 40
})
const panel = elementWithRect({
left: 0,
right: 320,
top: 0,
bottom: 180,
width: 320,
height: 180
})
const position = await getContextualTourFloatingPosition({
arrowElement: arrowElement(),
floatingElement: panel,
panelHost: null,
preferredPlacement: placement,
targetElement: target
})
expect(position.panelPlacement).toBe(placement)
expect(position.panelPosition).toEqual(expected)
}
)
// Regression: computePosition already returns coordinates relative to the
// panel's offsetParent (the host). Subtracting the host rect again sent
// hosted panels (e.g. the workspace-creation dialog tour) off-screen.
it('positions hosted panels in host-local coordinates without double offset subtraction', async () => {
const host = elementWithRect({
left: 300,
right: 820,
top: 120,
bottom: 720,
width: 520,
height: 600
})
host.style.position = 'fixed'
const target = elementWithRect({
left: 400,
right: 500,
top: 200,
bottom: 240,
width: 100,
height: 40
})
host.appendChild(target)
const panel = elementWithRect({
left: 0,
right: 320,
top: 0,
bottom: 180,
width: 320,
height: 180
})
panel.style.position = 'absolute'
Object.defineProperty(panel, 'offsetParent', { configurable: true, value: host })
host.appendChild(panel)
const position = await getContextualTourFloatingPosition({
arrowElement: arrowElement(),
floatingElement: panel,
panelHost: host,
preferredPlacement: 'bottom',
targetElement: target
})
// Host-local: target is at (100, 80) inside the host, so a bottom-placed
// panel sits at target bottom (120) + 12px gap, shifted to stay inside.
expect(position.panelPosition.top).toBe(132)
expect(position.panelPosition.left).toBeGreaterThanOrEqual(0)
expect(Number(position.panelPosition.left)).toBeLessThanOrEqual(520 - 320)
})
})

View File

@ -0,0 +1,146 @@
import {
arrow,
autoUpdate,
computePosition,
flip,
offset,
shift,
type Boundary,
type Placement
} from '@floating-ui/dom'
import type { CSSProperties } from 'react'
import type { ContextualTourStepPlacement } from '../../../../shared/contextual-tours'
export type ContextualTourPanelPlacement = 'top' | 'right' | 'bottom' | 'left'
export type ContextualTourFloatingPosition = {
arrowPosition: CSSProperties
panelPlacement: ContextualTourPanelPlacement
panelPosition: CSSProperties
}
const PANEL_GAP = 12
const COLLISION_PADDING = 12
const ARROW_PADDING = 16
const ARROW_WIDTH = 18
const ARROW_HEIGHT = 8
const FALLBACK_PLACEMENTS = {
top: ['bottom', 'right', 'left'],
right: ['left', 'bottom', 'top'],
bottom: ['top', 'right', 'left'],
left: ['right', 'bottom', 'top']
} satisfies Record<ContextualTourPanelPlacement, ContextualTourPanelPlacement[]>
export const CONTEXTUAL_TOUR_ARROW_SIZE = {
width: ARROW_WIDTH,
height: ARROW_HEIGHT
} as const
// Why: the arrow overlaps the panel by exactly the border width so its fill
// covers the border segment beneath it — the panel outline then flows around
// the arrow tip instead of cutting across its base.
export const CONTEXTUAL_TOUR_PANEL_BORDER_WIDTH = 1
export async function getContextualTourFloatingPosition(args: {
arrowElement: Element
floatingElement: HTMLElement
panelHost: HTMLElement | null
preferredPlacement?: ContextualTourStepPlacement
targetElement: Element
}): Promise<ContextualTourFloatingPosition> {
const initialPlacement = args.preferredPlacement ?? 'right'
const boundary = getContextualTourCollisionBoundary(args.panelHost)
const result = await computePosition(args.targetElement, args.floatingElement, {
// Why: the strategy must match the panel's actual CSS position — hosted
// panels are absolute children of the dialog/sheet, floating ones fixed.
// computePosition returns coordinates relative to the panel's offsetParent,
// so the result is applied to left/top as-is in both cases.
strategy: args.panelHost ? 'absolute' : 'fixed',
placement: initialPlacement,
middleware: [
offset(PANEL_GAP),
flip({
boundary,
padding: COLLISION_PADDING,
fallbackPlacements: FALLBACK_PLACEMENTS[initialPlacement]
}),
// Why: crossAxis lets the panel slide over the target when no placement
// fits (e.g. a tall step panel inside a small dialog host) — a partial
// overlap keeps the panel's buttons reachable instead of letting the
// host's overflow clipping cut them off.
shift({ boundary, padding: COLLISION_PADDING, crossAxis: true }),
arrow({ element: args.arrowElement, padding: ARROW_PADDING })
]
})
const panelPlacement = getContextualTourPanelPlacement(result.placement)
const panelPosition: CSSProperties = { left: result.x, top: result.y }
const arrowPosition = getContextualTourArrowPosition({
arrowX: result.middlewareData.arrow?.x,
arrowY: result.middlewareData.arrow?.y,
panelPlacement
})
return { arrowPosition, panelPlacement, panelPosition }
}
export function watchContextualTourFloatingPosition(args: {
arrowElement: Element
floatingElement: HTMLElement
panelHost: HTMLElement | null
preferredPlacement?: ContextualTourStepPlacement
targetElement: Element
onPosition: (position: ContextualTourFloatingPosition) => void
}): () => void {
let disposed = false
let updateSequence = 0
const update = (): void => {
const sequence = ++updateSequence
void getContextualTourFloatingPosition(args)
.then((position) => {
// Why: computePosition is async; a stale resolve after dispose or a
// newer frame must not overwrite the latest panel position.
if (!disposed && sequence === updateSequence) {
args.onPosition(position)
}
})
.catch(() => undefined)
}
// Why: tour targets move with layout animation (sidebar slide, pane resize),
// which scroll/resize observers can't see. Frame-loop tracking keeps the
// panel glued to its target instead of polling and re-showing it.
const stopAutoUpdate = autoUpdate(args.targetElement, args.floatingElement, update, {
animationFrame: true
})
return () => {
disposed = true
stopAutoUpdate()
}
}
function getContextualTourCollisionBoundary(panelHost: HTMLElement | null): Boundary {
return panelHost ?? 'clippingAncestors'
}
function getContextualTourPanelPlacement(placement: Placement): ContextualTourPanelPlacement {
return placement.split('-')[0] as ContextualTourPanelPlacement
}
function getContextualTourArrowPosition(args: {
arrowX?: number
arrowY?: number
panelPlacement: ContextualTourPanelPlacement
}): CSSProperties {
const staticSide = {
top: 'bottom',
right: 'left',
bottom: 'top',
left: 'right'
}[args.panelPlacement]
return {
left: args.arrowX,
top: args.arrowY,
[staticSide]: -(ARROW_HEIGHT - CONTEXTUAL_TOUR_PANEL_BORDER_WIDTH)
}
}

View File

@ -1,69 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getContextualTourOverlayPanelPosition } from './contextual-tour-overlay-position'
function rect(
partial: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>
): DOMRect {
return partial as DOMRect
}
function elementWithRect(
bounds: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>
): HTMLElement {
return {
getBoundingClientRect: () => rect(bounds)
} as HTMLElement
}
describe('contextual tour overlay position', () => {
it('returns viewport coordinates for floating panels', () => {
const position = getContextualTourOverlayPanelPosition({
targetRect: rect({ left: 100, right: 200, top: 200, bottom: 240, width: 100, height: 40 }),
panelElement: elementWithRect({
left: 0,
right: 320,
top: 0,
bottom: 180,
width: 320,
height: 180
}),
panelHost: null,
viewport: { width: 1024, height: 768 }
})
expect(position.panelPlacement).toBe('right')
expect(position.panelPosition.left).toBe(212)
expect(position.panelPosition.top).toBe(130)
expect(position.panelPosition['--contextual-tour-arrow-offset']).toBe('90px')
})
it('returns host-local coordinates for panels portaled into clipped dialog content', () => {
const position = getContextualTourOverlayPanelPosition({
targetRect: rect({ left: 110, right: 1018, top: 240, bottom: 315, width: 908, height: 75 }),
panelElement: elementWithRect({
left: 0,
right: 320,
top: 0,
bottom: 180,
width: 320,
height: 180
}),
panelHost: elementWithRect({
left: 55,
right: 1075,
top: 42,
bottom: 952,
width: 1020,
height: 910
}),
viewport: { width: 1512, height: 982 }
})
expect(position.panelPlacement).toBe('bottom')
expect(position.panelPosition.left).toBe(349)
expect(position.panelPosition.top).toBe(285)
expect(position.panelPosition.left).toBeGreaterThanOrEqual(12)
expect(Number(position.panelPosition.left) + 320).toBeLessThanOrEqual(1020 - 12)
expect(position.panelPosition['--contextual-tour-arrow-offset']).toBe('160px')
})
})

View File

@ -1,51 +0,0 @@
import type { CSSProperties } from 'react'
import type { ContextualTourStepPlacement } from '../../../../shared/contextual-tours'
import type { ContextualTourPanelPlacement } from './contextual-tour-panel-position'
import {
clampContextualTourPanelPosition,
getContextualTourTargetRectInHost
} from './contextual-tour-panel-position'
const PANEL_FALLBACK_SIZE = { width: 304, height: 172 }
export type ContextualTourOverlayPanelPosition = {
panelPosition: CSSProperties & { '--contextual-tour-arrow-offset'?: string }
panelPlacement: ContextualTourPanelPlacement
}
/**
* Returns the CSS position and placement for a tour panel rendered inside an overlay host,
* clamping coordinates to host space so clipped containers don't obscure the panel.
*/
export function getContextualTourOverlayPanelPosition(args: {
targetRect: DOMRect
panelElement: HTMLElement | null
panelHost: HTMLElement | null
preferredPlacement?: ContextualTourStepPlacement
viewport: { width: number; height: number }
}): ContextualTourOverlayPanelPosition {
const panelRect = args.panelElement?.getBoundingClientRect()
const panel = panelRect
? { width: panelRect.width, height: panelRect.height }
: PANEL_FALLBACK_SIZE
// Why: hosted panels portal into dialog/sheet content whose overflow clips
// them, so position and clamp in host space — viewport clamping can park the
// panel in the clipped region outside the host and leave only a sliver visible.
const hostRect = args.panelHost?.getBoundingClientRect()
const clamped = clampContextualTourPanelPosition({
targetRect: hostRect
? getContextualTourTargetRectInHost(args.targetRect, hostRect)
: args.targetRect,
viewport: hostRect ? { width: hostRect.width, height: hostRect.height } : args.viewport,
panel,
preferredPlacement: args.preferredPlacement
})
return {
panelPlacement: clamped.placement,
panelPosition: {
left: clamped.left,
top: clamped.top,
'--contextual-tour-arrow-offset': `${clamped.arrowOffset}px`
}
}
}

View File

@ -1,134 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
clampContextualTourPanelPosition,
getContextualTourTargetRectInHost
} from './contextual-tour-panel-position'
describe('contextual tour panel position', () => {
it('clamps the panel inside narrow viewports', () => {
const position = clampContextualTourPanelPosition({
targetRect: {
left: 20,
right: 140,
top: 40,
bottom: 100,
width: 120,
height: 60
},
viewport: { width: 320, height: 220 },
panel: { width: 304, height: 160 }
})
expect(position.left).toBeGreaterThanOrEqual(12)
expect(position.top).toBeGreaterThanOrEqual(12)
expect(position.left).toBeLessThanOrEqual(12)
expect(position.top).toBeLessThanOrEqual(48)
})
it('places the panel to the right when room allows and aims the arrow at target center', () => {
const position = clampContextualTourPanelPosition({
targetRect: { left: 100, right: 200, top: 200, bottom: 240, width: 100, height: 40 },
viewport: { width: 1024, height: 768 },
panel: { width: 320, height: 180 }
})
expect(position.placement).toBe('right')
// panel is positioned to the right of the target, vertically centered;
// arrow should sit near the panel's vertical center pointing at the target's center
expect(position.left).toBe(212)
expect(position.arrowOffset).toBeGreaterThan(60)
expect(position.arrowOffset).toBeLessThan(120)
})
it('translates a viewport target rect into hosted dialog coordinates', () => {
expect(
getContextualTourTargetRectInHost(
{ left: 555, right: 1018, top: 240, bottom: 315, width: 463, height: 75 },
{ left: 500, top: 80 }
)
).toEqual({ left: 55, right: 518, top: 160, bottom: 235, width: 463, height: 75 })
})
it('keeps a hosted panel inside a dialog whose field spans nearly its full width', () => {
// Regression: the workspace-creation tour panel was clamped against the
// viewport, so it sat to the right of the Project field — outside the
// dialog content that clips overflow — and only a sliver was visible.
const hostRect = { left: 55, top: 42 }
const position = clampContextualTourPanelPosition({
targetRect: getContextualTourTargetRectInHost(
{ left: 110, right: 1018, top: 240, bottom: 315, width: 908, height: 75 },
hostRect
),
viewport: { width: 1020, height: 910 },
panel: { width: 320, height: 180 }
})
expect(position.placement).toBe('bottom')
expect(position.left).toBeGreaterThanOrEqual(12)
expect(position.left + 320).toBeLessThanOrEqual(1020 - 12)
expect(position.top + 180).toBeLessThanOrEqual(910 - 12)
})
it('flips below the target when neither side has horizontal room', () => {
const position = clampContextualTourPanelPosition({
targetRect: { left: 60, right: 260, top: 40, bottom: 80, width: 200, height: 40 },
viewport: { width: 320, height: 600 },
panel: { width: 304, height: 160 }
})
expect(position.placement).toBe('bottom')
expect(position.top).toBeGreaterThanOrEqual(80 + 12)
})
it('honors a preferred placement for anchored tip-style tours', () => {
const position = clampContextualTourPanelPosition({
targetRect: { left: 280, right: 1160, top: 452, bottom: 453, width: 880, height: 1 },
viewport: { width: 1512, height: 900 },
panel: { width: 320, height: 140 },
preferredPlacement: 'bottom'
})
expect(position.placement).toBe('bottom')
expect(position.top).toBe(465)
expect(position.left).toBe(560)
})
it('keeps the floating workspace surface fallback panel outside the taught surface', () => {
const targetRect = { left: 360, right: 1080, top: 96, bottom: 536, width: 720, height: 440 }
const position = clampContextualTourPanelPosition({
targetRect,
viewport: { width: 1280, height: 720 },
panel: { width: 320, height: 160 },
preferredPlacement: 'left'
})
expect(position.placement).toBe('left')
expect(position.left + 320).toBeLessThanOrEqual(targetRect.left - 12)
})
it('flips a preferred side placement instead of clamping over the target', () => {
const targetRect = { left: 12, right: 360, top: 96, bottom: 536, width: 348, height: 440 }
const position = clampContextualTourPanelPosition({
targetRect,
viewport: { width: 1280, height: 720 },
panel: { width: 320, height: 160 },
preferredPlacement: 'left'
})
expect(position.placement).toBe('right')
expect(position.left).toBeGreaterThanOrEqual(targetRect.right + 12)
})
it('uses side room when a preferred vertical placement cannot fit above or below', () => {
const targetRect = { left: 120, right: 220, top: 40, bottom: 680, width: 100, height: 640 }
const position = clampContextualTourPanelPosition({
targetRect,
viewport: { width: 1280, height: 720 },
panel: { width: 320, height: 160 },
preferredPlacement: 'bottom'
})
expect(position.placement).toBe('right')
expect(position.left).toBeGreaterThanOrEqual(targetRect.right + 12)
})
})

View File

@ -1,207 +0,0 @@
export type ContextualTourPanelPlacement = 'top' | 'right' | 'bottom' | 'left'
export type ContextualTourPanelPosition = {
left: number
top: number
placement: ContextualTourPanelPlacement
arrowOffset: number
}
type ViewportSize = {
width: number
height: number
}
type PanelSize = {
width: number
height: number
}
/**
* Computes the position and placement for a tour panel relative to a target element,
* choosing the best side and clamping the panel within the viewport.
*/
export function clampContextualTourPanelPosition(args: {
targetRect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>
viewport: ViewportSize
panel: PanelSize
preferredPlacement?: ContextualTourPanelPlacement
gap?: number
margin?: number
}): ContextualTourPanelPosition {
const gap = args.gap ?? 12
const margin = args.margin ?? 12
const { targetRect, viewport, panel } = args
const roomRight = viewport.width - targetRect.right
const roomLeft = targetRect.left
const roomBelow = viewport.height - targetRect.bottom
const roomAbove = targetRect.top
let placement: ContextualTourPanelPlacement
let left: number
let top: number
if (args.preferredPlacement) {
placement = resolvePreferredPlacement({
preferredPlacement: args.preferredPlacement,
roomAbove,
roomBelow,
roomLeft,
roomRight,
panel,
gap
})
const preferredPosition = getUnclampedPanelPosition({
placement,
targetRect,
panel,
gap
})
left = preferredPosition.left
top = preferredPosition.top
} else if (roomRight >= panel.width + gap || roomRight >= roomLeft) {
placement = 'right'
left = targetRect.right + gap
top = targetRect.top + targetRect.height / 2 - panel.height / 2
} else {
placement = 'left'
left = targetRect.left - panel.width - gap
top = targetRect.top + targetRect.height / 2 - panel.height / 2
}
if (roomRight < panel.width + gap && roomLeft < panel.width + gap) {
left = targetRect.left + targetRect.width / 2 - panel.width / 2
if (roomBelow >= panel.height + gap || roomBelow >= roomAbove) {
placement = 'bottom'
top = targetRect.bottom + gap
} else {
placement = 'top'
top = targetRect.top - panel.height - gap
}
}
const clampedLeft = clampNumber(
left,
margin,
Math.max(margin, viewport.width - panel.width - margin)
)
const clampedTop = clampNumber(
top,
margin,
Math.max(margin, viewport.height - panel.height - margin)
)
// Arrow offset along the panel edge, pointed at the target's center.
const targetCenterX = targetRect.left + targetRect.width / 2
const targetCenterY = targetRect.top + targetRect.height / 2
const arrowMargin = 16
const arrowOffset =
placement === 'top' || placement === 'bottom'
? clampNumber(targetCenterX - clampedLeft, arrowMargin, panel.width - arrowMargin)
: clampNumber(targetCenterY - clampedTop, arrowMargin, panel.height - arrowMargin)
return { left: clampedLeft, top: clampedTop, placement, arrowOffset }
}
// Why: flip to the opposite side when the preferred side lacks room, and fall
// back to the horizontal axis when neither vertical side fits.
function resolvePreferredPlacement(args: {
preferredPlacement: ContextualTourPanelPlacement
roomAbove: number
roomBelow: number
roomLeft: number
roomRight: number
panel: PanelSize
gap: number
}): ContextualTourPanelPlacement {
const horizontalRoom = args.panel.width + args.gap
const verticalRoom = args.panel.height + args.gap
if (args.preferredPlacement === 'left') {
return args.roomLeft < horizontalRoom && args.roomRight >= horizontalRoom ? 'right' : 'left'
}
if (args.preferredPlacement === 'right') {
return args.roomRight < horizontalRoom && args.roomLeft >= horizontalRoom ? 'left' : 'right'
}
if (args.preferredPlacement === 'top') {
if (args.roomAbove >= verticalRoom) {
return 'top'
}
if (args.roomBelow >= verticalRoom) {
return 'bottom'
}
return getPreferredHorizontalPlacement(args)
}
if (args.roomBelow >= verticalRoom) {
return 'bottom'
}
if (args.roomAbove >= verticalRoom) {
return 'top'
}
return getPreferredHorizontalPlacement(args)
}
// Why: prefer right when it fits or has at least as much room as left,
// matching the no-preference placement heuristic above.
function getPreferredHorizontalPlacement(args: {
roomLeft: number
roomRight: number
panel: PanelSize
gap: number
}): ContextualTourPanelPlacement {
const horizontalRoom = args.panel.width + args.gap
if (args.roomRight >= horizontalRoom || args.roomRight >= args.roomLeft) {
return 'right'
}
return 'left'
}
/** Returns the raw (unclamped) top-left position for a panel at the given placement side. */
function getUnclampedPanelPosition(args: {
placement: ContextualTourPanelPlacement
targetRect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>
panel: PanelSize
gap: number
}): Pick<ContextualTourPanelPosition, 'left' | 'top'> {
const { placement, targetRect, panel, gap } = args
if (placement === 'top') {
return {
left: targetRect.left + targetRect.width / 2 - panel.width / 2,
top: targetRect.top - panel.height - gap
}
}
if (placement === 'bottom') {
return {
left: targetRect.left + targetRect.width / 2 - panel.width / 2,
top: targetRect.bottom + gap
}
}
if (placement === 'left') {
return {
left: targetRect.left - panel.width - gap,
top: targetRect.top + targetRect.height / 2 - panel.height / 2
}
}
return {
left: targetRect.right + gap,
top: targetRect.top + targetRect.height / 2 - panel.height / 2
}
}
/** Translates a target rect from viewport coordinates into the host element's local coordinate space. */
export function getContextualTourTargetRectInHost(
targetRect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>,
hostRect: Pick<DOMRect, 'left' | 'top'>
): Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'> {
return {
left: targetRect.left - hostRect.left,
right: targetRect.right - hostRect.left,
top: targetRect.top - hostRect.top,
bottom: targetRect.bottom - hostRect.top,
width: targetRect.width,
height: targetRect.height
}
}
/** Clamps a number between min and max, inclusive. */
function clampNumber(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}