mirror of
https://github.com/langgenius/dify.git
synced 2026-08-24 12:32:54 -04:00
fix(web): prevent Safari clipping tour recovery prompt (#41060)
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+30
-19
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user