Compare commits

...
Author SHA1 Message Date
Jingyi-Dify 5807c36029 fix(web): contain step tour focus 2026-07-27 15:26:06 -07:00
7 changed files with 332 additions and 49 deletions
@@ -371,11 +371,13 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
source: 'explore_template_preview',
templateId: currentTryApp?.appId || currentTryApp?.app.app_id,
}
shouldCompleteHomeTourOnCreateRef.current =
const isHomeTryAppCreateGuideActive =
isCurrentTryAppFromLearnDifyRef.current &&
activeStepByStepTourTaskId === HOME_STEP_BY_STEP_TOUR_TASK_ID &&
!completedStepByStepTourTaskIds.includes(HOME_STEP_BY_STEP_TOUR_TASK_ID) &&
activeStepByStepTourGuideIndex === 1
shouldCompleteHomeTourOnCreateRef.current = isHomeTryAppCreateGuideActive
if (isHomeTryAppCreateGuideActive) resetStepByStepTourSession()
setIsShowCreateModal(true)
}, [
activeStepByStepTourGuideIndex,
@@ -383,6 +385,7 @@ const Apps = ({ onSuccess }: { onSuccess?: () => void }) => {
completedStepByStepTourTaskIds,
currentTryApp?.app,
currentTryApp?.appId,
resetStepByStepTourSession,
])
const handleCreateFromLearnDify = useCallback((app: App) => {
setCurrApp(app)
@@ -55,7 +55,7 @@ const LearnDifyItem = ({ canCreate, item, onCreate, onTry }: LearnDifyItemProps)
return (
<article
className={cn(
'relative flex min-w-0 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-4 pt-4 pb-4 shadow-xs',
'relative flex min-w-0 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-4 pt-4 pb-4 shadow-xs outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
isClickable && 'cursor-pointer',
)}
role={isClickable ? 'button' : undefined}
@@ -863,7 +863,7 @@ describe('StepByStepTourMount', () => {
await user.click(screen.getByRole('button', { name: 'Take a look' }))
expect(mockRouterPush).toHaveBeenCalledWith('/integrations/model-provider')
expect(await screen.findByRole('region', { name: 'Model Provider' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Model Provider' })).toBeInTheDocument()
expect(screen.getByText('1 of 5')).toBeInTheDocument()
expect(
screen.getByText(
@@ -929,9 +929,10 @@ describe('StepByStepTourMount', () => {
await user.click(await screen.findByRole('button', { name: 'Show me' }))
expect(mockRouterPush).toHaveBeenCalledWith('/')
expect(
await screen.findByRole('region', { name: 'Pick a lesson to see how it works.' }),
).toBeInTheDocument()
const coachmark = await screen.findByRole('region', {
name: 'Pick a lesson to see how it works.',
})
expect(coachmark).not.toHaveAttribute('aria-modal')
expect(screen.queryByText('Try a Learn Dify lesson')).not.toBeInTheDocument()
expect(screen.getByText('1 of 2')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Skip tour' })).toBeInTheDocument()
@@ -940,6 +941,89 @@ describe('StepByStepTourMount', () => {
}
})
it('keeps keyboard focus within the Learn Dify target and coachmark', async () => {
setStepByStepTourTestState({
activeTaskId: 'home',
activeGuideIndex: 0,
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: true,
completedTaskIds: [],
skipped: false,
})
const target = createTourTarget(STEP_BY_STEP_TOUR_TARGETS.home, 120, {
height: 180,
left: 40,
width: 360,
})
const firstLesson = document.createElement('button')
firstLesson.textContent = 'First lesson'
const secondLesson = document.createElement('button')
secondLesson.textContent = 'Second lesson'
const backgroundAction = document.createElement('button')
backgroundAction.textContent = 'Background action'
target.append(firstLesson, secondLesson)
document.body.appendChild(backgroundAction)
try {
renderStepByStepTourMount()
await screen.findByRole('region', { name: 'Pick a lesson to see how it works.' })
await waitFor(() => expect(firstLesson).toHaveFocus())
await user.tab()
expect(secondLesson).toHaveFocus()
await user.tab()
expect(screen.getByRole('button', { name: 'Skip tour' })).toHaveFocus()
await user.tab()
expect(firstLesson).toHaveFocus()
await user.tab({ shift: true })
expect(screen.getByRole('button', { name: 'Skip tour' })).toHaveFocus()
expect(backgroundAction).not.toHaveFocus()
} finally {
backgroundAction.remove()
target.remove()
}
})
it('keeps keyboard focus inside blocked walkthrough coachmarks', async () => {
mockPathname = '/integrations/model-provider'
setStepByStepTourTestState({
activeTaskId: 'integration',
activeGuideIndex: 0,
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: true,
completedTaskIds: ['home', 'studio', 'knowledge'],
skipped: false,
})
const target = createTourTarget(STEP_BY_STEP_TOUR_TARGETS.integrationModelProviderNav)
const backgroundAction = document.createElement('button')
backgroundAction.textContent = 'Background action'
document.body.appendChild(backgroundAction)
try {
renderStepByStepTourMount()
const coachmark = await screen.findByRole('dialog', { name: 'Model Provider' })
expect(coachmark).toHaveAttribute('aria-modal', 'true')
await waitFor(() => expect(coachmark).toHaveFocus())
await user.tab()
expect(screen.getByRole('button', { name: 'Skip tour' })).toHaveFocus()
await user.tab()
expect(screen.getByRole('button', { name: 'Got it' })).toHaveFocus()
await user.tab()
expect(screen.getByRole('button', { name: 'Skip tour' })).toHaveFocus()
await user.tab({ shift: true })
expect(screen.getByRole('button', { name: 'Got it' })).toHaveFocus()
expect(backgroundAction).not.toHaveFocus()
} finally {
backgroundAction.remove()
target.remove()
}
})
it('uses no-create Learn Dify copy when the workspace cannot create apps', async () => {
mockWorkspacePermissionKeys.value = []
setStepByStepTourTestState({
@@ -967,7 +1051,7 @@ describe('StepByStepTourMount', () => {
await user.click(await screen.findByRole('button', { name: 'Show me' }))
expect(mockRouterPush).toHaveBeenCalledWith('/')
expect(await screen.findByRole('region', { name: 'Browse Learn Dify' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Browse Learn Dify' })).toBeInTheDocument()
expect(
screen.getByText(
'You can review lessons and see how Dify works here. Creating an app from a lesson requires a workspace where you have create permission, or help from an admin.',
@@ -1084,7 +1168,7 @@ describe('StepByStepTourMount', () => {
try {
renderStepByStepTourMount()
expect(await screen.findByRole('region', { name: 'Model Provider' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Model Provider' })).toBeInTheDocument()
const [minimizedTourButton] = screen.getAllByRole('button', {
name: 'Open step-by-step tour',
})
@@ -1104,7 +1188,7 @@ describe('StepByStepTourMount', () => {
})
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'Tool Plugin' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Tool Plugin' })).toBeInTheDocument()
expect(screen.getByText('2 of 6')).toBeInTheDocument()
expect(mockRouterPush).toHaveBeenLastCalledWith('/integrations/tools/built-in')
expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', {
@@ -1114,24 +1198,24 @@ describe('StepByStepTourMount', () => {
})
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'MCP' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'MCP' })).toBeInTheDocument()
expect(screen.getByText('3 of 6')).toBeInTheDocument()
expect(mockRouterPush).toHaveBeenLastCalledWith('/integrations/tools/mcp')
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'Data Source' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Data Source' })).toBeInTheDocument()
expect(screen.getByText('4 of 6')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Learn more' })).not.toBeInTheDocument()
expect(mockRouterPush).toHaveBeenLastCalledWith('/integrations/data-source')
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'Trigger' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Trigger' })).toBeInTheDocument()
expect(screen.getByText('5 of 6')).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Learn more' })).not.toBeInTheDocument()
expect(mockRouterPush).toHaveBeenLastCalledWith('/integrations/trigger')
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'Update Settings' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Update Settings' })).toBeInTheDocument()
expect(screen.getByText('6 of 6')).toBeInTheDocument()
expect(mockRouterPush).toHaveBeenLastCalledWith('/integrations/tools/built-in')
@@ -1169,23 +1253,23 @@ describe('StepByStepTourMount', () => {
try {
renderStepByStepTourMount()
expect(await screen.findByRole('region', { name: 'Model Provider' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Model Provider' })).toBeInTheDocument()
expect(screen.getByText('1 of 6')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'Tool Plugin' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Tool Plugin' })).toBeInTheDocument()
expect(screen.getByText('2 of 6')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'MCP' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'MCP' })).toBeInTheDocument()
expect(screen.getByText('3 of 6')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'Data Source' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Data Source' })).toBeInTheDocument()
expect(screen.getByText('4 of 6')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'Trigger' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Trigger' })).toBeInTheDocument()
expect(screen.getByText('5 of 6')).toBeInTheDocument()
const updateSettingsTarget = createTourTarget(
@@ -1196,7 +1280,7 @@ describe('StepByStepTourMount', () => {
mockPathname = '/integrations/tools/built-in'
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'Update Settings' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Update Settings' })).toBeInTheDocument()
expect(screen.getByText('6 of 6')).toBeInTheDocument()
} finally {
targets.forEach((target) => target.remove())
@@ -1234,23 +1318,23 @@ describe('StepByStepTourMount', () => {
try {
renderStepByStepTourMount()
expect(await screen.findByRole('region', { name: 'Model Provider' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Model Provider' })).toBeInTheDocument()
expect(screen.getByText('1 of 5')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'Tool Plugin' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Tool Plugin' })).toBeInTheDocument()
expect(screen.getByText('2 of 5')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'MCP' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'MCP' })).toBeInTheDocument()
expect(screen.getByText('3 of 5')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'Data Source' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Data Source' })).toBeInTheDocument()
expect(screen.getByText('4 of 5')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Got it' }))
expect(await screen.findByRole('region', { name: 'Trigger' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Trigger' })).toBeInTheDocument()
expect(screen.getByText('5 of 5')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Got it' }))
@@ -1286,7 +1370,7 @@ describe('StepByStepTourMount', () => {
try {
renderStepByStepTourMount()
expect(await screen.findByRole('region', { name: 'Import a DSL file' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Import a DSL file' })).toBeInTheDocument()
expect(screen.getByText('3 of 3')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Got it' }))
@@ -1323,7 +1407,7 @@ describe('StepByStepTourMount', () => {
try {
renderStepByStepTourMount()
expect(await screen.findByRole('region', { name: 'Create a new app' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Create a new app' })).toBeInTheDocument()
expect(screen.getByText('1 of 1')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Got it' }))
@@ -1350,7 +1434,7 @@ describe('StepByStepTourMount', () => {
try {
renderStepByStepTourMount()
expect(await screen.findByRole('region', { name: 'Tool Plugin' })).toBeInTheDocument()
expect(await screen.findByRole('dialog', { name: 'Tool Plugin' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Got it' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Skip tour' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Learn more' })).not.toBeInTheDocument()
@@ -12,6 +12,7 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { getStepByStepTourGuideKind, getStepByStepTourTargetSelector } from './target-registry'
import { useStepByStepTourCoachmarkPosition } from './use-coachmark-position'
import { useStepByStepTourFocusScope } from './use-focus-scope'
import { useStepByStepTourTargetRect } from './use-target-rect'
const HIGHLIGHT_PADDING = 4
@@ -89,6 +90,7 @@ type StepByStepTourCoachmarkProps = {
stepLabel: string
skipLabel: string
interactionPolicy: StepByStepTourGuideInteractionPolicy
returnFocusElement: HTMLElement | null
onSkip: () => void
onComplete: () => void
}
@@ -100,6 +102,7 @@ export function StepByStepTourCoachmark({
stepLabel,
skipLabel,
interactionPolicy,
returnFocusElement,
onSkip,
onComplete,
}: StepByStepTourCoachmarkProps) {
@@ -111,6 +114,7 @@ export function StepByStepTourCoachmark({
targetElement: measuredTargetElement,
} = useStepByStepTourTargetRect(targetElement, guide.highlightPartSelectors)
const coachmarkRef = useRef<HTMLDivElement>(null)
const focusScopeRef = useRef<HTMLElement>(null)
const coachmarkSizeFrameRef = useRef<number | undefined>(undefined)
const shouldUseLatePortalRoot = guide.portalOrder === 'afterOverlays'
const portalRoot = useStepByStepTourPortalRoot(shouldUseLatePortalRoot)
@@ -159,6 +163,7 @@ export function StepByStepTourCoachmark({
const isActionGuide = stableOverlay
? getStepByStepTourGuideKind(stableOverlay.guide) === 'action'
: false
const isBlocked = stableOverlay?.interactionPolicy === 'blocked'
const coachmarkMeasurementKey = stableOverlay
? [
stableOverlay.guide.target,
@@ -182,6 +187,15 @@ export function StepByStepTourCoachmark({
}
: undefined
useStepByStepTourFocusScope({
enabled: Boolean(stableOverlay && highlightStyle),
guideId: stableOverlay?.guide.id,
interactionPolicy: stableOverlay?.interactionPolicy ?? interactionPolicy,
returnFocusElement,
scopeRef: focusScopeRef,
targetElement,
})
const syncCoachmarkSize = useCallback((element: HTMLElement) => {
const rect = element.getBoundingClientRect()
const nextSize = {
@@ -217,32 +231,44 @@ export function StepByStepTourCoachmark({
if (typeof document === 'undefined') return null
if (shouldUseLatePortalRoot && !portalRoot) return null
const targetBlockerStyles: CSSProperties[] =
const targetBlockerStyles: { key: string; style: CSSProperties }[] =
stableOverlay && highlightStyle
? [
{
height: highlightStyle.top,
left: 0,
right: 0,
top: 0,
key: 'top',
style: {
height: highlightStyle.top,
left: 0,
right: 0,
top: 0,
},
},
{
bottom: 0,
left: 0,
top: Number(highlightStyle.top) + Number(highlightStyle.height),
right: 0,
key: 'bottom',
style: {
bottom: 0,
left: 0,
top: Number(highlightStyle.top) + Number(highlightStyle.height),
right: 0,
},
},
{
height: highlightStyle.height,
left: 0,
top: highlightStyle.top,
width: highlightStyle.left,
key: 'left',
style: {
height: highlightStyle.height,
left: 0,
top: highlightStyle.top,
width: highlightStyle.left,
},
},
{
height: highlightStyle.height,
left: Number(highlightStyle.left) + Number(highlightStyle.width),
right: 0,
top: highlightStyle.top,
key: 'right',
style: {
height: highlightStyle.height,
left: Number(highlightStyle.left) + Number(highlightStyle.width),
right: 0,
top: highlightStyle.top,
},
},
]
: []
@@ -250,10 +276,9 @@ export function StepByStepTourCoachmark({
return createPortal(
<>
{stableOverlay?.interactionPolicy === 'target-only' ? (
targetBlockerStyles.map((style, index) => (
targetBlockerStyles.map(({ key, style }) => (
<div
// eslint-disable-next-line react/no-array-index-key -- The four blocker slices are static and positional.
key={index}
key={key}
aria-hidden="true"
data-step-by-step-tour-backdrop=""
data-step-by-step-tour-blocker=""
@@ -308,11 +333,15 @@ export function StepByStepTourCoachmark({
</div>
)}
<section
ref={focusScopeRef}
role={isBlocked ? 'dialog' : undefined}
aria-label={
isActionGuide ? stableOverlay.guide.description : stableOverlay.guide.title
}
aria-modal={isBlocked || undefined}
tabIndex={-1}
className={cn(
'relative flex w-full flex-col rounded-2xl border-[0.5px] border-state-accent-hover-alt bg-state-accent-hover p-4 shadow-[0_20px_24px_-4px_var(--color-shadow-shadow-5),0_8px_8px_-4px_var(--color-shadow-shadow-1)] backdrop-blur-[5px]',
'relative flex w-full flex-col rounded-2xl border-[0.5px] border-state-accent-hover-alt bg-state-accent-hover p-4 shadow-[0_20px_24px_-4px_var(--color-shadow-shadow-5),0_8px_8px_-4px_var(--color-shadow-shadow-1)] outline-hidden backdrop-blur-[5px] focus-visible:ring-2 focus-visible:ring-state-accent-solid',
isActionGuide ? 'min-h-[118px]' : 'min-h-[158px]',
)}
>
@@ -71,6 +71,8 @@ export function FloatingChecklist({
return (
<section
aria-label={title}
data-step-by-step-tour-checklist=""
tabIndex={-1}
className={cn(
'flex max-h-[calc(100vh-16px)] w-[320px] max-w-[calc(100vw-16px)] flex-col overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur bg-clip-padding shadow-[0_8px_8px_-4px_var(--color-shadow-shadow-1),0_20px_24px_-4px_var(--color-shadow-shadow-5)] backdrop-blur-[10px]',
className,
+14 -1
View File
@@ -103,6 +103,13 @@ const scrollTourTargetIntoView = (element: HTMLElement) => {
})
}
const getActiveFocusableElement = () => {
const activeElement = document.activeElement
return activeElement instanceof HTMLElement && activeElement !== document.body
? activeElement
: null
}
const createGuideIndexes = (guides: StepByStepTourGuide[]) => guides.map((_, index) => index)
const getActiveGuideIndexes = (
@@ -156,6 +163,9 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
const shownAnalyticsKeyRef = useRef<string | undefined>(undefined)
const skipTimeoutRef = useRef<number | undefined>(undefined)
const stepShownAnalyticsKeyRef = useRef<string | undefined>(undefined)
const returnFocusElementRef = useRef<HTMLElement | null>(
typeof document === 'undefined' ? null : getActiveFocusableElement(),
)
const [checklistExiting, setChecklistExiting] = useState(false)
const currentWorkspaceId = currentWorkspace.id
const canCreateApp = hasPermission(workspacePermissionKeys, 'app.create_and_management')
@@ -634,6 +644,8 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
if (!task) return
returnFocusElementRef.current = getActiveFocusableElement()
const guideGroup =
taskId === 'home'
? homeGuideGroup
@@ -713,12 +725,13 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
activeGuide,
activeTask.canClickThrough,
)}
returnFocusElement={returnFocusElementRef.current}
onSkip={skipActiveGuide}
onComplete={completeActiveGuide}
/>
)}
{visible && (!allTasksCompleted || completionPromptVisible) && (
<Popover open={overlayVisible && expanded}>
<Popover modal={false} open={overlayVisible && expanded}>
<div
ref={anchorRef}
aria-hidden="true"
@@ -0,0 +1,152 @@
'use client'
import type { RefObject } from 'react'
import type { StepByStepTourGuideInteractionPolicy } from './target-registry'
import { useLayoutEffect, useRef } from 'react'
const FOCUSABLE_SELECTOR = [
'a[href]',
'button',
'input',
'select',
'textarea',
'[contenteditable="true"]',
'[tabindex="0"]',
].join(',')
const MODAL_DIALOG_SELECTOR = '[role="dialog"][aria-modal="true"]'
const TOUR_CHECKLIST_SELECTOR = '[data-step-by-step-tour-checklist]'
const isFocusable = (element: HTMLElement) => {
if (element.tabIndex !== 0 || element.matches(':disabled')) return false
if (element.closest('[aria-hidden="true"], [hidden], [inert]')) return false
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden'
}
const getFocusableElements = (container: HTMLElement) => {
const elements = [
...(container.matches(FOCUSABLE_SELECTOR) ? [container] : []),
...container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
]
return elements.filter(isFocusable)
}
const contains = (container: Element, element: Element | null) =>
Boolean(element && (container === element || container.contains(element)))
const getExternalModal = (allowedContainers: HTMLElement[]) =>
Array.from(document.querySelectorAll<HTMLElement>(MODAL_DIALOG_SELECTOR)).find(
(modal) => !allowedContainers.some((container) => contains(modal, container)),
)
export const useStepByStepTourFocusScope = ({
enabled,
guideId,
interactionPolicy,
returnFocusElement,
scopeRef,
targetElement,
}: {
enabled: boolean
guideId?: string
interactionPolicy: StepByStepTourGuideInteractionPolicy
returnFocusElement: HTMLElement | null
scopeRef: RefObject<HTMLElement | null>
targetElement: HTMLElement
}) => {
const returnFocusRef = useRef<HTMLElement | null>(null)
const lastFocusedElementRef = useRef<HTMLElement | null>(null)
useLayoutEffect(() => {
const scopeElement = scopeRef.current
if (!enabled || !scopeElement) return
if (!returnFocusRef.current?.isConnected) {
returnFocusRef.current =
returnFocusElement ??
(document.activeElement instanceof HTMLElement ? document.activeElement : null)
}
const allowedContainers =
interactionPolicy === 'target-only' ? [targetElement, scopeElement] : [scopeElement]
const getInitialFocus = () =>
interactionPolicy === 'target-only'
? (getFocusableElements(targetElement)[0] ?? scopeElement)
: scopeElement
const restoreScopeFocus = () => {
const lastFocusedElement = lastFocusedElementRef.current
const focusTarget =
lastFocusedElement &&
allowedContainers.some((container) => contains(container, lastFocusedElement))
? lastFocusedElement
: getInitialFocus()
focusTarget.focus({ preventScroll: true })
}
getInitialFocus().focus({ preventScroll: true })
lastFocusedElementRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab' || event.altKey || event.ctrlKey || event.metaKey) return
if (getExternalModal(allowedContainers)) return
const focusableElements = allowedContainers.flatMap(getFocusableElements)
if (focusableElements.length === 0) {
event.preventDefault()
scopeElement.focus({ preventScroll: true })
return
}
const currentIndex = focusableElements.indexOf(document.activeElement as HTMLElement)
const nextIndex = event.shiftKey
? currentIndex <= 0
? focusableElements.length - 1
: currentIndex - 1
: currentIndex === -1 || currentIndex === focusableElements.length - 1
? 0
: currentIndex + 1
event.preventDefault()
focusableElements[nextIndex]?.focus({ preventScroll: true })
}
const handleFocusIn = (event: FocusEvent) => {
const focusedElement = event.target instanceof HTMLElement ? event.target : null
if (!focusedElement) return
if (allowedContainers.some((container) => contains(container, focusedElement))) {
lastFocusedElementRef.current = focusedElement
return
}
if (getExternalModal(allowedContainers)) return
queueMicrotask(restoreScopeFocus)
}
document.addEventListener('focusin', handleFocusIn, true)
document.addEventListener('keydown', handleKeyDown, true)
return () => {
document.removeEventListener('focusin', handleFocusIn, true)
document.removeEventListener('keydown', handleKeyDown, true)
window.requestAnimationFrame(() => {
if (document.querySelector('[data-step-by-step-tour-coachmark]')) return
if (getExternalModal(allowedContainers)) return
const returnFocusElement = returnFocusRef.current
const checklistElement = document.querySelector<HTMLElement>(TOUR_CHECKLIST_SELECTOR)
const focusTarget = returnFocusElement?.isConnected
? returnFocusElement
: (getFocusableElements(targetElement)[0] ?? checklistElement)
focusTarget?.focus({ preventScroll: true })
})
}
}, [enabled, guideId, interactionPolicy, scopeRef, targetElement])
}