fix(web): prevent Safari clipping tour recovery prompt (#41060)

This commit is contained in:
yyh
2026-08-21 09:19:38 +00:00
committed by GitHub
parent b38d907ede
commit faca558bf5
14 changed files with 473 additions and 290 deletions
+4
View File
@@ -11,6 +11,7 @@ const DialogTrigger = BaseDialog.Trigger
const DialogTitle = BaseDialog.Title
const DialogDescription = BaseDialog.Description
const DialogPortal = BaseDialog.Portal
const DialogClose = BaseDialog.Close
const createDialogHandle = BaseDialog.createHandle
type DialogProps<Payload = unknown> = BaseDialog.Root.Props<Payload>
@@ -19,6 +20,7 @@ type DialogTriggerProps<Payload = unknown> = BaseDialog.Trigger.Props<Payload>
type DialogTitleProps = BaseDialog.Title.Props
type DialogDescriptionProps = BaseDialog.Description.Props
type DialogPortalProps = BaseDialog.Portal.Props
type DialogCloseProps = BaseDialog.Close.Props
type DialogBackdropProps = Omit<BaseDialog.Backdrop.Props, 'className'> & {
className?: string
@@ -109,6 +111,7 @@ export {
createDialogHandle,
Dialog,
DialogBackdrop,
DialogClose,
DialogCloseButton,
DialogContent,
DialogDescription,
@@ -122,6 +125,7 @@ export {
export type {
DialogBackdropProps,
DialogCloseButtonProps,
DialogCloseProps,
DialogContentProps,
DialogDescriptionProps,
DialogHandle,
@@ -1,7 +1,16 @@
import type * as React from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { Popover, PopoverContent, PopoverTrigger } from '..'
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverPopup,
PopoverPortal,
PopoverPositioner,
PopoverTitle,
PopoverTrigger,
} from '..'
const renderWithSafeViewport = (ui: React.ReactNode) =>
render(<div style={{ minHeight: '100vh', minWidth: '100vw', padding: '240px' }}>{ui}</div>)
@@ -19,10 +28,8 @@ describe('PopoverContent', () => {
const screen = await renderWithSafeViewport(
<Popover>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverContent
popupClassName="duration-[30s]"
popupProps={{ role: 'dialog', 'aria-label': 'popover content' }}
>
<PopoverContent popupClassName="duration-[30s]">
<PopoverTitle>Popover content</PopoverTitle>
<button type="button">Focusable content</button>
</PopoverContent>
</Popover>,
@@ -38,98 +45,88 @@ describe('PopoverContent', () => {
await userEvent.keyboard('{Escape}')
await expect.element(trigger).toHaveFocus()
expect(trigger.element().matches(':focus-visible')).toBe(true)
} finally {
animationSettings.BASE_UI_ANIMATIONS_DISABLED = animationsDisabled
}
})
})
describe('Placement', () => {
it('should use bottom placement and default offsets when placement props are not provided', async () => {
describe('Surface', () => {
it('should provide the default popover surface', async () => {
const screen = await renderWithSafeViewport(
<Popover open>
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
<PopoverContent
positionerProps={{ role: 'group', 'aria-label': 'default positioner' }}
popupProps={{ role: 'dialog', 'aria-label': 'default popover' }}
>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverContent>
<PopoverTitle>Default popover</PopoverTitle>
<span>Default content</span>
</PopoverContent>
</Popover>,
)
await expect
.element(screen.getByRole('group', { name: 'default positioner' }))
.toHaveAttribute('data-side', 'bottom')
await expect
.element(screen.getByRole('group', { name: 'default positioner' }))
.toHaveAttribute('data-align', 'center')
await expect
.element(screen.getByRole('dialog', { name: 'default popover' }))
.toHaveTextContent('Default content')
})
it('should apply parsed custom placement and custom offsets when placement props are provided', async () => {
const screen = await renderWithSafeViewport(
<Popover open>
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
<PopoverContent
placement="top-end"
sideOffset={14}
alignOffset={6}
positionerProps={{ role: 'group', 'aria-label': 'custom positioner' }}
popupProps={{ role: 'dialog', 'aria-label': 'custom popover' }}
>
<span>Custom placement content</span>
</PopoverContent>
</Popover>,
)
await expect
.element(screen.getByRole('group', { name: 'custom positioner' }))
.toHaveAttribute('data-side', 'top')
await expect
.element(screen.getByRole('group', { name: 'custom positioner' }))
.toHaveAttribute('data-align', 'end')
await expect
.element(screen.getByRole('dialog', { name: 'custom popover' }))
.toHaveTextContent('Custom placement content')
})
})
describe('Passthrough props', () => {
it('should forward positionerProps and popupProps when passthrough props are provided', async () => {
const onPopupClick = vi.fn()
const screen = await render(
<Popover open>
<PopoverTrigger aria-label="popover trigger">Open</PopoverTrigger>
<PopoverContent
positionerProps={{
role: 'group',
'aria-label': 'popover positioner',
id: 'popover-positioner-id',
}}
popupProps={{
id: 'popover-popup-id',
role: 'dialog',
'aria-label': 'popover content',
onClick: onPopupClick,
}}
>
<span>Popover body</span>
</PopoverContent>
</Popover>,
)
const popup = screen.getByRole('dialog', { name: 'popover content' })
await popup.click()
await expect
.element(screen.getByRole('group', { name: 'popover positioner' }))
.toHaveAttribute('id', 'popover-positioner-id')
await expect.element(popup).toHaveAttribute('id', 'popover-popup-id')
expect(onPopupClick).toHaveBeenCalledTimes(1)
const popup = screen.getByRole('dialog', { name: 'default popover' })
await expect.element(popup).toHaveTextContent('Default content')
const popupStyle = getComputedStyle(popup.element())
expect(popupStyle.borderTopWidth).not.toBe('0px')
expect(popupStyle.borderTopLeftRadius).not.toBe('0px')
expect(popupStyle.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
expect(popupStyle.boxShadow).not.toBe('none')
})
})
})
describe('Popover anatomy', () => {
it('should use the default positioner placement', async () => {
const screen = await renderWithSafeViewport(
<Popover open>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverPortal>
<PopoverPositioner data-testid="default-positioner">
<PopoverPopup>
<PopoverTitle>Default anatomy popover</PopoverTitle>
</PopoverPopup>
</PopoverPositioner>
</PopoverPortal>
</Popover>,
)
await expect
.element(screen.getByTestId('default-positioner'))
.toHaveAttribute('data-side', 'bottom')
await expect
.element(screen.getByTestId('default-positioner'))
.toHaveAttribute('data-align', 'center')
})
it('should compose the portal, positioner, and popup directly', async () => {
const screen = await renderWithSafeViewport(
<Popover open>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverPortal>
<PopoverPositioner placement="top-end" data-testid="anatomy-positioner">
<PopoverPopup>
<PopoverTitle>Anatomy popover</PopoverTitle>
<PopoverDescription>Anatomy content</PopoverDescription>
</PopoverPopup>
</PopoverPositioner>
</PopoverPortal>
</Popover>,
)
await expect
.element(screen.getByTestId('anatomy-positioner'))
.toHaveAttribute('data-side', 'top')
await expect
.element(screen.getByTestId('anatomy-positioner'))
.toHaveAttribute('data-align', 'end')
const popup = screen.getByRole('dialog', { name: 'Anatomy popover' })
await expect.element(popup).toHaveTextContent('Anatomy content')
const popupStyle = getComputedStyle(popup.element())
expect(popupStyle.borderTopWidth).toBe('0px')
expect(popupStyle.borderTopLeftRadius).toBe('0px')
expect(popupStyle.backgroundColor).toBe('rgba(0, 0, 0, 0)')
expect(popupStyle.boxShadow).toBe('none')
expect(popupStyle.paddingTop).toBe('0px')
expect(popupStyle.overflow).toBe('visible')
})
})
+64 -20
View File
@@ -8,6 +8,8 @@ import { floatingPopupAnimationClassName } from '../overlay-shared'
import { parsePlacement } from '../placement'
const Popover = BasePopover.Root
const PopoverArrow = BasePopover.Arrow
const PopoverPortal = BasePopover.Portal
const PopoverTrigger = BasePopover.Trigger
const PopoverClose = BasePopover.Close
const PopoverTitle = BasePopover.Title
@@ -15,12 +17,57 @@ const PopoverDescription = BasePopover.Description
const createPopoverHandle = BasePopover.createHandle
type PopoverProps<Payload = unknown> = BasePopover.Root.Props<Payload>
type PopoverArrowProps = BasePopover.Arrow.Props
type PopoverPortalProps = BasePopover.Portal.Props
type PopoverHandle<Payload = unknown> = BasePopover.Handle<Payload>
type PopoverTriggerProps<Payload = unknown> = BasePopover.Trigger.Props<Payload>
type PopoverCloseProps = BasePopover.Close.Props
type PopoverTitleProps = BasePopover.Title.Props
type PopoverDescriptionProps = BasePopover.Description.Props
type PopoverPositionerProps = Omit<BasePopover.Positioner.Props, 'className' | 'side' | 'align'> & {
className?: string
placement?: Placement
}
function PopoverPositioner({
className,
placement = 'bottom',
sideOffset = 8,
alignOffset = 0,
...props
}: PopoverPositionerProps) {
const { side, align } = parsePlacement(placement)
return (
<BasePopover.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-hidden', className)}
{...props}
/>
)
}
type PopoverPopupProps = Omit<BasePopover.Popup.Props, 'className'> & {
className?: string
}
function PopoverPopup({ className, ...props }: PopoverPopupProps) {
return (
<BasePopover.Popup
className={cn(
'outline-hidden focus:outline-hidden focus-visible:outline-hidden',
floatingPopupAnimationClassName,
className,
)}
{...props}
/>
)
}
type PopoverContentProps = {
children: React.ReactNode
placement?: Placement
@@ -28,11 +75,7 @@ type PopoverContentProps = {
alignOffset?: number
className?: string
popupClassName?: string
positionerProps?: Omit<
BasePopover.Positioner.Props,
'children' | 'className' | 'side' | 'align' | 'sideOffset' | 'alignOffset'
>
popupProps?: Omit<BasePopover.Popup.Props, 'children' | 'className'>
popupProps?: Omit<PopoverPopupProps, 'children' | 'className'>
}
function PopoverContent({
@@ -42,52 +85,53 @@ function PopoverContent({
alignOffset = 0,
className,
popupClassName,
positionerProps,
popupProps,
}: PopoverContentProps) {
const { side, align } = parsePlacement(placement)
return (
<BasePopover.Portal>
<BasePopover.Positioner
side={side}
align={align}
<PopoverPortal>
<PopoverPositioner
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn('z-50 outline-hidden', className)}
{...positionerProps}
className={className}
>
<BasePopover.Popup
<PopoverPopup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
'outline-hidden focus:outline-hidden focus-visible:outline-hidden',
floatingPopupAnimationClassName,
popupClassName,
)}
{...popupProps}
>
{children}
</BasePopover.Popup>
</BasePopover.Positioner>
</BasePopover.Portal>
</PopoverPopup>
</PopoverPositioner>
</PopoverPortal>
)
}
export {
createPopoverHandle,
Popover,
PopoverArrow,
PopoverClose,
PopoverContent,
PopoverDescription,
PopoverPopup,
PopoverPortal,
PopoverPositioner,
PopoverTitle,
PopoverTrigger,
}
export type {
Placement,
PopoverArrowProps,
PopoverCloseProps,
PopoverContentProps,
PopoverDescriptionProps,
PopoverHandle,
PopoverPopupProps,
PopoverPortalProps,
PopoverPositionerProps,
PopoverProps,
PopoverTitleProps,
PopoverTriggerProps,
@@ -1,6 +1,7 @@
import type { AccountSettingTab } from '../constants'
import type { ConsoleStateFixture } from '@/test/console/state-fixture'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
@@ -226,7 +227,7 @@ describe('AccountSetting', () => {
renderAccountSetting()
// Assert
expect(screen.getByText('common.settings.settings'))!.toBeInTheDocument()
expect(screen.getByRole('dialog', { name: 'common.settings.settings' })).toBeInTheDocument()
expect(screen.getAllByText('common.settings.workspace').length).toBeGreaterThan(0)
expect(screen.queryByText('common.settings.provider'))!.not.toBeInTheDocument()
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
@@ -626,15 +627,13 @@ describe('AccountSetting', () => {
})
describe('Interactions', () => {
it('should call onCancel when clicking close button', () => {
// Act
it('should call onCancel when clicking close button', async () => {
const user = userEvent.setup()
renderAccountSetting()
const closeIcon = document.querySelector('.i-ri-close-line')
const closeButton = closeIcon?.closest('button')
expect(closeButton).not.toBeNull()
fireEvent.click(closeButton!)
const dialog = screen.getByRole('dialog', { name: 'common.settings.settings' })
await user.click(within(dialog).getByRole('button', { name: 'common.operation.close' }))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
@@ -1,7 +1,6 @@
'use client'
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
import { cn } from '@langgenius/dify-ui/cn'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
ScrollArea,
ScrollAreaContent,
@@ -171,18 +170,11 @@ export default function AccountSetting({
]
return (
<MenuDialog show onClose={onCancelAction}>
<div className="fixed top-6 right-6 z-20 flex shrink-0 flex-col items-center">
<IconButton
variant="tertiary"
size="xl"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
onClick={onCancelAction}
>
<span aria-hidden className="i-ri-close-line size-5" />
</IconButton>
<div className="mt-1 system-2xs-medium-uppercase text-text-tertiary">ESC</div>
</div>
<MenuDialog
title={t(($) => $['settings.settings'], { ns: 'common' })}
closeButtonLabel={t(($) => $['operation.close'], { ns: 'common' })}
onClose={onCancelAction}
>
<div className="flex h-screen w-full max-w-full pl-0 sm:pl-58">
<div className="flex w-11 shrink-0 flex-col pr-6 pl-4 sm:w-56">
<div className="mt-6 mb-8 flex h-9.5 items-center px-3 title-2xl-semi-bold whitespace-nowrap text-text-primary">
@@ -1,35 +1,53 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { useCallback } from 'react'
import {
Dialog,
DialogBackdrop,
DialogClose,
DialogPopup,
DialogPortal,
DialogTitle,
DialogViewport,
} from '@langgenius/dify-ui/dialog'
import { IconButton } from '@langgenius/dify-ui/icon-button'
type DialogProps = {
backdropClassName?: string
className?: string
type MenuDialogProps = {
children: ReactNode
show: boolean
onClose?: () => void
closeButtonLabel: string
title: string
onClose: () => void
}
const MenuDialog = ({ backdropClassName, className, children, show, onClose }: DialogProps) => {
const close = useCallback(() => onClose?.(), [onClose])
const MenuDialog = ({ children, closeButtonLabel, title, onClose }: MenuDialogProps) => {
return (
<Dialog
open={show}
open
onOpenChange={(open) => {
if (!open) close()
if (!open) onClose()
}}
>
<DialogContent
backdropClassName={cn('bg-transparent', backdropClassName)}
className={cn(
'top-0 left-0 h-full max-h-none w-full max-w-none translate-x-0 translate-y-0 scale-100 overflow-hidden rounded-none border-none bg-background-sidenav-bg p-0 shadow-none backdrop-blur-md transition-opacity data-ending-style:scale-100 data-starting-style:scale-100',
className,
)}
>
{children}
</DialogContent>
<DialogPortal>
<DialogBackdrop className="bg-transparent" />
<DialogViewport>
<DialogPopup className="pointer-events-none relative isolate h-full w-full scale-100 overflow-visible rounded-none border-none bg-transparent shadow-none transition-opacity data-ending-style:scale-100 data-starting-style:scale-100">
<DialogTitle className="sr-only">{title}</DialogTitle>
<div className="pointer-events-auto absolute top-6 right-6 z-10 flex shrink-0 flex-col items-center">
<DialogClose
render={
<IconButton variant="tertiary" size="xl" aria-label={closeButtonLabel}>
<span aria-hidden className="i-ri-close-line size-5" />
</IconButton>
}
/>
<div aria-hidden className="mt-1 system-2xs-medium-uppercase text-text-tertiary">
ESC
</div>
</div>
<div className="pointer-events-auto relative z-0 h-full w-full overflow-hidden bg-background-sidenav-bg backdrop-blur-md">
{children}
</div>
</DialogPopup>
</DialogViewport>
</DialogPortal>
</Dialog>
)
}
+5 -13
View File
@@ -1,7 +1,6 @@
'use client'
import type { IntegrationSection } from './routes'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import MenuDialog from '@/app/components/header/account-setting/menu-dialog'
@@ -29,7 +28,11 @@ export default function IntegrationsSettingModal({
}, [])
return (
<MenuDialog show onClose={onCancel}>
<MenuDialog
title={t(($) => $['settings.integrations'], { ns: 'common' })}
closeButtonLabel={t(($) => $['operation.close'], { ns: 'common' })}
onClose={onCancel}
>
<div className="mx-auto flex h-dvh w-[min(1440px,calc(100vw-48px))] shrink-0 py-6">
<div className="relative flex min-h-0 w-full shrink-0 overflow-hidden rounded-2xl border border-divider-subtle bg-components-panel-bg shadow-2xl">
<IntegrationsPage
@@ -37,17 +40,6 @@ export default function IntegrationsSettingModal({
onSectionChange={onSectionChange}
onSwitchToMarketplace={handleSwitchToMarketplace}
/>
<div className="fixed top-6 right-6 flex shrink-0 flex-col items-center">
<IconButton
variant="tertiary"
size="xl"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
onClick={onCancel}
>
<span aria-hidden className="i-ri-close-line h-5 w-5" />
</IconButton>
<div className="mt-1 system-2xs-medium-uppercase text-text-tertiary">ESC</div>
</div>
</div>
</div>
</MenuDialog>
@@ -24,7 +24,10 @@ import { DETAIL_SIDEBAR_STORAGE_KEY } from '@/app/components/detail-sidebar/stor
import { LEARN_DIFY_HIDDEN_STORAGE_KEY } from '@/app/components/explore/learn-dify/storage'
import { gotoAnythingDialogHandle } from '@/app/components/goto-anything/dialog-handle'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { stepByStepTourSessionAtom } from '@/app/components/step-by-step-tour/state'
import {
stepByStepTourSessionAtom,
stepByStepTourSkipRecoveryVisibleAtom,
} from '@/app/components/step-by-step-tour/state'
import { STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY } from '@/app/components/step-by-step-tour/storage'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
@@ -216,6 +219,10 @@ vi.mock('react-i18next', async () => {
'common.stepByStepTour.minimize': 'Minimize tour',
'common.stepByStepTour.restore': 'Open step-by-step tour',
'common.stepByStepTour.learnMore': 'Learn more',
'common.stepByStepTour.skipRecovery.label': 'Step-by-step Tour recovery tip',
'common.stepByStepTour.skipRecovery.message':
'Tour hidden. Turn it back on anytime in Help → Step-by-step Tour.',
'common.stepByStepTour.skipRecovery.dismiss': 'Got it',
'common.stepByStepTour.tasks.home.title': 'Try a Learn Dify lesson',
'common.stepByStepTour.tasks.home.description':
'Open a hands-on lesson from Learn Dify to see Dify in action.',
@@ -532,6 +539,7 @@ const renderMainNav = (
store?: ReturnType<typeof createStore>
extra?: ReactNode
educationStatus?: NonNullable<Parameters<typeof renderWithConsoleQuery>[1]>['educationStatus']
skipRecoveryVisible?: boolean
} = {},
) => {
const queryClient = createConsoleQueryClient()
@@ -566,6 +574,8 @@ const renderMainNav = (
seedRegisteredConsoleStateFixture(store)
store.set(queryClientAtom, queryClient)
store.set(stepByStepTourSessionAtom, mockStepByStepTour.uiState)
if (options.skipRecoveryVisible !== undefined)
store.set(stepByStepTourSkipRecoveryVisibleAtom, options.skipRecoveryVisible)
const resolvedSystemFeatures = {
...defaultMainNavSystemFeatures,
...systemFeatures,
@@ -977,6 +987,26 @@ describe('MainNav', () => {
expect(mockPush).not.toHaveBeenCalled()
})
it('keeps focus in the help menu when it dismisses the recovery prompt', async () => {
const user = userEvent.setup()
mockStepByStepTour.setState({ skipped: true })
renderMainNav(undefined, { skipRecoveryVisible: true })
expect(
await screen.findByRole('dialog', { name: 'Step-by-step Tour recovery tip' }),
).toBeInTheDocument()
const helpTrigger = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })
await user.click(helpTrigger)
expect(
screen.queryByRole('dialog', { name: 'Step-by-step Tour recovery tip' }),
).not.toBeInTheDocument()
await waitFor(() => {
expect(screen.getByRole('menu')).toHaveFocus()
})
})
it('shows Step-by-step Tour switch in help menu and stores the current workspace disable override', async () => {
const user = userEvent.setup()
renderMainNav({ enable_learn_app: true })
@@ -1181,11 +1211,14 @@ describe('MainNav', () => {
})
it('hides the help menu when branding is enabled', () => {
renderMainNav({ branding: { enabled: true } })
renderMainNav({ branding: { enabled: true } }, { skipRecoveryVisible: true })
expect(
screen.queryByRole('button', { name: 'common.mainNav.help.openMenu' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('dialog', { name: 'Step-by-step Tour recovery tip' }),
).not.toBeInTheDocument()
})
it('opens workspace settings, members, plan, and workspace switching actions', async () => {
@@ -1,7 +1,7 @@
'use client'
import type { IconButtonProps } from '@langgenius/dify-ui/icon-button'
import type { ReactElement } from 'react'
import type { ReactElement, Ref } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
@@ -56,6 +56,7 @@ import SupportMenu from './support-menu'
type HelpMenuProps = {
triggerIcon?: ReactElement
triggerClassName?: string
triggerRef?: Ref<HTMLButtonElement>
triggerSize?: IconButtonProps['size']
}
@@ -87,7 +88,7 @@ const MenuSwitchIndicator = ({ checked }: { checked: boolean }) => (
/>
)
const HelpMenu = ({ triggerIcon, triggerClassName, triggerSize }: HelpMenuProps) => {
const HelpMenu = ({ triggerIcon, triggerClassName, triggerRef, triggerSize }: HelpMenuProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
@@ -152,12 +153,14 @@ const HelpMenu = ({ triggerIcon, triggerClassName, triggerSize }: HelpMenuProps)
<>
<DropdownMenu onOpenChange={handleOpenChange}>
<DropdownMenuTrigger
ref={triggerRef}
data-learn-dify-help-target
render={
<IconButton
size={triggerSize ?? 'lg'}
aria-label={t(($) => $['mainNav.help.openMenu'], { ns: 'common' })}
className={cn(
'focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-state-accent-solid focus-visible:outline-solid',
usesDefaultTrigger && [
'rounded-full border border-components-card-border bg-components-card-bg text-text-tertiary shadow-xs transition-colors hover:bg-components-card-bg-alt hover:text-saas-dify-blue-inverted',
!triggerSize && 'size-7 p-0',
+7 -3
View File
@@ -4,7 +4,7 @@ import type { MainNavItem, MainNavProps } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useMemo } from 'react'
import { useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
@@ -39,6 +39,7 @@ export function MainNav({ className }: MainNavProps) {
const agentV2Enabled = isAgentV2Enabled()
const canManageAgents = useCanManageAgents()
const showEnvTag = currentEnv === 'TESTING' || currentEnv === 'DEVELOPMENT'
const helpMenuTriggerRef = useRef<HTMLButtonElement>(null)
const navItems = useMemo<MainNavItem[]>(
() =>
@@ -127,13 +128,16 @@ export function MainNav({ className }: MainNavProps) {
)}
</div>
<div className="isolate w-60 shrink-0">
<StepByStepTourMount className="relative z-1 -mb-1 ml-2.5 h-8 w-45.75 overflow-visible" />
<StepByStepTourMount
recoveryAnchorRef={systemFeatures.branding.enabled ? undefined : helpMenuTriggerRef}
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 />
</div>
<div className="flex shrink-0 items-center justify-center rounded-full p-1">
<HelpMenu />
<HelpMenu triggerRef={helpMenuTriggerRef} />
</div>
</div>
</div>
@@ -10,6 +10,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { createRef } from 'react'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture'
import { createSystemFeaturesFixture } from '@/test/console/system-features'
@@ -485,6 +486,7 @@ const setStepByStepTourTestState = (state: Partial<StepByStepTourFixtureState>)
}
const renderStepByStepTourMount = (searchParams = '') => {
const recoveryAnchorRef = createRef<HTMLButtonElement>()
const queryClient = createTestQueryClient()
queryClient.setQueryData(mockStepByStepTour.stateQueryKey, mockStepByStepTour.state)
queryClient.setQueryData(
@@ -504,7 +506,12 @@ const renderStepByStepTourMount = (searchParams = '') => {
return render(
<JotaiProvider store={jotaiStore}>
<QueryClientProvider client={queryClient}>
<StepByStepTourMount />
<div data-testid="step-by-step-tour-clip-boundary" style={{ overflow: 'hidden' }}>
<StepByStepTourMount recoveryAnchorRef={recoveryAnchorRef} />
<button ref={recoveryAnchorRef} type="button">
Open help menu
</button>
</div>
</QueryClientProvider>
</JotaiProvider>,
{ wrapper },
@@ -592,18 +599,41 @@ describe('StepByStepTourMount', () => {
expect(screen.queryByRole('region', { name: 'Get to know Dify' })).not.toBeInTheDocument()
})
expect(
screen.getByRole('region', { name: 'Step-by-step Tour recovery tip' }),
screen.getByRole('dialog', { name: 'Step-by-step Tour recovery tip' }),
).toBeInTheDocument()
expect(
screen.getByText('Tour hidden. Turn it back on anytime in Help → Step-by-step Tour.'),
).toBeInTheDocument()
expect(screen.getByTestId('step-by-step-tour-clip-boundary')).not.toContainElement(
screen.getByRole('dialog', { name: 'Step-by-step Tour recovery tip' }),
)
await expectStepByStepTourPatch({ action: 'skip' })
await user.click(screen.getByRole('button', { name: 'Got it' }))
const dismissButton = screen.getByRole('button', { name: 'Got it' })
await waitFor(() => {
expect(dismissButton).toHaveFocus()
})
await user.click(dismissButton)
expect(
screen.queryByRole('region', { name: 'Step-by-step Tour recovery tip' }),
screen.queryByRole('dialog', { name: 'Step-by-step Tour recovery tip' }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Open help menu' })).toHaveFocus()
})
it('returns keyboard focus to Help after dismissing the recovery hint with Escape', async () => {
renderStepByStepTourMount()
await user.click(await screen.findByRole('button', { name: 'Skip tour' }))
await screen.findByRole('dialog', { name: 'Step-by-step Tour recovery tip' })
await user.keyboard('{Escape}')
expect(
screen.queryByRole('dialog', { name: 'Step-by-step Tour recovery tip' }),
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Open help menu' })).toHaveFocus()
})
it('restores the checklist after Skip fails and allows retry', async () => {
@@ -616,7 +646,7 @@ describe('StepByStepTourMount', () => {
await waitFor(() => {
expect(mockStepByStepTour.patchState).toHaveBeenCalledTimes(1)
expect(
screen.getByRole('region', { name: 'Step-by-step Tour recovery tip' }),
screen.getByRole('dialog', { name: 'Step-by-step Tour recovery tip' }),
).toBeInTheDocument()
})
deferred.reject(new Error('patch failed'))
@@ -624,7 +654,7 @@ describe('StepByStepTourMount', () => {
await waitFor(() => {
expect(screen.getByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument()
expect(
screen.queryByRole('region', { name: 'Step-by-step Tour recovery tip' }),
screen.queryByRole('dialog', { name: 'Step-by-step Tour recovery tip' }),
).not.toBeInTheDocument()
})
expect(mockTrackEvent).not.toHaveBeenCalledWith(
@@ -637,7 +667,7 @@ describe('StepByStepTourMount', () => {
await waitFor(() => {
expect(mockStepByStepTour.patchState).toHaveBeenCalledTimes(2)
expect(
screen.getByRole('region', { name: 'Step-by-step Tour recovery tip' }),
screen.getByRole('dialog', { name: 'Step-by-step Tour recovery tip' }),
).toBeInTheDocument()
})
})
@@ -1498,7 +1528,7 @@ describe('StepByStepTourMount', () => {
expect(localStorage.getItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY)).toBe('expanded')
expect(screen.getByRole('region', { name: 'Get to know Dify' })).toBeInTheDocument()
expect(
screen.queryByRole('region', { name: 'Step-by-step Tour recovery tip' }),
screen.queryByRole('dialog', { name: 'Step-by-step Tour recovery tip' }),
).not.toBeInTheDocument()
expect(mockTrackEvent).toHaveBeenCalledWith('step_tour', {
action: 'guide_skipped',
+103 -52
View File
@@ -1,5 +1,6 @@
'use client'
import type { RefObject } from 'react'
import type { StepByStepTourGuide } from './target-registry'
import type {
StepByStepTourGuideGroup,
@@ -8,7 +9,17 @@ import type {
} from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
Popover,
PopoverArrow,
PopoverClose,
PopoverDescription,
PopoverPopup,
PopoverPortal,
PopoverPositioner,
PopoverTitle,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
@@ -125,9 +136,13 @@ const getActiveGuideIndexes = (
type StepByStepTourMountProps = {
className?: string
recoveryAnchorRef?: RefObject<HTMLButtonElement | null>
}
export default function StepByStepTourMount({ className }: StepByStepTourMountProps) {
export default function StepByStepTourMount({
className,
recoveryAnchorRef,
}: StepByStepTourMountProps) {
const router = useRouter()
const pathname = usePathname()
const docLink = useDocLink()
@@ -418,7 +433,9 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
previousSkippedRef.current = skipped
}, [skipped])
if (!visible && !skipRecoveryVisible) return null
const recoveryVisible = Boolean(recoveryAnchorRef) && skipRecoveryVisible
if (!visible && !recoveryVisible) return null
const title = t(($) => $['stepByStepTour.title'])
const taskCopy: Record<
StepByStepTourTaskId,
@@ -490,7 +507,7 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
},
})
setChecklistExiting(false)
setSkipRecoveryVisible(true)
if (recoveryAnchorRef) setSkipRecoveryVisible(true)
}, 160)
}
@@ -748,35 +765,34 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
/>
)}
{overlayVisible && (
<PopoverContent
placement="top-start"
sideOffset={0}
positionerProps={{
anchor: anchorRef,
collisionPadding: 8,
collisionAvoidance: {
<PopoverPortal>
<PopoverPositioner
placement="top-start"
sideOffset={0}
anchor={anchorRef}
collisionPadding={8}
collisionAvoidance={{
side: 'shift',
align: 'shift',
fallbackAxisSide: 'none',
},
}}
popupClassName="overflow-visible rounded-none border-0 bg-transparent p-0 shadow-none"
popupProps={{
initialFocus: checklistCloseButtonRef,
finalFocus: restoreTriggerRef,
}}
>
{floatingChecklist}
</PopoverContent>
}}
>
<PopoverPopup initialFocus={checklistCloseButtonRef} finalFocus={restoreTriggerRef}>
{floatingChecklist}
</PopoverPopup>
</PopoverPositioner>
</PopoverPortal>
)}
</Popover>
)}
{skipRecoveryVisible && (
{recoveryAnchorRef && (
<SkipRecoveryPrompt
open={recoveryVisible}
anchorRef={recoveryAnchorRef}
label={t(($) => $['stepByStepTour.skipRecovery.label'])}
message={t(($) => $['stepByStepTour.skipRecovery.message'])}
dismissLabel={t(($) => $['stepByStepTour.skipRecovery.dismiss'])}
onDismiss={() => setSkipRecoveryVisible(false)}
onOpenChange={setSkipRecoveryVisible}
/>
)}
</div>
@@ -784,47 +800,82 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr
}
function SkipRecoveryPrompt({
anchorRef,
dismissLabel,
label,
message,
onDismiss,
onOpenChange,
open,
}: {
anchorRef: RefObject<HTMLButtonElement | null>
dismissLabel: string
label: string
message: string
onDismiss: () => void
onOpenChange: (open: boolean) => void
open: boolean
}) {
const dismissRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
dismissRef.current?.focus({ preventScroll: true })
}, [])
const shouldRestoreFocusRef = useRef(false)
return (
<section
aria-label={label}
className="fixed bottom-19 left-1.5 z-50 flex w-65 max-w-[calc(100vw-12px)] flex-col gap-1 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-[10px]"
<Popover
open={open}
onOpenChange={(nextOpen, eventDetails) => {
shouldRestoreFocusRef.current =
!nextOpen &&
(eventDetails.reason === 'close-press' || eventDetails.reason === 'escape-key')
onOpenChange(nextOpen)
}}
>
<p className="system-sm-regular text-text-secondary">{message}</p>
<div className="flex h-12 items-end justify-end pt-4">
<Button
ref={dismissRef}
variant="primary"
size="medium"
className="w-20"
onClick={onDismiss}
<PopoverPortal>
<PopoverPositioner
placement="top-end"
sideOffset={28}
anchor={anchorRef}
arrowPadding={8}
collisionPadding={6}
collisionAvoidance={{
side: 'shift',
align: 'shift',
fallbackAxisSide: 'none',
}}
>
{dismissLabel}
</Button>
</div>
<span
aria-hidden
className="absolute top-full left-53.5 h-7 w-0.5 bg-state-accent-hover-alt"
/>
<span
aria-hidden
className="absolute top-[calc(100%+22px)] left-52.25 size-3 rounded-full border-2 border-state-accent-hover bg-state-accent-solid shadow-xs"
/>
</section>
<PopoverPopup
initialFocus={dismissRef}
finalFocus={() => {
const shouldRestoreFocus = shouldRestoreFocusRef.current
shouldRestoreFocusRef.current = false
return shouldRestoreFocus ? anchorRef.current : false
}}
className="w-65 max-w-[calc(100vw-12px)] 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-[10px]"
>
<div className="flex flex-col gap-1">
<PopoverTitle className="sr-only">{label}</PopoverTitle>
<PopoverDescription className="system-sm-regular text-text-secondary">
{message}
</PopoverDescription>
<div className="flex h-12 items-end justify-end pt-4">
<PopoverClose
ref={dismissRef}
render={<Button variant="primary" size="medium" className="w-20" />}
>
{dismissLabel}
</PopoverClose>
</div>
</div>
<PopoverArrow className="pointer-events-none -bottom-7 h-7 w-3">
<span
aria-hidden
className="absolute top-0 left-1/2 h-7 w-0.5 -translate-x-1/2 bg-state-accent-hover-alt"
/>
<span
aria-hidden
className="absolute top-5.5 left-1/2 size-3 -translate-x-1/2 rounded-full border-2 border-state-accent-hover bg-state-accent-solid shadow-xs"
/>
</PopoverArrow>
</PopoverPopup>
</PopoverPositioner>
</PopoverPortal>
</Popover>
)
}
@@ -13,7 +13,9 @@ import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
Popover,
PopoverClose,
PopoverContent,
PopoverPopup,
PopoverPortal,
PopoverPositioner,
PopoverTitle,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
@@ -154,49 +156,52 @@ function BlockSelector({
return (
<Popover modal="trap-focus" open={open} onOpenChange={handleOpenChange}>
{triggerWithTooltip}
<PopoverContent
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
positionerProps={{ positionMethod: 'fixed' }}
popupClassName="border-none bg-transparent shadow-none"
popupProps={{
initialFocus: searchInputRef,
onClick: handlePopupClick,
...(isolateKeyboardEvents ? { onKeyDown: handlePopupKeyDown } : {}),
}}
>
<PopoverTitle className="sr-only">
{t(($) => $['common.addBlock'], { ns: 'workflow' })}
</PopoverTitle>
<div
className={cn(
'w-100 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
popupClassName,
)}
<PopoverPortal>
<PopoverPositioner
placement={placement}
sideOffset={sideOffset}
alignOffset={alignOffset}
positionMethod="fixed"
>
<BlockSelectorContent
standalonePanel={standalonePanel}
searchInputRef={searchInputRef}
blocks={blocks}
onSelect={handleSelect}
onRequestClose={() => handleOpenChange(false)}
availableBlocksTypes={availableBlocksTypes}
dataSources={dataSources}
noBlocks={noBlocks}
noTools={noTools}
showStartTab={showStartTab}
defaultActiveTab={defaultActiveTab}
ignoreNodeIds={ignoreNodeIds}
forceEnableStartTab={forceEnableStartTab}
allowUserInputSelection={allowUserInputSelection}
snippetInsertPayload={snippetInsertPayload}
/>
</div>
<PopoverClose className="sr-only" tabIndex={-1}>
{t(($) => $['operation.close'], { ns: 'common' })}
</PopoverClose>
</PopoverContent>
<PopoverPopup
initialFocus={searchInputRef}
className="border-none bg-transparent shadow-none"
onClick={handlePopupClick}
onKeyDown={isolateKeyboardEvents ? handlePopupKeyDown : undefined}
>
<PopoverTitle className="sr-only">
{t(($) => $['common.addBlock'], { ns: 'workflow' })}
</PopoverTitle>
<div
className={cn(
'w-100 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg',
popupClassName,
)}
>
<BlockSelectorContent
standalonePanel={standalonePanel}
searchInputRef={searchInputRef}
blocks={blocks}
onSelect={handleSelect}
onRequestClose={() => handleOpenChange(false)}
availableBlocksTypes={availableBlocksTypes}
dataSources={dataSources}
noBlocks={noBlocks}
noTools={noTools}
showStartTab={showStartTab}
defaultActiveTab={defaultActiveTab}
ignoreNodeIds={ignoreNodeIds}
forceEnableStartTab={forceEnableStartTab}
allowUserInputSelection={allowUserInputSelection}
snippetInsertPayload={snippetInsertPayload}
/>
</div>
<PopoverClose className="sr-only" tabIndex={-1}>
{t(($) => $['operation.close'], { ns: 'common' })}
</PopoverClose>
</PopoverPopup>
</PopoverPositioner>
</PopoverPortal>
</Popover>
)
}
@@ -1,7 +1,12 @@
import type { Recipient as RecipientItem } from '../../../types'
import type { Member } from '@/models/common'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent } from '@langgenius/dify-ui/popover'
import {
Popover,
PopoverPopup,
PopoverPortal,
PopoverPositioner,
} from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -144,24 +149,30 @@ const EmailInput = ({ email, value, list, onDelete, onSelect, onAdd, disabled =
onChange={handleValueChange}
onKeyDown={handleKeyDown}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
alignOffset={-40}
popupClassName="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
popupProps={{ initialFocus: false, finalFocus: false }}
positionerProps={{ anchor: inputRef }}
>
<MemberList
searchValue={searchKey}
list={list}
value={value}
onSearchChange={setSearchKey}
onSelect={handleSelect}
email={email}
hideSearch
/>
</PopoverContent>
<PopoverPortal>
<PopoverPositioner
placement="bottom-start"
sideOffset={4}
alignOffset={-40}
anchor={inputRef}
>
<PopoverPopup
className="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
initialFocus={false}
finalFocus={false}
>
<MemberList
searchValue={searchKey}
list={list}
value={value}
onSearchChange={setSearchKey}
onSelect={handleSelect}
email={email}
hideSearch
/>
</PopoverPopup>
</PopoverPositioner>
</PopoverPortal>
</Popover>
)}
</div>