Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5807c36029 |
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
Reference in New Issue
Block a user