Move tour panel positioning out of gate

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-28 16:25:37 -07:00
co-authored by Orca
parent 08496af14f
commit 5b18086bc6
6 changed files with 94 additions and 92 deletions
@@ -1,5 +1,5 @@
import type { CSSProperties, JSX } from 'react'
import type { ContextualTourPanelPlacement } from './contextual-tour-gate'
import type { ContextualTourPanelPlacement } from './contextual-tour-panel-position'
export function ContextualTourArrow({
placement
@@ -16,7 +16,6 @@ import {
trackContextualTourShown
} from '@/lib/feature-education-telemetry'
import {
clampContextualTourPanelPosition,
getContextualTourStepCopy,
getContextualTourStepProgress,
getContextualTourOutcomeStepTotal,
@@ -25,7 +24,10 @@ import {
getVisibleContextualTourStepIndexes,
isContextualTourAllowedForModal
} from './contextual-tour-gate'
import { getContextualTourPanelCssPosition } from './contextual-tour-panel-position'
import {
clampContextualTourPanelPosition,
getContextualTourPanelCssPosition
} from './contextual-tour-panel-position'
import {
ContextualTourOverlaySurface,
getContextualTourFocusableElements,
@@ -11,7 +11,7 @@ import type {
import { ContextualTourArrow } from './ContextualTourArrow'
import { ContextualTourControl } from './ContextualTourControl'
import { ContextualTourProgressDots } from './ContextualTourProgressDots'
import type { ContextualTourPanelPlacement } from './contextual-tour-gate'
import type { ContextualTourPanelPlacement } from './contextual-tour-panel-position'
const FOCUSABLE_SELECTOR =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import { getContextualTour } from '../../../../shared/contextual-tours'
import {
clampContextualTourPanelPosition,
getContextualTourRequestDecision,
getContextualTourStepProgress,
getContextualTourOutcomeStepTotal,
@@ -11,7 +10,10 @@ import {
getVisibleContextualTourStepIndexes,
isContextualTourAllowedForModal
} from './contextual-tour-gate'
import { getContextualTourPanelCssPosition } from './contextual-tour-panel-position'
import {
clampContextualTourPanelPosition,
getContextualTourPanelCssPosition
} from './contextual-tour-panel-position'
describe('contextual tour gate', () => {
it('allows only workspace creation over its workspace composer modal', () => {
@@ -21,25 +21,6 @@ export type ContextualTourRequestDecision =
| 'missing-start-target'
}
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
}
const PANEL_HOST_SELECTOR = '[data-slot="dialog-content"], [data-slot="sheet-content"]'
export function getContextualTourPanelHost(targetElement: Element): HTMLElement | null {
@@ -205,68 +186,6 @@ export function getContextualTourOutcomeStepTotal(
return visibleStepIndexes.length > 0 ? visibleStepIndexes.length : fallback
}
export function clampContextualTourPanelPosition(args: {
targetRect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>
viewport: ViewportSize
panel: PanelSize
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 (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 }
}
export function getContextualTourStepCopy(step: ContextualTourStep): string {
return step.body || step.fallbackCopy || ''
}
@@ -320,7 +239,3 @@ function isContextualTourTargetVisible(element: Element): boolean {
}
return true
}
function clampNumber(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
@@ -1,4 +1,83 @@
import type { ContextualTourPanelPosition } from './contextual-tour-gate'
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
}
export function clampContextualTourPanelPosition(args: {
targetRect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>
viewport: ViewportSize
panel: PanelSize
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 (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 }
}
export function getContextualTourPanelCssPosition(args: {
position: ContextualTourPanelPosition
@@ -9,3 +88,7 @@ export function getContextualTourPanelCssPosition(args: {
const top = panelHostRect ? position.top - panelHostRect.top : position.top
return { left, top, arrowOffset: position.arrowOffset }
}
function clampNumber(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}