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:
parent
a4a1b12d80
commit
10411fd39c
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Reference in New Issue