fix(web): improve main navigation responsiveness and tour focus (#39940)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh
2026-08-03 19:05:49 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent b4f360127d
commit 5456d4d56e
16 changed files with 153 additions and 230 deletions
+2 -2
View File
@@ -1493,7 +1493,7 @@
},
"web/app/components/base/icons/src/vender/solid/development/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 4
"count": 3
}
},
"web/app/components/base/icons/src/vender/solid/editor/index.ts": {
@@ -1503,7 +1503,7 @@
},
"web/app/components/base/icons/src/vender/solid/education/index.ts": {
"no-barrel-files/no-barrel-files": {
"count": 2
"count": 1
}
},
"web/app/components/base/icons/src/vender/solid/files/index.ts": {
@@ -1,38 +0,0 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "12",
"height": "12",
"viewBox": "0 0 12 12",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "terminal-square"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Solid",
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M8.91927 1H3.08073C2.81716 0.999992 2.58977 0.999984 2.40249 1.01529C2.20481 1.03144 2.00821 1.06709 1.81902 1.16349C1.53677 1.3073 1.3073 1.53677 1.16349 1.81902C1.06709 2.00821 1.03144 2.20481 1.01529 2.40249C0.999984 2.58977 0.999992 2.81714 1 3.08071V8.91927C0.999992 9.18284 0.999984 9.41023 1.01529 9.59752C1.03144 9.79519 1.06709 9.9918 1.16349 10.181C1.3073 10.4632 1.53677 10.6927 1.81902 10.8365C2.00821 10.9329 2.20481 10.9686 2.40249 10.9847C2.58977 11 2.81715 11 3.08072 11H8.91928C9.18285 11 9.41023 11 9.59752 10.9847C9.79519 10.9686 9.9918 10.9329 10.181 10.8365C10.4632 10.6927 10.6927 10.4632 10.8365 10.181C10.9329 9.9918 10.9686 9.79519 10.9847 9.59752C11 9.41023 11 9.18285 11 8.91928V3.08072C11 2.81715 11 2.58977 10.9847 2.40249C10.9686 2.20481 10.9329 2.00821 10.8365 1.81902C10.6927 1.53677 10.4632 1.3073 10.181 1.16349C9.9918 1.06709 9.79519 1.03144 9.59752 1.01529C9.41023 0.999984 9.18284 0.999992 8.91927 1ZM3.85355 4.14645C3.65829 3.95118 3.34171 3.95118 3.14645 4.14645C2.95118 4.34171 2.95118 4.65829 3.14645 4.85355L4.29289 6L3.14645 7.14645C2.95118 7.34171 2.95118 7.65829 3.14645 7.85355C3.34171 8.04882 3.65829 8.04882 3.85355 7.85355L5.35355 6.35355C5.54882 6.15829 5.54882 5.84171 5.35355 5.64645L3.85355 4.14645ZM6.5 7C6.22386 7 6 7.22386 6 7.5C6 7.77614 6.22386 8 6.5 8H8.5C8.77614 8 9 7.77614 9 7.5C9 7.22386 8.77614 7 8.5 7H6.5Z",
"fill": "currentColor"
},
"children": []
}
]
}
]
},
"name": "TerminalSquare"
}
@@ -1,18 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './TerminalSquare.json'
const Icon = ({
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
}) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'TerminalSquare'
export default Icon
@@ -1,5 +1,4 @@
export { default as ApiConnection } from './ApiConnection'
export { default as ApiConnectionMod } from './ApiConnectionMod'
export { default as TerminalSquare } from './TerminalSquare'
export { default as Variable02 } from './Variable02'
@@ -1,38 +0,0 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "12",
"height": "12",
"viewBox": "0 0 12 12",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "beaker-02"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Solid",
"fill-rule": "evenodd",
"clip-rule": "evenodd",
"d": "M4.13856 0.500003H7.8617C7.92126 0.49998 7.99238 0.499953 8.05504 0.505073C8.12765 0.511005 8.23165 0.526227 8.34062 0.581751C8.48174 0.653656 8.59648 0.768392 8.66838 0.909513C8.72391 1.01849 8.73913 1.12248 8.74506 1.19509C8.75018 1.25775 8.75015 1.32888 8.75013 1.38844V2.61157C8.75015 2.67113 8.75018 2.74226 8.74506 2.80492C8.73913 2.87753 8.72391 2.98153 8.66838 3.0905C8.59648 3.23162 8.48174 3.34636 8.34062 3.41826C8.23165 3.47379 8.12765 3.48901 8.05504 3.49494C8.03725 3.49639 8.01877 3.49743 8.00006 3.49817V5.2506C8.00006 5.55312 8.00408 5.61265 8.01723 5.66153C8.03245 5.71807 8.05747 5.7715 8.09117 5.81939C8.1203 5.86078 8.16346 5.90197 8.39586 6.09564L10.2807 7.66627C10.4566 7.81255 10.6116 7.94145 10.7267 8.10509C10.8278 8.24875 10.9029 8.40904 10.9486 8.57867C11.0005 8.7719 11.0003 8.97351 11.0001 9.2023C11.0001 9.39886 11.0002 9.59542 11.0002 9.79198C11.0003 9.98232 11.0005 10.1463 10.9713 10.2927C10.853 10.8877 10.3878 11.3529 9.7928 11.4712C9.64637 11.5003 9.48246 11.5002 9.29211 11.5001H2.70822C2.51787 11.5002 2.35396 11.5003 2.20753 11.4712C1.98473 11.4269 1.78014 11.334 1.60515 11.2038C1.42854 11.0725 1.28221 10.9034 1.17753 10.7077C1.10892 10.5796 1.05831 10.4401 1.02899 10.2927C0.999862 10.1463 0.999992 9.98233 1.00014 9.79199C1.00014 9.59542 1.00006 9.39886 1.00003 9.20229C0.999794 8.97351 0.999584 8.7719 1.05157 8.57867C1.09721 8.40904 1.17229 8.24875 1.27338 8.10509C1.38855 7.94145 1.54356 7.81255 1.71947 7.66627L3.60427 6.09564C3.83667 5.90197 3.87983 5.86078 3.90896 5.81939C3.94266 5.7715 3.96768 5.71807 3.9829 5.66153C3.99605 5.61265 4.00006 5.55312 4.00006 5.2506V3.49817C3.9814 3.49743 3.96297 3.49639 3.94521 3.49494C3.8726 3.48901 3.76861 3.47379 3.65964 3.41826C3.51851 3.34636 3.40378 3.23162 3.33187 3.0905C3.27635 2.98153 3.26113 2.87753 3.25519 2.80492C3.25008 2.74226 3.2501 2.67113 3.25013 2.61158V1.38844C3.2501 1.32888 3.25008 1.25775 3.25519 1.19509C3.26113 1.12248 3.27635 1.01849 3.33187 0.909513C3.40378 0.768392 3.51851 0.653656 3.65964 0.581751C3.76861 0.526227 3.8726 0.511005 3.94521 0.505073C4.00787 0.499953 4.079 0.49998 4.13856 0.500003ZM9.11909 8.00004H2.88104L4.28066 6.83373C4.45657 6.68745 4.61158 6.55855 4.72675 6.39491C4.82784 6.25125 4.90292 6.09096 4.94856 5.92133C5.00054 5.7281 5.00033 5.52649 5.0001 5.29771L5.00006 3.50001H7.00006L7.00003 5.29771C6.99979 5.52649 6.99958 5.7281 7.05157 5.92133C7.09721 6.09096 7.17229 6.25125 7.27338 6.39491C7.38855 6.55855 7.54356 6.68745 7.71947 6.83373L9.11909 8.00004Z",
"fill": "currentColor"
},
"children": []
}
]
}
]
},
"name": "Beaker02"
}
@@ -1,18 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import type { IconData } from '@/app/components/base/icons/IconBase'
import * as React from 'react'
import IconBase from '@/app/components/base/icons/IconBase'
import data from './Beaker02.json'
const Icon = ({
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
}) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Beaker02'
export default Icon
@@ -1,3 +1 @@
export { default as Beaker02 } from './Beaker02'
export { default as Unblur } from './Unblur'
+4 -10
View File
@@ -2,8 +2,6 @@
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { TerminalSquare } from '@/app/components/base/icons/src/vender/solid/development'
import { Beaker02 } from '@/app/components/base/icons/src/vender/solid/education'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
const headerEnvClassName: { [k: string]: string } = {
@@ -26,18 +24,14 @@ const EnvNav = () => {
>
{langGeniusVersionInfo.current_env === 'TESTING' && (
<>
<Beaker02 className="size-3" />
<div className="ml-1 max-[1280px]:hidden">
{t(($) => $['environment.testing'], { ns: 'common' })}
</div>
<span aria-hidden className="i-custom-vender-solid-education-beaker-02 size-3" />
<div className="ml-1">{t(($) => $['environment.testing'], { ns: 'common' })}</div>
</>
)}
{langGeniusVersionInfo.current_env === 'DEVELOPMENT' && (
<>
<TerminalSquare className="size-3" />
<div className="ml-1 max-[1280px]:hidden">
{t(($) => $['environment.development'], { ns: 'common' })}
</div>
<span aria-hidden className="i-custom-vender-solid-development-terminal-square size-3" />
<div className="ml-1">{t(($) => $['environment.development'], { ns: 'common' })}</div>
</>
)}
</div>
@@ -715,22 +715,6 @@ describe('MainNav', () => {
expect(accountButton).not.toHaveClass('justify-center', 'p-1')
})
it('renders the desktop environment tag from the old header contract', () => {
mockConsoleState.current = {
...consoleState,
langGeniusVersionInfo: {
...consoleState.langGeniusVersionInfo,
current_env: 'TESTING',
},
}
renderMainNav()
const environmentTag = screen.getByText('common.environment.testing')
expect(environmentTag).toBeInTheDocument()
expect(environmentTag.closest('.relative.z-30')).toHaveClass('mt-auto', 'shrink-0')
})
it('does not reserve environment tag space when the environment is not shown', () => {
const { container } = renderMainNav()
@@ -993,6 +977,7 @@ describe('MainNav', () => {
await waitFor(() => {
expect(localStorage.getItem(LEARN_DIFY_HIDDEN_STORAGE_KEY)).toBe('false')
})
expect(screen.getByRole('menu')).toBeInTheDocument()
expect(mockPush).not.toHaveBeenCalled()
})
@@ -1036,7 +1021,7 @@ describe('MainNav', () => {
).toHaveAttribute('aria-checked', 'false')
})
it('lets existing accounts enable Step-by-step Tour from the help menu', async () => {
it('closes the help menu and opens Step-by-step Tour when enabling it', async () => {
const user = userEvent.setup()
localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed')
mockStepByStepTour.setState({
@@ -1062,6 +1047,10 @@ describe('MainNav', () => {
expect(localStorage.getItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY)).toBe('expanded')
})
expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', { action: 'tour_enabled' })
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
expect(await screen.findByRole('dialog', { name: 'Get to know Dify' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }))
expect(
@@ -91,7 +91,7 @@ describe('SupportMenu', () => {
;(mailToSupport as Mock).mockReturnValue('mailto:support@example.com')
})
const renderSupportMenu = (onContactUsClick = vi.fn()) => {
const renderSupportMenu = () => {
const { wrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: deploymentEdition },
})
@@ -99,7 +99,7 @@ describe('SupportMenu', () => {
<DropdownMenu open={true} onOpenChange={() => {}}>
<DropdownMenuTrigger>open</DropdownMenuTrigger>
<DropdownMenuContent>
<SupportMenu onContactUsClick={onContactUsClick} />
<SupportMenu />
</DropdownMenuContent>
</DropdownMenu>,
{ wrapper },
@@ -107,8 +107,7 @@ describe('SupportMenu', () => {
}
it('renders contact us before community support entries when Zendesk is configured', () => {
const onContactUsClick = vi.fn()
renderSupportMenu(onContactUsClick)
renderSupportMenu()
expect(screen.getByText('common.userProfile.contactUs')).toBeInTheDocument()
expect(screen.getByText('common.userProfile.forum')).toBeInTheDocument()
@@ -126,7 +125,6 @@ describe('SupportMenu', () => {
fireEvent.click(screen.getByRole('menuitem', { name: 'common.userProfile.contactUs' }))
expect(openZendeskWindow).toHaveBeenCalledWith('CLOUD')
expect(onContactUsClick).toHaveBeenCalled()
})
it('renders contact us with upgrade badge for Cloud sandbox plan without dedicated support', () => {
@@ -135,8 +133,7 @@ describe('SupportMenu', () => {
plan: { type: Plan.sandbox },
})
const onContactUsClick = vi.fn()
renderSupportMenu(onContactUsClick)
renderSupportMenu()
expect(screen.getByText('common.userProfile.contactUs')).toHaveClass('text-text-disabled')
expect(screen.getByText('billing.upgradeBtn.encourageShort')).toHaveClass(
@@ -156,7 +153,6 @@ describe('SupportMenu', () => {
expect(mockSetShowPricingModal).toHaveBeenCalled()
expect(openZendeskWindow).not.toHaveBeenCalled()
expect(onContactUsClick).toHaveBeenCalled()
})
it('hides upgrade contact for Cloud sandbox plan when billing is disabled', () => {
@@ -100,7 +100,6 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
const disableStepByStepTour = useSetAtom(disableStepByStepTourForCurrentWorkspaceAtom)
const setStepByStepTourShellMode = useSetStepByStepTourShellMode()
const [aboutOpen, setAboutOpen] = useState(false)
const [open, setOpen] = useState(false)
const shouldShowLearnDifySwitch = systemFeatures.enable_learn_app
const shouldShowStepByStepTourSwitch = systemFeatures.enable_step_by_step_tour
const canToggleStepByStepTour =
@@ -123,13 +122,9 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
onSuccess: trackVisibilityToggled,
})
}
if (checked) setOpen(false)
}
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (nextOpen) setSkipRecoveryVisible(false)
}
@@ -137,7 +132,7 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
return (
<>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger
aria-label={t(($) => $['mainNav.help.openMenu'], { ns: 'common' })}
data-learn-dify-help-target
@@ -201,7 +196,7 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
{systemFeatures.deployment_edition === 'CLOUD' && shouldShowStepByStepTourSwitch && (
<DropdownMenuCheckboxItem
checked={stepByStepTourEnabled}
closeOnClick={false}
closeOnClick={!stepByStepTourEnabled}
className="mx-0 h-8 gap-1 px-0 py-1 pr-2 pl-3"
disabled={!canToggleStepByStepTour}
onCheckedChange={handleStepByStepTourCheckedChange}
@@ -222,7 +217,7 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0!" />
<DropdownMenuGroup className="p-1">
<SupportMenu onContactUsClick={() => setOpen(false)} />
<SupportMenu />
</DropdownMenuGroup>
<DropdownMenuSeparator className="my-0!" />
<DropdownMenuGroup className="p-1">
@@ -249,10 +244,7 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
{env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
<DropdownMenuItem
className="mx-0 h-8 gap-1 px-3 py-1.5"
onClick={() => {
setOpen(false)
setAboutOpen(true)
}}
onClick={() => setAboutOpen(true)}
>
<MenuItemContent
iconClassName="i-ri-information-2-line"
@@ -16,11 +16,7 @@ import { useProviderContext } from '@/context/provider-context'
import { langGeniusVersionInfoAtom } from '@/context/version-state'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
type SupportMenuProps = {
onContactUsClick?: () => void
}
export default function SupportMenu({ onContactUsClick }: SupportMenuProps) {
export default function SupportMenu() {
const { t } = useTranslation()
const { data: deploymentEdition } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
@@ -46,7 +42,6 @@ export default function SupportMenu({ onContactUsClick }: SupportMenuProps) {
className="mx-0 h-8 gap-1 px-3 py-1"
onClick={() => {
setShowPricingModal()
onContactUsClick?.()
}}
>
<MenuItemContent
@@ -72,7 +67,6 @@ export default function SupportMenu({ onContactUsClick }: SupportMenuProps) {
className="mx-0 h-8 gap-1 px-3 py-1"
onClick={() => {
openZendeskWindow(deploymentEdition)
onContactUsClick?.()
}}
>
<MenuItemContent
+3 -3
View File
@@ -127,13 +127,13 @@ export function MainNav({ className }: MainNavProps) {
</nav>
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
{showEnvTag && (
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
<div className="mt-auto shrink-0 px-3 pb-2">
<EnvNav />
</div>
)}
</div>
<div className="relative w-60 shrink-0">
<StepByStepTourMount className="absolute -top-7 left-2.5 h-8 w-45.75 overflow-visible" />
<div className="isolate w-60 shrink-0">
<StepByStepTourMount className="relative z-1 -mb-1 ml-2.5 h-8 w-45.75 overflow-visible" />
<div className="flex w-60 items-center justify-between bg-linear-to-b from-background-body-transparent to-background-body to-50% py-3 pr-1 pl-3 backdrop-blur-[2px]">
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
<AccountSection />
@@ -672,6 +672,9 @@ describe('StepByStepTourMount', () => {
).toBeInTheDocument()
const dismissButton = screen.getByRole('button', { name: 'Dismiss' })
await waitFor(() => {
expect(dismissButton).toHaveFocus()
})
await user.click(dismissButton)
await waitFor(() => {
@@ -761,6 +764,71 @@ describe('StepByStepTourMount', () => {
expect(await screen.findByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument()
})
it('exposes dialog semantics and returns focus after minimizing with the keyboard', async () => {
setStepByStepTourTestState({
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: [],
skipped: false,
})
renderStepByStepTourMount()
expect(
await screen.findByRole('dialog', {
name: 'Get to know Dify',
description: 'A quick tour — about 5 minutes',
}),
).toBeInTheDocument()
const minimizeButton = await screen.findByRole('button', { name: 'Minimize tour' })
minimizeButton.focus()
await user.keyboard('{Enter}')
const restoreButton = await screen.findByRole('button', { name: 'Open step-by-step tour' })
await waitFor(() => {
expect(restoreButton).toHaveFocus()
})
expect(screen.getAllByRole('button', { name: 'Open step-by-step tour' })).toHaveLength(1)
})
it('focuses the minimize button when the checklist opens', async () => {
setStepByStepTourTestState({
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: ['home'],
skipped: false,
})
renderStepByStepTourMount()
const minimizeButton = await screen.findByRole('button', { name: 'Minimize tour' })
await waitFor(() => {
expect(minimizeButton).toHaveFocus()
})
})
it('keeps the expanded checklist open after an outside pointer interaction', async () => {
setStepByStepTourTestState({
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: [],
skipped: false,
})
renderStepByStepTourMount()
const dialog = await screen.findByRole('dialog', { name: 'Get to know Dify' })
await user.click(document.body)
expect(dialog).toBeInTheDocument()
expect(localStorage.getItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY)).toBe('expanded')
})
it('shows the completion prompt expanded even when the saved shell mode is collapsed', async () => {
localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed')
setStepByStepTourTestState({
@@ -942,13 +1010,13 @@ describe('StepByStepTourMount', () => {
}
})
it('completes Knowledge directly when the workspace has no Knowledge walkthrough permissions', async () => {
it('focuses Dismiss after completing Knowledge as the final task without permission', async () => {
mockWorkspacePermissionKeys.value = ['app.create_and_management']
setStepByStepTourTestState({
manuallyEnabledWorkspaceIds: ['workspace-1'],
manuallyDisabledWorkspaceIds: [],
minimized: false,
completedTaskIds: ['home', 'studio'],
completedTaskIds: ['home', 'studio', 'integration'],
skipped: false,
})
@@ -973,6 +1041,10 @@ describe('StepByStepTourMount', () => {
await user.click(screen.getByRole('button', { name: 'Got it' }))
await expectStepByStepTourPatch({ action: 'complete_task', task_id: 'knowledge' })
const dismissButton = await screen.findByRole('button', { name: 'Dismiss' })
await waitFor(() => {
expect(dismissButton).toHaveFocus()
})
expect(mockRouterPush).not.toHaveBeenCalled()
})
@@ -1,15 +1,16 @@
'use client'
import type { ComponentProps, RefObject } from 'react'
import type { StepByStepTourTaskId, StepByStepTourTaskView } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useEffect, useRef } from 'react'
import { PopoverDescription, PopoverTitle } from '@langgenius/dify-ui/popover'
import { useEffect } from 'react'
export type FloatingChecklistProps = {
className?: string
title: string
duration: string
minimized: boolean
progress: {
ariaValueText: string
completed: number
@@ -23,13 +24,12 @@ export type FloatingChecklistProps = {
title: string
}
tasks: StepByStepTourTaskView[]
closeButtonRef: RefObject<HTMLButtonElement | null>
skipLabel: string
minimizeLabel: string
restoreLabel: string
getTaskCompleteLabel: (taskTitle: string) => string
getTaskIncompleteLabel: (taskTitle: string) => string
onMinimize: () => void
onRestore: () => void
onSkip: () => void
onCompleteTask: (taskId: StepByStepTourTaskId) => void
onStartTask: (taskId: StepByStepTourTaskId) => void
@@ -40,34 +40,20 @@ export function FloatingChecklist({
className,
title,
duration,
minimized,
progress,
completionPrompt,
tasks,
closeButtonRef,
skipLabel,
minimizeLabel,
restoreLabel,
getTaskCompleteLabel,
getTaskIncompleteLabel,
onMinimize,
onRestore,
onSkip,
onCompleteTask,
onStartTask,
onUncompleteTask,
}: FloatingChecklistProps) {
if (minimized && !completionPrompt) {
return (
<MinimizedTourPill
title={title}
progress={progress}
restoreLabel={restoreLabel}
onRestore={onRestore}
className={className}
/>
)
}
return (
<section
aria-label={title}
@@ -79,8 +65,10 @@ export function FloatingChecklist({
<div className="flex w-full shrink-0 flex-col gap-2 px-4 pt-4 pb-1">
<div className="flex w-full items-start gap-1">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 className="system-xl-semibold text-text-secondary">{title}</h2>
<p className="system-xs-regular text-text-tertiary">{duration}</p>
<PopoverTitle className="system-xl-semibold text-text-secondary">{title}</PopoverTitle>
<PopoverDescription className="system-xs-regular text-text-tertiary">
{duration}
</PopoverDescription>
</div>
{!completionPrompt && (
<>
@@ -93,6 +81,7 @@ export function FloatingChecklist({
{skipLabel}
</Button>
<Button
ref={closeButtonRef}
variant="ghost"
size="small"
className="size-6 px-0 text-text-tertiary hover:text-text-secondary"
@@ -122,7 +111,9 @@ export function FloatingChecklist({
onUncompleteTask={onUncompleteTask}
/>
))}
{completionPrompt && <TourCompletionPrompt {...completionPrompt} />}
{completionPrompt && (
<TourCompletionPrompt {...completionPrompt} closeButtonRef={closeButtonRef} />
)}
</div>
</section>
)
@@ -134,12 +125,13 @@ function TourCompletionPrompt({
label,
onDismiss,
title,
}: NonNullable<FloatingChecklistProps['completionPrompt']>) {
const dismissRef = useRef<HTMLButtonElement>(null)
closeButtonRef,
}: NonNullable<FloatingChecklistProps['completionPrompt']> & {
closeButtonRef: RefObject<HTMLButtonElement | null>
}) {
useEffect(() => {
dismissRef.current?.focus({ preventScroll: true })
}, [])
closeButtonRef.current?.focus({ preventScroll: true })
}, [closeButtonRef])
return (
<section
@@ -156,7 +148,7 @@ function TourCompletionPrompt({
<h3 className="system-md-semibold text-text-primary">{title}</h3>
<p className="mt-1 system-sm-regular text-text-tertiary">{description}</p>
<Button
ref={dismissRef}
ref={closeButtonRef}
variant="secondary"
size="medium"
className="mt-4 min-w-20"
@@ -168,28 +160,23 @@ function TourCompletionPrompt({
)
}
function MinimizedTourPill({
export function MinimizedTourPill({
title,
progress,
restoreLabel,
onRestore,
className,
...props
}: {
title: string
progress: FloatingChecklistProps['progress']
restoreLabel: string
onRestore: () => void
className?: string
}) {
} & ComponentProps<'button'>) {
return (
<button
{...props}
type="button"
aria-label={restoreLabel}
className={cn(
'inline-flex h-8 w-45.75 max-w-[calc(100vw-16px)] items-center gap-2 overflow-hidden rounded-full border-[0.5px] border-components-panel-border bg-background-section px-3 py-2 text-saas-dify-blue-inverted outline-hidden transition-colors hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
className,
)}
onClick={onRestore}
>
<span aria-hidden className="i-custom-vender-line-education-lesson-open-01 size-4 shrink-0" />
<span className="w-26 shrink-0 truncate system-sm-medium">{title}</span>
+31 -17
View File
@@ -8,7 +8,7 @@ import type {
} from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent } from '@langgenius/dify-ui/popover'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
@@ -29,7 +29,7 @@ import { usePathname, useRouter } from '@/next/navigation'
import { hasPermission } from '@/utils/permission'
import { getStepByStepTourPermissionVariant, trackStepByStepTourEvent } from './analytics'
import { StepByStepTourCoachmark } from './coachmark'
import { FloatingChecklist } from './floating-widget'
import { FloatingChecklist, MinimizedTourPill } from './floating-widget'
import {
activeStepByStepTourGuideGroupAtom,
activeStepByStepTourGuideIndexAtom,
@@ -158,6 +158,8 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
const shellMode = useStepByStepTourShellModeValue()
const setShellMode = useSetStepByStepTourShellMode()
const anchorRef = useRef<HTMLDivElement>(null)
const checklistCloseButtonRef = useRef<HTMLButtonElement>(null)
const restoreTriggerRef = useRef<HTMLButtonElement>(null)
const lastRequestedIntegrationRouteRef = useRef<string | undefined>(undefined)
const previousSkippedRef = useRef(skipped)
const permissionFallbackAnalyticsKeyRef = useRef<string | undefined>(undefined)
@@ -588,19 +590,19 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
setShellMode('expanded')
}
const progress = {
ariaValueText: t(($) => $['stepByStepTour.progressAriaValueText'], {
completed: completedAvailableTaskIds.length,
total: availableTasks.length,
}),
completed: completedAvailableTaskIds.length,
total: availableTasks.length,
}
const floatingChecklist = (
<FloatingChecklist
title={title}
duration={t(($) => $['stepByStepTour.duration'])}
minimized={checklistMinimized}
progress={{
ariaValueText: t(($) => $['stepByStepTour.progressAriaValueText'], {
completed: completedAvailableTaskIds.length,
total: availableTasks.length,
}),
completed: completedAvailableTaskIds.length,
total: availableTasks.length,
}}
progress={progress}
completionPrompt={
completionPromptVisible
? {
@@ -613,9 +615,9 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
: undefined
}
tasks={tasks}
closeButtonRef={checklistCloseButtonRef}
skipLabel={t(($) => $['stepByStepTour.skip'])}
minimizeLabel={t(($) => $['stepByStepTour.minimize'])}
restoreLabel={t(($) => $['stepByStepTour.restore'])}
getTaskCompleteLabel={(taskTitle) =>
t(($) => $['stepByStepTour.markTaskComplete'], { title: taskTitle })
}
@@ -625,9 +627,6 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
onMinimize={() => {
setShellMode('collapsed')
}}
onRestore={() => {
setShellMode('expanded')
}}
onSkip={skipTour}
onCompleteTask={(taskId) => {
const guides = getStepByStepTourGuides(taskId, sessionGuideGroup)
@@ -730,13 +729,24 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
/>
)}
{visible && (!allTasksCompleted || completionPromptVisible) && (
<Popover open={overlayVisible && expanded}>
<Popover
open={overlayVisible && expanded}
onOpenChange={(open) => {
if (open) setShellMode('expanded')
}}
>
<div
ref={anchorRef}
aria-hidden="true"
className="pointer-events-none absolute bottom-0 left-0 h-0 w-full"
/>
{checklistMinimized && floatingChecklist}
{checklistMinimized && (
<PopoverTrigger
ref={restoreTriggerRef}
aria-label={t(($) => $['stepByStepTour.restore'])}
render={(props) => <MinimizedTourPill {...props} title={title} progress={progress} />}
/>
)}
{overlayVisible && (
<PopoverContent
placement="top-start"
@@ -751,6 +761,10 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
},
}}
popupClassName="overflow-visible rounded-none border-0 bg-transparent p-0 shadow-none"
popupProps={{
initialFocus: checklistCloseButtonRef,
finalFocus: restoreTriggerRef,
}}
>
{floatingChecklist}
</PopoverContent>