diff --git a/packages/dify-ui/src/dialog/index.tsx b/packages/dify-ui/src/dialog/index.tsx index d93f351d11d..a77a2ea2da0 100644 --- a/packages/dify-ui/src/dialog/index.tsx +++ b/packages/dify-ui/src/dialog/index.tsx @@ -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 = BaseDialog.Root.Props @@ -19,6 +20,7 @@ type DialogTriggerProps = BaseDialog.Trigger.Props type DialogTitleProps = BaseDialog.Title.Props type DialogDescriptionProps = BaseDialog.Description.Props type DialogPortalProps = BaseDialog.Portal.Props +type DialogCloseProps = BaseDialog.Close.Props type DialogBackdropProps = Omit & { 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, diff --git a/packages/dify-ui/src/popover/__tests__/index.spec.tsx b/packages/dify-ui/src/popover/__tests__/index.spec.tsx index 19593668878..fb69ffb2c32 100644 --- a/packages/dify-ui/src/popover/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/popover/__tests__/index.spec.tsx @@ -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(
{ui}
) @@ -19,10 +28,8 @@ describe('PopoverContent', () => { const screen = await renderWithSafeViewport( Open - + + Popover content , @@ -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( - Open - + Open + + Default popover Default content , ) - 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( - - Open - - Custom placement content - - , - ) - - 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( - - Open - - Popover body - - , - ) - - 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( + + Open + + + + Default anatomy 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( + + Open + + + + Anatomy popover + Anatomy content + + + + , + ) + + 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') + }) +}) diff --git a/packages/dify-ui/src/popover/index.tsx b/packages/dify-ui/src/popover/index.tsx index f3eb3b73204..7082046cf8b 100644 --- a/packages/dify-ui/src/popover/index.tsx +++ b/packages/dify-ui/src/popover/index.tsx @@ -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 = BasePopover.Root.Props +type PopoverArrowProps = BasePopover.Arrow.Props +type PopoverPortalProps = BasePopover.Portal.Props type PopoverHandle = BasePopover.Handle type PopoverTriggerProps = BasePopover.Trigger.Props type PopoverCloseProps = BasePopover.Close.Props type PopoverTitleProps = BasePopover.Title.Props type PopoverDescriptionProps = BasePopover.Description.Props +type PopoverPositionerProps = Omit & { + className?: string + placement?: Placement +} + +function PopoverPositioner({ + className, + placement = 'bottom', + sideOffset = 8, + alignOffset = 0, + ...props +}: PopoverPositionerProps) { + const { side, align } = parsePlacement(placement) + + return ( + + ) +} + +type PopoverPopupProps = Omit & { + className?: string +} + +function PopoverPopup({ className, ...props }: PopoverPopupProps) { + return ( + + ) +} + 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 + popupProps?: Omit } function PopoverContent({ @@ -42,52 +85,53 @@ function PopoverContent({ alignOffset = 0, className, popupClassName, - positionerProps, popupProps, }: PopoverContentProps) { - const { side, align } = parsePlacement(placement) - return ( - - + - {children} - - - + + + ) } 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, diff --git a/web/app/components/header/account-setting/__tests__/index.spec.tsx b/web/app/components/header/account-setting/__tests__/index.spec.tsx index c7f1d89c72b..681ded7772d 100644 --- a/web/app/components/header/account-setting/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/__tests__/index.spec.tsx @@ -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() }) diff --git a/web/app/components/header/account-setting/index.tsx b/web/app/components/header/account-setting/index.tsx index 48a4fccab33..e24f5f74162 100644 --- a/web/app/components/header/account-setting/index.tsx +++ b/web/app/components/header/account-setting/index.tsx @@ -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 ( - -
- $['operation.close'], { ns: 'common' })} - onClick={onCancelAction} - > - - -
ESC
-
+ $['settings.settings'], { ns: 'common' })} + closeButtonLabel={t(($) => $['operation.close'], { ns: 'common' })} + onClose={onCancelAction} + >
diff --git a/web/app/components/header/account-setting/menu-dialog.tsx b/web/app/components/header/account-setting/menu-dialog.tsx index c0226cf49cc..f59983ebe84 100644 --- a/web/app/components/header/account-setting/menu-dialog.tsx +++ b/web/app/components/header/account-setting/menu-dialog.tsx @@ -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 ( { - if (!open) close() + if (!open) onClose() }} > - - {children} - + + + + + {title} +
+ + + + } + /> +
+ ESC +
+
+
+ {children} +
+
+
+
) } diff --git a/web/app/components/integrations/modal.tsx b/web/app/components/integrations/modal.tsx index 5e4245faac7..1c2732fde65 100644 --- a/web/app/components/integrations/modal.tsx +++ b/web/app/components/integrations/modal.tsx @@ -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 ( - + $['settings.integrations'], { ns: 'common' })} + closeButtonLabel={t(($) => $['operation.close'], { ns: 'common' })} + onClose={onCancel} + >
-
- $['operation.close'], { ns: 'common' })} - onClick={onCancel} - > - - -
ESC
-
diff --git a/web/app/components/main-nav/__tests__/index.spec.tsx b/web/app/components/main-nav/__tests__/index.spec.tsx index 32fb5397720..ba07cefdafb 100644 --- a/web/app/components/main-nav/__tests__/index.spec.tsx +++ b/web/app/components/main-nav/__tests__/index.spec.tsx @@ -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 extra?: ReactNode educationStatus?: NonNullable[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 () => { diff --git a/web/app/components/main-nav/components/help-menu.tsx b/web/app/components/main-nav/components/help-menu.tsx index d32fe803949..96be18b50a7 100644 --- a/web/app/components/main-nav/components/help-menu.tsx +++ b/web/app/components/main-nav/components/help-menu.tsx @@ -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 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) <> $['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', diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index 94e49570e54..aecd6cade81 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -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(null) const navItems = useMemo( () => @@ -127,13 +128,16 @@ export function MainNav({ className }: MainNavProps) { )}
- +
- +
diff --git a/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx b/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx index 591586b674f..9719aae9b0f 100644 --- a/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx +++ b/web/app/components/step-by-step-tour/__tests__/mount.spec.tsx @@ -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) } const renderStepByStepTourMount = (searchParams = '') => { + const recoveryAnchorRef = createRef() const queryClient = createTestQueryClient() queryClient.setQueryData(mockStepByStepTour.stateQueryKey, mockStepByStepTour.state) queryClient.setQueryData( @@ -504,7 +506,12 @@ const renderStepByStepTourMount = (searchParams = '') => { return render( - +
+ + +
, { 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', diff --git a/web/app/components/step-by-step-tour/mount.tsx b/web/app/components/step-by-step-tour/mount.tsx index dedbd3da95f..ad7be6d2ea3 100644 --- a/web/app/components/step-by-step-tour/mount.tsx +++ b/web/app/components/step-by-step-tour/mount.tsx @@ -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 } -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 && ( - + - {floatingChecklist} - + }} + > + + {floatingChecklist} + + + )} )} - {skipRecoveryVisible && ( + {recoveryAnchorRef && ( $['stepByStepTour.skipRecovery.label'])} message={t(($) => $['stepByStepTour.skipRecovery.message'])} dismissLabel={t(($) => $['stepByStepTour.skipRecovery.dismiss'])} - onDismiss={() => setSkipRecoveryVisible(false)} + onOpenChange={setSkipRecoveryVisible} /> )}
@@ -784,47 +800,82 @@ export default function StepByStepTourMount({ className }: StepByStepTourMountPr } function SkipRecoveryPrompt({ + anchorRef, dismissLabel, label, message, - onDismiss, + onOpenChange, + open, }: { + anchorRef: RefObject dismissLabel: string label: string message: string - onDismiss: () => void + onOpenChange: (open: boolean) => void + open: boolean }) { const dismissRef = useRef(null) - - useEffect(() => { - dismissRef.current?.focus({ preventScroll: true }) - }, []) + const shouldRestoreFocusRef = useRef(false) return ( -
{ + shouldRestoreFocusRef.current = + !nextOpen && + (eventDetails.reason === 'close-press' || eventDetails.reason === 'escape-key') + onOpenChange(nextOpen) + }} > -

{message}

-
- -
- - -
+ { + 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]" + > +
+ {label} + + {message} + +
+ } + > + {dismissLabel} + +
+
+ + + + +
+ + + ) } diff --git a/web/app/components/workflow/block-selector/index.tsx b/web/app/components/workflow/block-selector/index.tsx index 1d636c28ee4..70afbb0180c 100644 --- a/web/app/components/workflow/block-selector/index.tsx +++ b/web/app/components/workflow/block-selector/index.tsx @@ -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 ( {triggerWithTooltip} - - - {t(($) => $['common.addBlock'], { ns: 'workflow' })} - -
+ - handleOpenChange(false)} - availableBlocksTypes={availableBlocksTypes} - dataSources={dataSources} - noBlocks={noBlocks} - noTools={noTools} - showStartTab={showStartTab} - defaultActiveTab={defaultActiveTab} - ignoreNodeIds={ignoreNodeIds} - forceEnableStartTab={forceEnableStartTab} - allowUserInputSelection={allowUserInputSelection} - snippetInsertPayload={snippetInsertPayload} - /> -
- - {t(($) => $['operation.close'], { ns: 'common' })} - -
+ + + {t(($) => $['common.addBlock'], { ns: 'workflow' })} + +
+ handleOpenChange(false)} + availableBlocksTypes={availableBlocksTypes} + dataSources={dataSources} + noBlocks={noBlocks} + noTools={noTools} + showStartTab={showStartTab} + defaultActiveTab={defaultActiveTab} + ignoreNodeIds={ignoreNodeIds} + forceEnableStartTab={forceEnableStartTab} + allowUserInputSelection={allowUserInputSelection} + snippetInsertPayload={snippetInsertPayload} + /> +
+ + {t(($) => $['operation.close'], { ns: 'common' })} + +
+ +
) } diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx index eaddb91b244..8eaed0d8c3a 100644 --- a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx +++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx @@ -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} /> - - - + + + + + + + )}