refactor(web): align Base UI state consumers (#39864)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-08-01 21:09:30 +08:00 committed by GitHub
parent eec0e2e44e
commit b6824334cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
137 changed files with 1393 additions and 753 deletions

View File

@ -1067,14 +1067,6 @@
"count": 2
}
},
"web/app/components/base/date-and-time-picker/time-picker/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/base/date-and-time-picker/time-picker/index.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@ -3342,14 +3334,6 @@
"count": 2
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/__tests__/selector-entry.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
}
},
"web/app/components/plugins/plugin-detail-panel/subscription-list/create/hooks/use-common-modal-state.ts": {
"erasable-syntax-only/enums": {
"count": 1

View File

@ -12,10 +12,19 @@ type DropdownMenuProps = {
onOpenChange?: (open: boolean) => void
}
type DropdownMenuTriggerProps = React.HTMLAttributes<HTMLElement> & {
type TriggerHtmlProps = React.HTMLAttributes<HTMLElement> & {
'data-testid'?: string
'data-disabled'?: string
'data-popup-open'?: string
}
type DropdownMenuTriggerProps = TriggerHtmlProps & {
children?: ReactNode
disabled?: boolean
nativeButton?: boolean
render?: React.ReactElement
render?:
| React.ReactElement
| ((props: TriggerHtmlProps, state: { open: boolean }) => React.ReactElement)
}
type DropdownMenuContentProps = React.HTMLAttributes<HTMLDivElement> & {
@ -51,16 +60,33 @@ export const DropdownMenuTrigger = ({
render,
nativeButton: _nativeButton,
onClick,
disabled,
...props
}: DropdownMenuTriggerProps) => {
const { open, onOpenChange } = React.useContext(DropdownMenuContext)
const node = render ?? children
const isNativeButton = React.isValidElement(node) && node.type === 'button'
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
if (disabled) return
onClick?.(event)
if (!event.defaultPrevented) onOpenChange(!open)
}
if (typeof render === 'function') {
return render(
{
...props,
'aria-disabled': disabled || undefined,
'data-testid': props['data-testid'] ?? 'dropdown-menu-trigger',
'data-disabled': disabled ? '' : undefined,
'data-popup-open': open ? '' : undefined,
onClick: handleClick,
},
{ open },
)
}
const node = render ?? children
const isNativeButton = React.isValidElement(node) && node.type === 'button'
if (React.isValidElement(node)) {
const triggerElement = node as React.ReactElement<Record<string, unknown>>
const childProps = (triggerElement.props ?? {}) as React.HTMLAttributes<HTMLElement> & {
@ -80,6 +106,10 @@ export const DropdownMenuTrigger = ({
...childProps,
'data-testid':
childProps['data-testid'] ?? triggerProps['data-testid'] ?? 'dropdown-menu-trigger',
'data-disabled': disabled ? '' : undefined,
'data-popup-open': open ? '' : undefined,
disabled: isNativeButton ? disabled : undefined,
'aria-disabled': !isNativeButton && disabled ? true : childProps['aria-disabled'],
role,
tabIndex:
childProps.tabIndex ?? triggerProps.tabIndex ?? (role === 'button' ? 0 : undefined),

View File

@ -12,10 +12,18 @@ type PopoverProps = {
onOpenChange?: (open: boolean) => void
}
type PopoverTriggerProps = React.HTMLAttributes<HTMLElement> & {
type TriggerHtmlProps = React.HTMLAttributes<HTMLElement> & {
'data-testid'?: string
'data-popover-trigger'?: string
'data-popup-open'?: string
}
type PopoverTriggerProps = TriggerHtmlProps & {
children?: ReactNode
nativeButton?: boolean
render?: React.ReactElement
render?:
| React.ReactElement
| ((props: TriggerHtmlProps, state: { open: boolean }) => React.ReactElement)
}
type PopoverContentProps = React.HTMLAttributes<HTMLDivElement> & {
@ -84,6 +92,21 @@ export const PopoverTrigger = ({
...props
}: PopoverTriggerProps) => {
const { open, onOpenChange } = React.useContext(PopoverContext)
if (typeof render === 'function') {
const triggerProps: TriggerHtmlProps = {
...props,
'data-testid': props['data-testid'] ?? 'popover-trigger',
'data-popover-trigger': 'true',
'data-popup-open': open ? '' : undefined,
onClick: (event: React.MouseEvent<HTMLElement>) => {
onClick?.(event)
if (event.defaultPrevented) return
onOpenChange(!open)
},
}
return render(triggerProps, { open })
}
const node = render ?? children
if (React.isValidElement(node)) {

View File

@ -1,7 +1,10 @@
'use client'
import type { Dayjs } from 'dayjs'
import type { FC } from 'react'
import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types'
import type {
DatePickerProps,
TriggerProps,
} from '@/app/components/base/date-and-time-picker/types'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCalendarLine } from '@remixicon/react'
import dayjs from 'dayjs'
@ -23,15 +26,19 @@ const today = dayjs()
const DatePicker: FC<Props> = ({ start, end, onStartChange, onEndChange }) => {
const locale = useLocale()
const renderDate = useCallback(
({ value, handleClickTrigger, isOpen }: TriggerProps) => {
const renderDate = useCallback<NonNullable<DatePickerProps['renderTrigger']>>(
(props, _state, { value, handleClickTrigger }: TriggerProps) => {
return (
<div
{...props}
className={cn(
'flex h-7 cursor-pointer items-center rounded-lg px-1 system-sm-regular text-components-input-text-filled hover:bg-state-base-hover',
isOpen && 'bg-state-base-hover',
'flex h-7 cursor-pointer items-center rounded-lg px-1 system-sm-regular text-components-input-text-filled hover:bg-state-base-hover data-popup-open:bg-state-base-hover',
props.className,
)}
onClick={handleClickTrigger}
onClick={(event) => {
handleClickTrigger(event)
props.onClick?.(event)
}}
>
{value ? formatToLocalTime(value, locale, 'MMM D') : ''}
</div>

View File

@ -10,7 +10,6 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -127,12 +126,7 @@ const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn(
'action-btn action-btn-m size-6 rounded-md text-text-tertiary',
open && 'bg-state-base-hover text-text-secondary',
)}
>
<DropdownMenuTrigger className="action-btn action-btn-m size-6 rounded-md text-text-tertiary data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary">
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-[180px] p-1">

View File

@ -61,7 +61,7 @@ describe('AccessControlItem', () => {
const organization = screen.getByRole('radio', { name: 'Organization Only' })
expect(organization).toHaveAttribute('aria-disabled', 'true')
expect(organization).toHaveClass('cursor-not-allowed')
expect(organization).toHaveAttribute('data-disabled')
await user.click(organization)

View File

@ -283,12 +283,18 @@ describe('AddMemberOrGroupDialog', () => {
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
const groupOption = screen.getByRole('option', { name: /Group One/ })
expect(groupOption).not.toHaveAttribute('data-selected')
fireEvent.click(groupOption)
expect(groupOption).toHaveAttribute('data-selected')
fireEvent.click(groupOption)
expect(groupOption).not.toHaveAttribute('data-selected')
const memberOption = screen.getByRole('option', { name: /Member One/ })
expect(memberOption).not.toHaveAttribute('data-selected')
fireEvent.click(memberOption)
expect(memberOption).toHaveAttribute('data-selected')
fireEvent.click(memberOption)
expect(memberOption).not.toHaveAttribute('data-selected')
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers'))

View File

@ -19,9 +19,7 @@ export default function AccessControlItem({ type, children, disabled }: AccessCo
'rounded-[10px] border-[0.5px] border-components-option-card-option-border bg-components-option-card-option-bg shadow-xs transition-colors',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
'data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:inset-ring-[0.5px] data-checked:inset-ring-components-option-card-option-selected-border',
disabled
? 'cursor-not-allowed opacity-60'
: 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover',
'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover data-disabled:cursor-not-allowed data-disabled:opacity-60 data-disabled:hover:border-components-option-card-option-border data-disabled:hover:bg-components-option-card-option-bg',
)}
>
{children}

View File

@ -297,19 +297,23 @@ function GroupItem({ group, subject }: GroupItemProps) {
return (
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
<BaseItem subject={subject}>
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<RiOrganizationChart
className="h-3.5 w-3.5 text-components-avatar-shape-fill-stop-0"
aria-hidden="true"
/>
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
</ComboboxItemText>
{(selected) => (
<>
<SelectionBox checked={selected} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<RiOrganizationChart
className="h-3.5 w-3.5 text-components-avatar-shape-fill-stop-0"
aria-hidden="true"
/>
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
</ComboboxItemText>
</>
)}
</BaseItem>
<Button
size="small"
@ -335,25 +339,27 @@ type MemberItemProps = {
function MemberItem({ member, subject }: MemberItemProps) {
const currentUser = useAtomValue(userProfileAtom)
const { t } = useTranslation()
const specificMembers = useAccessControlStore((s) => s.specificMembers)
const isChecked = specificMembers.some((m) => m.id === member.id)
return (
<BaseItem subject={subject} className="pr-3">
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
{currentUser.email === member.email && (
<span className="system-xs-regular text-text-tertiary">
({t(($) => $.you, { ns: 'common' })})
</span>
)}
</ComboboxItemText>
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
{(selected) => (
<>
<SelectionBox checked={selected} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
{currentUser.email === member.email && (
<span className="system-xs-regular text-text-tertiary">
({t(($) => $.you, { ns: 'common' })})
</span>
)}
</ComboboxItemText>
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
</>
)}
</BaseItem>
)
}
@ -361,7 +367,7 @@ function MemberItem({ member, subject }: MemberItemProps) {
type BaseItemProps = {
className?: string
subject: Subject
children: React.ReactNode
children: (selected: boolean) => React.ReactNode
}
function BaseItem({ children, className, subject }: BaseItemProps) {
return (
@ -371,9 +377,12 @@ function BaseItem({ children, className, subject }: BaseItemProps) {
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
className,
)}
>
{children}
</ComboboxItem>
render={(props, state) => (
<div {...props} className={props.className}>
{children(state.selected)}
</div>
)}
/>
)
}

View File

@ -1,4 +1,3 @@
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -30,12 +29,7 @@ const VersionSelector: React.FC<VersionSelectorProps> = ({ versionLen, value, on
<DropdownMenu>
<DropdownMenuTrigger
disabled={!moreThanOneVersion}
className={cn(
'flex items-center border-none bg-transparent p-0 system-xs-medium text-text-tertiary',
moreThanOneVersion
? 'cursor-pointer data-popup-open:text-text-secondary'
: 'cursor-default',
)}
className="flex cursor-pointer items-center border-none bg-transparent p-0 system-xs-medium text-text-tertiary data-disabled:cursor-default data-popup-open:text-text-secondary"
>
<div>
{t(($) => $['generate.version'], { ns: 'appDebug' })} {value + 1}

View File

@ -651,8 +651,7 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
}
disabled={isExporting}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed',
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed data-popup-open:bg-state-base-hover',
)}
onClick={(e) => {
e.stopPropagation()
@ -1291,10 +1290,7 @@ export function AppCard({
})
}
disabled={isExporting}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed',
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-not-allowed data-popup-open:bg-state-base-hover"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()

View File

@ -29,15 +29,16 @@ const MobileOperationDropdown = ({
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
render={
render={(props, state) => (
<ActionButton
{...props}
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
size="l"
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
state={state.open ? ActionButtonState.Hover : ActionButtonState.Default}
>
<div className="i-ri-more-fill h-4.5 w-4.5" aria-hidden="true" />
</ActionButton>
}
)}
/>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-[160px]">
<DropdownMenuItem

View File

@ -1,6 +1,5 @@
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { RiChatSettingsLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import InputsFormContent from '@/app/components/base/chat/chat-with-history/inputs-form/content'
@ -8,16 +7,18 @@ import { Message3Fill } from '@/app/components/base/icons/src/public/other'
const ViewFormDropdown = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover>
<PopoverTrigger
render={
<ActionButton size="l" state={open ? ActionButtonState.Hover : ActionButtonState.Default}>
render={(props, state) => (
<ActionButton
{...props}
size="l"
state={state.open ? ActionButtonState.Hover : ActionButtonState.Default}
>
<RiChatSettingsLine className="h-4.5 w-4.5" />
</ActionButton>
}
)}
/>
<PopoverContent
placement="bottom-end"

View File

@ -48,24 +48,26 @@ const Operation: FC<Props> = ({
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
render={
render={(props, state) => (
<ActionButton
{...props}
className={cn(
isItemHovering || open
isItemHovering || state.open
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0',
props.className,
)}
state={
isActive
? ActionButtonState.Active
: open
: state.open
? ActionButtonState.Hover
: ActionButtonState.Default
}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</ActionButton>
}
)}
onClick={(e) => e.stopPropagation()}
/>
<DropdownMenuContent

View File

@ -1,7 +1,6 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
@ -12,20 +11,19 @@ type Props = Readonly<{
const ViewFormDropdown = ({ iconColor }: Props) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<Popover open={open} onOpenChange={setOpen}>
<Popover>
<PopoverTrigger
render={
render={(props, state) => (
<ActionButton
{...props}
size="l"
state={open ? ActionButtonState.Hover : ActionButtonState.Default}
state={state.open ? ActionButtonState.Hover : ActionButtonState.Default}
data-testid="view-form-dropdown-trigger"
>
<div className={cn('i-ri-chat-settings-line h-4.5 w-4.5 shrink-0', iconColor)} />
</ActionButton>
}
)}
/>
<PopoverContent
placement="bottom-end"

View File

@ -400,8 +400,16 @@ describe('DatePicker', () => {
describe('Clear Behavior', () => {
it('should call onClear when clear is clicked while picker is closed', () => {
const onClear = vi.fn()
const renderTrigger = vi.fn(({ handleClear }) => (
<button data-testid="clear-trigger" onClick={handleClear}>
const renderTrigger = vi.fn((triggerProps, _state, { handleClear }) => (
<button
{...triggerProps}
data-testid="clear-trigger"
onClick={(event) => {
event.preventDefault()
handleClear(event)
triggerProps.onClick?.(event)
}}
>
Clear
</button>
))
@ -420,8 +428,8 @@ describe('DatePicker', () => {
it('should clear selected date without calling onClear when picker is open', () => {
const onClear = vi.fn()
const onChange = vi.fn()
const renderTrigger = vi.fn(({ handleClickTrigger, handleClear }) => (
<div>
const renderTrigger = vi.fn((triggerProps, _state, { handleClickTrigger, handleClear }) => (
<div {...triggerProps}>
<button data-testid="open-trigger" onClick={handleClickTrigger}>
Open
</button>
@ -576,8 +584,8 @@ describe('DatePicker', () => {
// Custom trigger
describe('Custom Trigger', () => {
it('should use renderTrigger when provided', () => {
const renderTrigger = vi.fn(({ handleClickTrigger }) => (
<button data-testid="custom-trigger" onClick={handleClickTrigger}>
const renderTrigger = vi.fn((triggerProps, _state, { handleClickTrigger }) => (
<button {...triggerProps} data-testid="custom-trigger" onClick={handleClickTrigger}>
Custom
</button>
))
@ -589,8 +597,8 @@ describe('DatePicker', () => {
})
it('should open picker when custom trigger is clicked', () => {
const renderTrigger = vi.fn(({ handleClickTrigger }) => (
<button data-testid="custom-trigger" onClick={handleClickTrigger}>
const renderTrigger = vi.fn((triggerProps, _state, { handleClickTrigger }) => (
<button {...triggerProps} data-testid="custom-trigger" onClick={handleClickTrigger}>
Custom
</button>
))
@ -602,6 +610,24 @@ describe('DatePicker', () => {
expect(screen.getAllByText(/daysInWeek/).length).toBeGreaterThan(0)
})
it('should expose Base UI trigger state and props to a custom trigger', () => {
const renderTrigger = vi.fn((triggerProps, state) => (
<button {...triggerProps} data-testid="state-trigger">
{state.open ? 'Open' : 'Closed'}
</button>
))
render(<DatePicker {...createDatePickerProps({ renderTrigger })} />)
expect(screen.getByTestId('state-trigger')).toHaveTextContent('Closed')
expect(screen.getByTestId('state-trigger')).not.toHaveAttribute('data-popup-open')
fireEvent.click(screen.getByTestId('state-trigger'))
expect(screen.getByTestId('state-trigger')).toHaveTextContent('Open')
expect(screen.getByTestId('state-trigger')).toHaveAttribute('data-popup-open')
})
})
// Disabled dates

View File

@ -223,42 +223,50 @@ const DatePicker = ({
: t(($) => $['dateFormats.display'], { ns: 'time' })
const displayValue = normalizedValue?.format(timeFormat) || ''
const displayTime = selectedDate?.format('hh:mm A') || '--:-- --'
const placeholderDate =
isOpen && selectedDate
? selectedDate.format(timeFormat)
: placeholder || t(($) => $.defaultPlaceholder, { ns: 'time' })
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger
nativeButton={false}
className={triggerWrapClassName}
render={
renderTrigger ? (
renderTrigger({
render={(props, state) => {
if (renderTrigger) {
return renderTrigger(props, state, {
value: normalizedValue,
selectedDate,
isOpen,
handleClear,
handleClickTrigger,
})
) : (
}
const placeholderDate =
state.open && selectedDate
? selectedDate.format(timeFormat)
: placeholder || t(($) => $.defaultPlaceholder, { ns: 'time' })
return (
<div
className="group flex w-63 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt"
onClick={handleClickTrigger}
{...props}
className={cn(
'group flex w-63 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
props.className,
)}
onClick={(event) => {
handleClickTrigger(event)
props.onClick?.(event)
}}
data-testid="date-picker-trigger"
>
<input
className="flex-1 cursor-pointer appearance-none truncate bg-transparent p-1 system-xs-regular text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder"
readOnly
value={isOpen ? '' : displayValue}
value={state.open ? '' : displayValue}
placeholder={placeholderDate}
/>
<span
className={cn(
'i-ri-calendar-line size-4 shrink-0 text-text-quaternary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
(displayValue || (isOpen && selectedDate)) && 'group-hover:hidden',
state.open ? 'text-text-secondary' : 'group-hover:text-text-secondary',
(displayValue || (state.open && selectedDate)) && 'group-hover:hidden',
)}
/>
<button
@ -266,7 +274,7 @@ const DatePicker = ({
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
className={cn(
'hidden size-4 shrink-0 border-none bg-transparent p-0 text-text-quaternary hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden',
(displayValue || (isOpen && selectedDate)) && 'group-hover:inline-block',
(displayValue || (state.open && selectedDate)) && 'group-hover:inline-block',
)}
onClick={handleClear}
>
@ -274,7 +282,7 @@ const DatePicker = ({
</button>
</div>
)
}
}}
/>
<PopoverContent
placement="bottom-end"

View File

@ -212,8 +212,8 @@ describe('TimePicker', () => {
})
it('should use renderTrigger when provided', () => {
const renderTrigger = vi.fn(({ inputElem, onClick }) => (
<div data-testid="custom-trigger" onClick={onClick}>
const renderTrigger = vi.fn((triggerProps, _state, { inputElem }) => (
<div {...triggerProps} data-testid="custom-trigger">
{inputElem}
</div>
))
@ -224,6 +224,25 @@ describe('TimePicker', () => {
expect(renderTrigger).toHaveBeenCalled()
})
it('should expose Base UI trigger state and props to a custom trigger', () => {
const renderTrigger = vi.fn((triggerProps, state, { inputElem }) => (
<button {...triggerProps} data-testid="state-trigger">
{state.open ? 'Open' : 'Closed'}
{inputElem}
</button>
))
render(<TimePicker {...baseProps} renderTrigger={renderTrigger} />)
expect(screen.getByTestId('state-trigger')).toHaveTextContent('Closed')
expect(screen.getByTestId('state-trigger')).not.toHaveAttribute('data-popup-open')
fireEvent.click(screen.getByTestId('state-trigger'))
expect(screen.getByTestId('state-trigger')).toHaveTextContent('Open')
expect(screen.getByTestId('state-trigger')).toHaveAttribute('data-popup-open')
})
it('should render with notClearable prop without errors', () => {
render(<TimePicker {...baseProps} notClearable={true} value="10:00 AM" timezone="UTC" />)

View File

@ -194,39 +194,43 @@ const TimePicker = ({
)
const displayValue = formatTimeValue(value)
const renderInput = (open: boolean) => {
const placeholderDate =
open && isDayjsObject(selectedTime)
? selectedTime.format(timeFormat)
: placeholder || t(($) => $.defaultPlaceholder, { ns: 'time' })
const placeholderDate =
isOpen && isDayjsObject(selectedTime)
? selectedTime.format(timeFormat)
: placeholder || t(($) => $.defaultPlaceholder, { ns: 'time' })
const inputElem = (
<input
className="flex-1 cursor-pointer appearance-none truncate bg-transparent p-1 system-xs-regular text-components-input-text-filled outline-hidden select-none placeholder:text-components-input-text-placeholder"
readOnly
value={isOpen ? '' : displayValue}
placeholder={placeholderDate}
/>
)
return (
<input
className="flex-1 cursor-pointer appearance-none truncate bg-transparent p-1 system-xs-regular text-components-input-text-filled outline-hidden select-none placeholder:text-components-input-text-placeholder"
readOnly
value={open ? '' : displayValue}
placeholder={placeholderDate}
/>
)
}
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger
nativeButton={false}
className={triggerFullWidth ? 'flex! w-full' : undefined}
render={
renderTrigger ? (
renderTrigger({
inputElem,
onClick: handleClickTrigger,
isOpen,
})
) : (
render={(props, state) => {
const inputElem = renderInput(state.open)
if (renderTrigger)
return renderTrigger(props, state, { inputElem, onClick: handleClickTrigger })
return (
<div
{...props}
className={cn(
'group flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
triggerFullWidth ? 'w-full min-w-0' : 'w-63',
props.className,
)}
onClick={handleClickTrigger}
onClick={(event) => {
handleClickTrigger(event)
props.onClick?.(event)
}}
data-testid="time-picker-trigger"
>
{inputElem}
@ -240,8 +244,8 @@ const TimePicker = ({
<span
className={cn(
'i-ri-time-line size-4 shrink-0 text-text-quaternary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
(displayValue || (isOpen && selectedTime)) &&
state.open ? 'text-text-secondary' : 'group-hover:text-text-secondary',
(displayValue || (state.open && selectedTime)) &&
!notClearable &&
'group-hover:hidden',
)}
@ -250,7 +254,7 @@ const TimePicker = ({
type="button"
className={cn(
'hidden size-4 shrink-0 border-none bg-transparent p-0 text-text-quaternary hover:text-text-secondary focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden',
(displayValue || (isOpen && selectedTime)) &&
(displayValue || (state.open && selectedTime)) &&
!notClearable &&
'group-hover:inline-block',
)}
@ -261,7 +265,7 @@ const TimePicker = ({
</button>
</div>
)
}
}}
/>
<PopoverContent
placement={placement}

View File

@ -1,4 +1,4 @@
import type { Placement } from '@langgenius/dify-ui/popover'
import type { Placement, PopoverTriggerProps } from '@langgenius/dify-ui/popover'
import type { Dayjs } from 'dayjs'
export enum ViewType {
@ -12,10 +12,13 @@ export enum Period {
PM = 'PM',
}
type PopoverTriggerRender = Exclude<NonNullable<PopoverTriggerProps['render']>, React.ReactElement>
type TriggerRenderProps = Parameters<PopoverTriggerRender>[0]
type TriggerState = Parameters<PopoverTriggerRender>[1]
export type TriggerProps = {
value: Dayjs | undefined
selectedDate: Dayjs | undefined
isOpen: boolean
handleClear: (e: React.MouseEvent) => void
handleClickTrigger: (e: React.MouseEvent) => void
}
@ -28,7 +31,11 @@ export type DatePickerProps = {
onChange: (date: Dayjs | undefined) => void
onClear: () => void
triggerWrapClassName?: string
renderTrigger?: (props: TriggerProps) => React.ReactElement
renderTrigger?: (
props: TriggerRenderProps,
state: TriggerState,
params: TriggerProps,
) => React.ReactElement
minuteFilter?: (minutes: string[]) => string[]
noConfirm?: boolean
getIsDateDisabled?: (date: Dayjs) => boolean
@ -51,7 +58,6 @@ export type DatePickerFooterProps = {
}
export type TriggerParams = {
isOpen: boolean
inputElem: React.ReactNode
onClick: (e: React.MouseEvent) => void
}
@ -61,7 +67,11 @@ export type TimePickerProps = {
placeholder?: string
onChange: (date: Dayjs | undefined) => void
onClear: () => void
renderTrigger?: (props: TriggerParams) => React.ReactElement
renderTrigger?: (
props: TriggerRenderProps,
state: TriggerState,
params: TriggerParams,
) => React.ReactElement
title?: string
minuteFilter?: (minutes: string[]) => string[]
popupClassName?: string

View File

@ -148,6 +148,26 @@ describe('FollowUpSettingModal', () => {
})
describe('Custom Prompt', () => {
it('should expose the selected prompt mode through RadioItem state attributes', async () => {
const user = userEvent.setup()
renderModal()
const defaultOption = screen
.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.defaultPromptOption')
.closest('button')!
const customOption = screen
.getByText('appDebug.feature.suggestedQuestionsAfterAnswer.modal.customPromptOption')
.closest('button')!
expect(defaultOption).toHaveAttribute('data-checked')
expect(customOption).not.toHaveAttribute('data-checked')
await user.click(customOption)
expect(defaultOption).not.toHaveAttribute('data-checked')
expect(customOption).toHaveAttribute('data-checked')
})
it('should enable custom prompt input and save the custom prompt when selected', async () => {
const user = userEvent.setup()
const { onSave } = renderModal()

View File

@ -2,7 +2,6 @@ import type { SuggestedQuestionsAfterAnswer } from '@/app/components/base/featur
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CompletionParams, Model, ModelModeType } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { Field, FieldItem } from '@langgenius/dify-ui/field'
import { Fieldset, FieldsetLegend } from '@langgenius/dify-ui/fieldset'
@ -158,12 +157,7 @@ const FollowUpSettingModal = ({ data, onSave, onCancel }: FollowUpSettingModalPr
value={PROMPT_MODE.default}
nativeButton
render={<button type="button" />}
className={cn(
'w-full rounded-xl border p-4 text-left transition-colors',
promptMode === PROMPT_MODE.default
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
)}
className="w-full rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-4 text-left transition-colors hover:bg-state-base-hover data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:hover:bg-components-option-card-option-selected-bg"
>
<div className="flex items-start justify-between gap-3">
<div>
@ -200,12 +194,7 @@ const FollowUpSettingModal = ({ data, onSave, onCancel }: FollowUpSettingModalPr
value={PROMPT_MODE.custom}
nativeButton
render={<button type="button" />}
className={cn(
'w-full rounded-xl border p-4 text-left transition-colors',
promptMode === PROMPT_MODE.custom
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg'
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:bg-state-base-hover',
)}
className="w-full rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-4 text-left transition-colors hover:bg-state-base-hover data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg data-checked:hover:bg-components-option-card-option-selected-bg"
>
<div className="flex items-start justify-between gap-3">
<div>

View File

@ -37,7 +37,11 @@ const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload =>
function renderAndOpen(props: Partial<React.ComponentProps<typeof FileFromLinkOrLocal>> = {}) {
const trigger =
props.trigger ??
((open: boolean) => <button data-testid="trigger">{open ? 'Close' : 'Open'}</button>)
((triggerProps, state) => (
<button {...triggerProps} data-testid="trigger">
{state.open ? 'Close' : 'Open'}
</button>
))
const result = render(
<FileContextProvider value={mockFiles}>
<FileFromLinkOrLocal
@ -58,10 +62,13 @@ describe('FileFromLinkOrLocal', () => {
})
it('should render trigger element', () => {
const trigger = (open: boolean) => (
<button data-testid="trigger">
const trigger: NonNullable<React.ComponentProps<typeof FileFromLinkOrLocal>['trigger']> = (
triggerProps,
state,
) => (
<button {...triggerProps} data-testid="trigger">
Open
{open ? 'close' : 'open'}
{state.open ? 'close' : 'open'}
</button>
)
render(
@ -173,8 +180,13 @@ describe('FileFromLinkOrLocal', () => {
})
it('should toggle open state when trigger is clicked', () => {
const trigger = (open: boolean) => (
<button data-testid="trigger">{open ? 'Close' : 'Open'}</button>
const trigger: NonNullable<React.ComponentProps<typeof FileFromLinkOrLocal>['trigger']> = (
triggerProps,
state,
) => (
<button {...triggerProps} data-testid="trigger">
{state.open ? 'Close' : 'Open'}
</button>
)
render(
<FileContextProvider value={mockFiles}>

View File

@ -1,3 +1,4 @@
import type { PopoverTriggerProps } from '@langgenius/dify-ui/popover'
import type { FileUpload } from '@/app/components/base/features/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@ -13,7 +14,7 @@ import { useStore } from '../store'
type FileFromLinkOrLocalProps = {
showFromLink?: boolean
showFromLocal?: boolean
trigger: (open: boolean) => React.ReactElement
trigger: NonNullable<PopoverTriggerProps['render']>
fileConfig: FileUpload
}
const FileFromLinkOrLocal = ({
@ -24,7 +25,6 @@ const FileFromLinkOrLocal = ({
}: FileFromLinkOrLocalProps) => {
const { t } = useTranslation()
const files = useStore((s) => s.files)
const [open, setOpen] = useState(false)
const [url, setUrl] = useState('')
const [showError, setShowError] = useState(false)
const { handleLoadFileFromLink } = useFile(fileConfig)
@ -48,8 +48,8 @@ const FileFromLinkOrLocal = ({
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger render={trigger(open)} />
<Popover>
<PopoverTrigger render={trigger} />
<PopoverContent
placement="top"
sideOffset={4}

View File

@ -39,13 +39,13 @@ const FileUploaderInAttachment = ({ isDisabled, fileConfig }: FileUploaderInAtta
]
const renderButton = useCallback(
(option: Option, open?: boolean) => {
(option: Option) => {
return (
<Button
variant="tertiary"
className={cn(
'relative w-full min-w-0',
open && 'bg-components-button-tertiary-bg-hover',
'data-popup-open:bg-components-button-tertiary-bg-hover',
)}
disabled={!!(fileConfig.number_limits && files.length >= fileConfig.number_limits)}
>
@ -57,12 +57,6 @@ const FileUploaderInAttachment = ({ isDisabled, fileConfig }: FileUploaderInAtta
},
[fileConfig, files.length],
)
const renderTrigger = useCallback(
(option: Option) => {
return (open: boolean) => renderButton(option, open)
},
[renderButton],
)
const renderOption = useCallback(
(option: Option) => {
if (
@ -84,14 +78,14 @@ const FileUploaderInAttachment = ({ isDisabled, fileConfig }: FileUploaderInAtta
<div key={option.value} className="min-w-0 flex-1">
<FileFromLinkOrLocal
showFromLocal={false}
trigger={renderTrigger(option)}
trigger={renderButton(option)}
fileConfig={fileConfig}
/>
</div>
)
}
},
[renderButton, renderTrigger, fileConfig],
[renderButton, fileConfig],
)
return (

View File

@ -11,35 +11,32 @@ type FileUploaderInChatInputProps = {
}
const FileUploaderInChatInput = ({ fileConfig, readonly }: FileUploaderInChatInputProps) => {
const { t } = useTranslation()
const renderTrigger = useCallback(
(_open: boolean) => {
return (
<button
type="button"
aria-label={t(($) => $['fileUploader.uploadFromComputer'], { ns: 'common' })}
className={cn(
'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg p-1.5 text-text-tertiary outline-hidden',
'hover:bg-state-base-hover hover:text-text-secondary',
'focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid',
'data-popup-open:bg-state-base-hover',
'disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled',
)}
disabled={readonly}
>
<span className="i-ri-attachment-line size-5" aria-hidden="true" />
</button>
)
},
[readonly, t],
)
const renderTrigger = useCallback(() => {
return (
<button
type="button"
aria-label={t(($) => $['fileUploader.uploadFromComputer'], { ns: 'common' })}
className={cn(
'inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-lg p-1.5 text-text-tertiary outline-hidden',
'hover:bg-state-base-hover hover:text-text-secondary',
'focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid',
'data-popup-open:bg-state-base-hover',
'disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled',
)}
disabled={readonly}
>
<span className="i-ri-attachment-line size-5" aria-hidden="true" />
</button>
)
}, [readonly, t])
return (
<span className="inline-flex size-8 shrink-0 items-center justify-center">
{readonly ? (
renderTrigger(false)
renderTrigger()
) : (
<FileFromLinkOrLocal
trigger={renderTrigger}
trigger={renderTrigger()}
fileConfig={fileConfig}
showFromLocal={fileConfig?.allowed_file_upload_methods?.includes(
TransferMethod.local_file,

View File

@ -155,7 +155,7 @@ describe('LanguageSelect', () => {
const trigger = screen.getByRole('combobox', { name: 'language' })
expect(trigger).toBeDisabled()
expect(trigger).toHaveClass('cursor-not-allowed')
expect(trigger).toHaveAttribute('data-disabled')
})
it('should not open the listbox when disabled', () => {

View File

@ -36,8 +36,7 @@ const LanguageSelect: FC<ILanguageSelectProps> = ({ currentLanguage, onSelect, d
aria-label="language"
className={cn(
'mx-1 w-auto shrink-0 bg-components-button-tertiary-bg text-components-button-tertiary-text hover:bg-components-button-tertiary-bg',
disabled &&
'cursor-not-allowed bg-components-button-tertiary-bg-disabled text-components-button-tertiary-text-disabled hover:bg-components-button-tertiary-bg-disabled',
'data-disabled:cursor-not-allowed data-disabled:bg-components-button-tertiary-bg-disabled data-disabled:text-components-button-tertiary-text-disabled data-disabled:hover:bg-components-button-tertiary-bg-disabled',
)}
>
<SelectValue placeholder={<span>&nbsp;</span>} />

View File

@ -101,8 +101,11 @@ describe('CredentialSelector', () => {
// Act - Click trigger to open dropdown
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(trigger.firstElementChild).toHaveClass('bg-state-base-hover')
// Assert - All credentials should be visible (current credential appears in both trigger and list)
// Assert - All credentials should be visible (current credential appears in both trigger and list)
expect(screen.getByTestId('popover-content'))!.toBeInTheDocument()

View File

@ -1,4 +1,5 @@
import type { DataSourceCredential } from '@/types/pipeline'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useBoolean } from 'ahooks'
import * as React from 'react'
@ -37,9 +38,14 @@ const CredentialSelector = ({
return (
<Popover open={open} onOpenChange={set}>
<PopoverTrigger nativeButton={false} render={<div className="grow overflow-hidden" />}>
<Trigger currentCredential={currentCredential} isOpen={open} />
</PopoverTrigger>
<PopoverTrigger
nativeButton={false}
render={(props, state) => (
<div {...props} className={cn('grow overflow-hidden', props.className)}>
<Trigger currentCredential={currentCredential} isOpen={state.open} />
</div>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}

View File

@ -13,7 +13,14 @@ describe('ApiAccess', () => {
const user = userEvent.setup()
render(<ApiAccess expand apiEnabled />)
await user.click(screen.getByRole('button', { name: 'common.appMenus.apiAccess' }))
const trigger = screen.getByRole('button', { name: 'common.appMenus.apiAccess' })
expect(trigger).not.toHaveAttribute('data-popup-open')
expect(trigger.firstElementChild).toHaveClass('hover:bg-state-base-hover')
await user.click(trigger)
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(trigger.firstElementChild).toHaveClass('bg-state-base-hover')
expect(screen.getByText('API enabled')).toBeInTheDocument()
})

View File

@ -20,13 +20,17 @@ const ApiAccess = ({ expand, apiEnabled }: ApiAccessProps) => {
<div className={cn(expand ? 'px-1 py-2' : 'flex justify-center px-3 py-2')}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<button type="button" className="w-full border-none bg-transparent p-0 text-left">
render={(props, state) => (
<button
{...props}
type="button"
className={cn('w-full border-none bg-transparent p-0 text-left', props.className)}
>
<div
className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
state.open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
@ -41,7 +45,7 @@ const ApiAccess = ({ expand, apiEnabled }: ApiAccessProps) => {
/>
</div>
</button>
}
)}
/>
<PopoverContent
placement="top-start"

View File

@ -30,7 +30,14 @@ describe('ServiceApi', () => {
const user = userEvent.setup()
render(<ServiceApi apiBaseUrl="https://api.example.com" />)
await user.click(screen.getByRole('button', { name: 'dataset.serviceApi.title' }))
const trigger = screen.getByRole('button', { name: 'dataset.serviceApi.title' })
expect(trigger).not.toHaveAttribute('data-popup-open')
expect(trigger.firstElementChild).toHaveClass('hover:bg-state-base-hover')
await user.click(trigger)
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(trigger.firstElementChild).toHaveClass('bg-state-base-hover')
await user.click(screen.getByRole('button', { name: 'dataset.serviceApi.card.apiKey' }))
expect(screen.getByText('secret key modal')).toBeInTheDocument()

View File

@ -33,12 +33,16 @@ const ServiceApi = ({ apiBaseUrl }: ServiceApiProps) => {
<div className="flex items-center">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<button type="button" className="w-full border-none bg-transparent p-0 text-left">
render={(props, state) => (
<button
{...props}
type="button"
className={cn('w-full border-none bg-transparent p-0 text-left', props.className)}
>
<div
className={cn(
'relative flex h-6 cursor-pointer items-center justify-center gap-1 overflow-hidden rounded-md px-1.5 py-1 text-text-tertiary',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
state.open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<StatusDot className={cn('shrink-0')} status={apiBaseUrl ? 'success' : 'warning'} />
@ -47,7 +51,7 @@ const ServiceApi = ({ apiBaseUrl }: ServiceApiProps) => {
</div>
</div>
</button>
}
)}
/>
<PopoverContent
placement="top-start"

View File

@ -11,16 +11,18 @@ type TriggerArgs = {
type DatePickerProps = {
onChange: (value: Date | null) => void
onClear: () => void
renderTrigger: (args: TriggerArgs) => React.ReactNode
renderTrigger: (
props: React.HTMLAttributes<HTMLDivElement>,
state: { open: boolean },
args: TriggerArgs,
) => React.ReactNode
value?: Date
}
// Mock the base date picker component
vi.mock('@/app/components/base/date-and-time-picker/date-picker', () => ({
default: ({ onChange, onClear, renderTrigger, value }: DatePickerProps) => {
const trigger = renderTrigger({
handleClickTrigger: () => {},
})
const trigger = renderTrigger({}, { open: false }, { handleClickTrigger: () => {} })
return (
<div role="group" aria-label="Date picker">
{trigger}

View File

@ -1,4 +1,7 @@
import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types'
import type {
DatePickerProps,
TriggerProps,
} from '@/app/components/base/date-and-time-picker/types'
import { cn } from '@langgenius/dify-ui/cn'
import { RiCalendarLine, RiCloseCircleFill } from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
@ -31,8 +34,8 @@ const WrappedDatePicker = ({ className, label, value, onChange }: Props) => {
[onChange],
)
const renderTrigger = useCallback(
({ handleClickTrigger }: TriggerProps) => {
const renderTrigger = useCallback<NonNullable<DatePickerProps['renderTrigger']>>(
(props, _state, { handleClickTrigger }: TriggerProps) => {
const hasValue = Boolean(value)
const triggerText = value
? formatTimestamp(
@ -44,9 +47,11 @@ const WrappedDatePicker = ({ className, label, value, onChange }: Props) => {
return (
<div
{...props}
className={cn(
'group flex items-center rounded-md bg-components-input-bg-normal',
className,
props.className,
)}
>
<button

View File

@ -62,8 +62,8 @@ const renderAccountDropdown = () => {
return renderWithConsoleQuery(
<AccountDropdown
trigger={({ isOpen, ariaLabel }) => (
<button type="button" aria-label={ariaLabel} data-open={isOpen}>
trigger={({ ariaLabel }) => (
<button type="button" aria-label={ariaLabel}>
Current account
</button>
)}
@ -120,12 +120,12 @@ describe('AccountDropdown', () => {
renderAccountDropdown()
const trigger = screen.getByRole('button', { name: 'common.account.account' })
expect(trigger).toHaveAttribute('data-open', 'false')
expect(trigger).not.toHaveAttribute('data-popup-open')
await user.click(trigger)
expect(await screen.findByText('current@example.com')).toBeInTheDocument()
expect(trigger).toHaveAttribute('data-open', 'true')
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByText('common.settings.preferences')).toBeInTheDocument()
expect(screen.getByText('common.account.appearanceLabel')).toBeInTheDocument()
})

View File

@ -14,7 +14,7 @@ import { useLogout } from '@/service/use-common'
import { MainNavMenuContent } from './main-nav-menu-content'
type AccountDropdownProps = {
trigger: (props: { isOpen: boolean; ariaLabel: string }) => ReactElement
trigger: (props: { ariaLabel: string }) => ReactElement
}
const mainNavMenuPopupClassName =
@ -50,7 +50,6 @@ export default function AccountDropdown({ trigger }: AccountDropdownProps) {
<DropdownMenuTrigger
disabled={isHydrating}
render={trigger({
isOpen: isAccountMenuOpen,
ariaLabel: t(($) => $['account.account'], { ns: 'common' }),
})}
/>

View File

@ -72,10 +72,28 @@ describe('WorkspaceRoleCheckboxList', () => {
/>,
)
expect(screen.getByRole('radio', { name: /First role/i })).toBeInTheDocument()
const selectedRole = screen.getByRole('radio', { name: /First role/i })
const unselectedRole = screen.getByRole('radio', { name: /Second role/i })
expect(selectedRole).toHaveAttribute('data-checked', '')
expect(unselectedRole).not.toHaveAttribute('data-checked')
expect(screen.queryByRole('checkbox', { name: /First role/i })).not.toBeInTheDocument()
})
it('should expose disabled state on single-role options', () => {
render(
<WorkspaceRoleCheckboxList
selectedRoleIds={['role-1']}
selectedRoles={[mockRoles[0]!]}
allowMultipleRoles={false}
disabledRoleIds={['role-1']}
onSelectedRolesChange={vi.fn()}
/>,
)
expect(screen.getByRole('radio', { name: /First role/i })).toHaveAttribute('data-disabled', '')
expect(screen.getByRole('radio', { name: /Second role/i })).not.toHaveAttribute('data-disabled')
})
it('should show legacy role descriptions when only one role is allowed', () => {
vi.mocked(useWorkspaceRoleList).mockReturnValue({
data: {

View File

@ -202,7 +202,7 @@ describe('AccessRuleSection', () => {
expect(onCreate).toHaveBeenCalledTimes(1)
})
it('should keep row actions when workspace role management is allowed', () => {
it('should keep row actions when workspace role management is allowed', async () => {
mocks.workspacePermissionKeys = ['workspace.role.manage']
renderWithQueryClient(
@ -215,7 +215,12 @@ describe('AccessRuleSection', () => {
/>,
)
expect(screen.getByRole('button', { name: 'common.operation.moreActions' })).toBeInTheDocument()
const trigger = screen.getByRole('button', { name: 'common.operation.moreActions' })
expect(trigger).not.toHaveAttribute('data-popup-open')
await userEvent.click(trigger)
expect(trigger).toHaveAttribute('data-popup-open', '')
})
it('should hide create action when workspace role management is missing', () => {

View File

@ -78,7 +78,7 @@ const AccessRuleRowMenu = ({ rule, onView, onEdit }: AccessRuleRowMenuProps) =>
render={
<ActionButton
size="l"
className={open ? 'bg-state-base-hover' : ''}
className="data-popup-open:bg-state-base-hover"
aria-label={t(($) => $['operation.moreActions'], { ns: 'common' })}
/>
}

View File

@ -93,11 +93,18 @@ describe('ApiBasedExtensionSelector', () => {
it('should open dropdown when clicked', async () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
const popoverTrigger = screen.getByTestId('popover-trigger')
const trigger = screen.getByText('common.apiBasedExtension.selector.placeholder')
const arrow = trigger.parentElement?.querySelector('[aria-hidden="true"]')
expect(popoverTrigger).not.toHaveAttribute('data-popup-open')
expect(arrow).toHaveClass('opacity-60')
fireEvent.click(trigger)
// Assert
// Assert
expect(popoverTrigger).toHaveAttribute('data-popup-open', '')
expect(arrow).not.toHaveClass('opacity-60')
expect(
await screen.findByText('common.apiBasedExtension.selector.title'),
)!.toBeInTheDocument()

View File

@ -1,3 +1,4 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useQuery } from '@tanstack/react-query'
import { useQueryState } from 'nuqs'
@ -41,8 +42,12 @@ export function ApiBasedExtensionSelector({ value, onChange }: ApiBasedExtension
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<button type="button" className="block w-full border-0 bg-transparent p-0 text-left">
render={(props, state) => (
<button
{...props}
type="button"
className={cn('block w-full border-0 bg-transparent p-0 text-left', props.className)}
>
{currentItem ? (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
<div className="text-sm text-text-primary">{currentItem.name}</div>
@ -51,7 +56,10 @@ export function ApiBasedExtensionSelector({ value, onChange }: ApiBasedExtension
{currentItem.api_endpoint}
</div>
<span
className={`i-ri-arrow-down-s-line size-4 text-text-secondary ${!open && 'opacity-60'}`}
className={cn(
'i-ri-arrow-down-s-line size-4 text-text-secondary',
!state.open && 'opacity-60',
)}
aria-hidden="true"
/>
</div>
@ -62,13 +70,16 @@ export function ApiBasedExtensionSelector({ value, onChange }: ApiBasedExtension
ns: 'common',
})}
<span
className={`i-ri-arrow-down-s-line h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`}
className={cn(
'i-ri-arrow-down-s-line h-4 w-4 text-text-secondary',
!state.open && 'opacity-60',
)}
aria-hidden="true"
/>
</div>
)}
</button>
}
)}
/>
<PopoverContent
placement="bottom-start"

View File

@ -101,7 +101,7 @@ const DataSourcePluginActions = ({ detail, onUpdate }: Props) => {
pluginID={detail.plugin_id}
currentVersion={detail.version}
onSelect={handleVersionSelect}
trigger={
trigger={() => (
<Badge
className="h-5 px-1.5"
text={
@ -118,7 +118,7 @@ const DataSourcePluginActions = ({ detail, onUpdate }: Props) => {
hasRedCornerMark={hasNewVersion}
uppercase={false}
/>
}
)}
/>
)}
{canUpdatePlugin && (hasNewVersion || isFromGitHub) && (

View File

@ -302,21 +302,27 @@ vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
}: {
value?: string | { format: (format: string) => string }
onChange: (value: { hour: () => number; minute: () => number }) => void
renderTrigger: (params: {
inputElem: ReactNode
onClick: () => void
isOpen: boolean
}) => ReactNode
renderTrigger: (
props: Record<string, never>,
state: { open: boolean },
params: {
inputElem: ReactNode
onClick: () => void
},
) => ReactNode
}) => {
const displayValue = typeof value === 'string' ? value : value?.format('HH:mm')
return (
<div data-testid="update-time-picker">
{renderTrigger({
inputElem: <span data-testid="update-time-value">{displayValue}</span>,
onClick: vi.fn(),
isOpen: false,
})}
{renderTrigger(
{},
{ open: false },
{
inputElem: <span data-testid="update-time-value">{displayValue}</span>,
onClick: vi.fn(),
},
)}
<button
type="button"
onClick={() =>

View File

@ -106,9 +106,13 @@ describe('AddCustomModel', () => {
/>,
)
fireEvent.click(screen.getByTestId('popover-trigger'))
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(trigger)
// The portal should be "open"
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByTestId('popover')).toHaveAttribute('data-open', 'true')
expect(screen.getByText('gpt-4')).toBeInTheDocument()
expect(screen.getByTestId('model-icon')).toBeInTheDocument()

View File

@ -98,7 +98,11 @@ const AddCustomModel = ({
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
nativeButton={false}
render={<div className="inline-block">{renderTrigger(open)}</div>}
render={(props, state) => (
<div {...props} className={cn('inline-block', props.className)}>
{renderTrigger(state.open)}
</div>
)}
/>
<PopoverContent
placement="bottom-end"

View File

@ -119,7 +119,13 @@ describe('Authorized', () => {
/>,
)
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(screen.getByRole('button', { name: /trigger\s*closed/i }))
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByRole('button', { name: /trigger\s*open/i })).toBeInTheDocument()
expect(screen.getByTestId('authorized-item'))!.toBeInTheDocument()
expect(screen.getByRole('button', { name: /addApiKey/i }))!.toBeInTheDocument()
})

View File

@ -177,11 +177,14 @@ const Authorized = ({
<Popover open={mergedIsOpen} onOpenChange={setMergedIsOpen}>
<PopoverTrigger
nativeButton={false}
render={
<div className={triggerPopupSameWidth ? 'w-full' : 'inline-block'}>
{renderTrigger(mergedIsOpen)}
render={(props, state) => (
<div
{...props}
className={cn(triggerPopupSameWidth ? 'w-full' : 'inline-block', props.className)}
>
{renderTrigger(state.open)}
</div>
}
)}
onClick={handleTriggerClick}
/>
<PopoverContent

View File

@ -367,9 +367,13 @@ describe('ModelParameterModal', () => {
/>,
)
const trigger = screen.getByText('Custom Closed').closest('button')
expect(trigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(screen.getByText('Custom Closed'))
expect(screen.getByText('Custom Open')).toBeInTheDocument()
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
fireEvent.click(screen.getByText('hide'))

View File

@ -126,20 +126,24 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
>
{renderTrigger ? (
<PopoverTrigger
render={
render={(props, state) => (
<button
{...props}
type="button"
className="block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]"
className={cn(
'block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]',
props.className,
)}
>
{renderTrigger({
open,
open: state.open,
currentProvider,
currentModel,
providerName: provider,
modelId,
})}
</button>
}
)}
/>
) : (
<div className="flex h-8 min-w-74 items-center gap-px overflow-hidden rounded-lg">

View File

@ -83,12 +83,12 @@ vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({
onSelect,
disabled,
}: {
trigger: ReactNode
trigger: (open: boolean) => ReactNode
onSelect: (state: { version: string; unique_identifier: string; isDowngrade?: boolean }) => void
disabled?: boolean
}) => (
<div data-testid="plugin-version-picker" data-disabled={String(Boolean(disabled))}>
{trigger}
{trigger(false)}
<button
type="button"
onClick={() =>

View File

@ -93,7 +93,7 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
onSelect={handleVersionSelect}
sideOffset={4}
alignOffset={0}
trigger={
trigger={() => (
<Badge
className={cn(
canUpdatePlugin &&
@ -111,7 +111,7 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
}
hasRedCornerMark={hasNewVersion}
/>
}
)}
/>
{isDebuggingPlugin && (
<Badge

View File

@ -73,7 +73,12 @@ const createRole = (overrides: Partial<Role> = {}): Role => ({
const openMenu = async () => {
const user = userEvent.setup()
await user.click(screen.getByRole('button', { name: 'common.operation.moreActions' }))
const trigger = screen.getByRole('button', { name: 'common.operation.moreActions' })
expect(trigger).not.toHaveAttribute('data-popup-open')
await user.click(trigger)
expect(trigger).toHaveAttribute('data-popup-open', '')
const menus = screen.getAllByRole('menu')
return {
user,

View File

@ -9,7 +9,6 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -114,7 +113,7 @@ const RowMenu = ({ roleCategory, role, onView, onEdit }: RowMenuProps) => {
render={
<ActionButton
size="m"
className={cn('shrink-0', open && 'bg-state-base-hover')}
className="shrink-0 data-popup-open:bg-state-base-hover"
aria-label={t(($) => $['operation.moreActions'], { ns: 'common' })}
/>
}

View File

@ -1,5 +1,5 @@
import type { ReactElement, ReactNode } from 'react'
import type { TriggerParams } from '@/app/components/base/date-and-time-picker/types'
import type { ReactNode } from 'react'
import type { TimePickerProps } from '@/app/components/base/date-and-time-picker/types'
import type { AutoUpdateConfig } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import type { dayjsToTimeOfDay } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/utils'
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
@ -39,7 +39,7 @@ type UpdateSettingDialogFormProps = {
onPluginsChange: (newPlugins: string[]) => void
onRequestClose: () => void
onUpdateTimeChange: (value: Parameters<typeof dayjsToTimeOfDay>[0]) => void
renderTimePickerTrigger: (params: TriggerParams) => ReactElement
renderTimePickerTrigger: NonNullable<TimePickerProps['renderTrigger']>
}
const updateSettingFormLabelClassName =

View File

@ -1,6 +1,9 @@
'use client'
import type { TriggerParams } from '@/app/components/base/date-and-time-picker/types'
import type {
TimePickerProps,
TriggerParams,
} from '@/app/components/base/date-and-time-picker/types'
import type { AutoUpdateConfig } from '@/app/components/plugins/reference-setting-modal/auto-update-setting/types'
import type { PluginCategoryEnum } from '@/app/components/plugins/types'
import { Button } from '@langgenius/dify-ui/button'
@ -203,19 +206,26 @@ const UpdateSettingDialog = ({ category, disabled = false }: Props) => {
setDraftAutoUpgrade(undefined)
setIsOpen(false)
}, [autoUpgrade, saveAutoUpgrade])
const renderTimePickerTrigger = useCallback(
({ inputElem, onClick, isOpen }: TriggerParams) => {
const renderTimePickerTrigger = useCallback<NonNullable<TimePickerProps['renderTrigger']>>(
(props, state, { inputElem, onClick }: TriggerParams) => {
return (
<button
{...props}
type="button"
className="group flex h-8 w-full cursor-pointer items-center gap-1 rounded-lg border-none bg-components-input-bg-normal px-2 py-1 text-left shadow-none hover:bg-state-base-hover-alt focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onClick}
className={cn(
'group flex h-8 w-full cursor-pointer items-center gap-1 rounded-lg border-none bg-components-input-bg-normal px-2 py-1 text-left shadow-none hover:bg-state-base-hover-alt focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden',
props.className,
)}
onClick={(event) => {
onClick(event)
props.onClick?.(event)
}}
>
<span
aria-hidden
className={cn(
'i-ri-time-line size-4 shrink-0 text-text-tertiary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
state.open ? 'text-text-secondary' : 'group-hover:text-text-secondary',
)}
/>
<span className="min-w-0 flex-1 p-1 system-sm-regular text-components-input-text-filled">

View File

@ -293,7 +293,6 @@ const WorkspaceRoleCheckboxList = ({
render={<ul />}
>
{filteredRoles.map((role) => {
const checked = selectedRoleIdSet.has(role.id)
const disabled = disabledRoleIdSet.has(role.id)
return (
@ -307,8 +306,8 @@ const WorkspaceRoleCheckboxList = ({
type="button"
className={cn(
'flex w-full cursor-pointer items-start gap-3 rounded-lg border-0 bg-transparent px-3 py-2.5 text-left hover:bg-state-base-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-components-input-border-active',
checked && 'bg-state-accent-hover hover:bg-state-accent-hover',
disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent',
'data-checked:bg-state-accent-hover data-checked:hover:bg-state-accent-hover',
'data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-transparent data-checked:data-disabled:hover:bg-transparent',
)}
/>
}

View File

@ -977,7 +977,12 @@ describe('MainNav', () => {
renderMainNav({ enable_learn_app: true })
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' }))
const helpTrigger = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })
expect(helpTrigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(helpTrigger)
expect(helpTrigger).toHaveAttribute('data-popup-open', '')
const learnDifyItem = await screen.findByRole('menuitemcheckbox', {
name: 'common.mainNav.help.learnDify',
})

View File

@ -332,7 +332,14 @@ describe('WorkspaceCard', () => {
it('opens workspace actions and switcher in a popover panel', async () => {
renderWorkspaceCard()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
const workspaceTrigger = screen.getByRole('button', {
name: 'common.mainNav.workspace.openMenu',
})
expect(workspaceTrigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(workspaceTrigger)
expect(workspaceTrigger).toHaveAttribute('data-popup-open', '')
const panel = await screen.findByRole('dialog', { name: 'Solar Studio' })
expect(panel).toBeInTheDocument()
@ -420,7 +427,14 @@ describe('WorkspaceCard', () => {
expect(defaultWorkspaceOptions).toEqual(['Atlas Workspace', 'Solar Studio', 'Evan Workspace'])
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' }))
const sortTrigger = screen.getByRole('button', {
name: 'common.mainNav.workspace.sort.openMenu',
})
expect(sortTrigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(sortTrigger)
expect(sortTrigger).toHaveAttribute('data-popup-open', '')
expect(
await screen.findByRole('menuitemradio', {

View File

@ -18,7 +18,7 @@ const AccountSection = ({ compact = false }: AccountSectionProps) => {
return (
<AccountDropdown
trigger={({ isOpen, ariaLabel }) => (
trigger={({ ariaLabel }) => (
<button
type="button"
aria-label={ariaLabel}
@ -26,7 +26,7 @@ const AccountSection = ({ compact = false }: AccountSectionProps) => {
className={cn(
'flex min-w-0 shrink items-center rounded-full text-left text-components-main-nav-text transition-colors hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-default disabled:hover:bg-transparent',
compact ? 'justify-center p-1' : 'max-w-45 gap-3 py-1 pr-4 pl-1',
isOpen && 'bg-state-base-hover',
'data-popup-open:bg-state-base-hover',
)}
>
<Avatar

View File

@ -144,7 +144,7 @@ const HelpMenu = ({ triggerIcon = defaultTriggerIcon, triggerClassName }: HelpMe
className={cn(
'inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-components-card-border bg-components-card-bg p-0 text-text-tertiary shadow-xs transition-colors hover:bg-components-card-bg-alt hover:text-saas-dify-blue-inverted focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
triggerClassName,
open && 'bg-components-card-bg-alt text-saas-dify-blue-inverted',
'data-popup-open:bg-components-card-bg-alt data-popup-open:text-saas-dify-blue-inverted',
skipRecoveryVisible && styles.stepByStepTourRecoveryPulse,
)}
>

View File

@ -85,7 +85,6 @@ function WorkspaceCreditsLabel({ credits, unit }: { credits: string; unit: strin
}
function WorkspaceCardTrigger({
open,
name,
status,
credits,
@ -96,7 +95,6 @@ function WorkspaceCardTrigger({
onPrefetchWorkspaces,
onPlanClick,
}: {
open: boolean
name: string
status: ReactNode
credits: number
@ -122,7 +120,7 @@ function WorkspaceCardTrigger({
className={cn(
'flex w-full items-center gap-1.5 py-1.5 pr-3 pl-1.5 text-left transition-colors hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden',
showCloudBilling ? 'rounded-t-xl' : 'rounded-xl',
open && 'bg-linear-to-b from-background-section-burn to-background-section',
'data-popup-open:bg-linear-to-b data-popup-open:from-background-section-burn data-popup-open:to-background-section',
)}
>
<WorkspaceAvatar name={name} size="sm" />
@ -309,7 +307,6 @@ export function WorkspaceCard() {
<Popover open={open} onOpenChange={setOpen}>
<>
<WorkspaceCardTrigger
open={open}
name={currentWorkspace.name}
status={renderWorkspaceStatus()}
credits={currentWorkspace.credits}

View File

@ -77,7 +77,7 @@ function WorkspaceSwitchControls({
disabled={disabled}
className={cn(
workspaceSwitchActionButtonClassName,
sortMenuOpen && 'bg-state-base-hover text-text-secondary',
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
)}
>
<span aria-hidden className={workspaceSwitchActionIconWrapClassName}>

View File

@ -30,7 +30,6 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte
{usedInMarketplace && (
<MarketplaceTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}
@ -39,7 +38,6 @@ function TagsFilter({ tags, onTagsChange, usedInMarketplace = false }: TagsFilte
{!usedInMarketplace && (
<ToolSelectorTrigger
selectedTagsLength={selectedTagsLength}
open={open}
tags={tags}
tagsMap={tagsMap}
onTagsChange={onTagsChange}

View File

@ -18,7 +18,6 @@ describe('MarketplaceTrigger', () => {
<Popover>
<MarketplaceTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
@ -37,7 +36,6 @@ describe('MarketplaceTrigger', () => {
<Popover>
<MarketplaceTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
@ -58,7 +56,6 @@ describe('MarketplaceTrigger', () => {
<Popover>
<MarketplaceTrigger
selectedTagsLength={tags.length}
open={false}
tags={tags}
tagsMap={tagsMap}
onTagsChange={setTags}
@ -80,4 +77,26 @@ describe('MarketplaceTrigger', () => {
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'pluginTags.allTags' })).toHaveFocus()
})
it('reflects the actual popover state on the trigger', async () => {
const user = userEvent.setup()
render(
<Popover>
<MarketplaceTrigger
selectedTagsLength={0}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
/>
</Popover>,
)
const trigger = screen.getByRole('button', { name: 'pluginTags.allTags' })
expect(trigger).not.toHaveAttribute('data-popup-open')
await user.click(trigger)
expect(trigger).toHaveAttribute('data-popup-open', '')
})
})

View File

@ -18,7 +18,6 @@ describe('ToolSelectorTrigger', () => {
<Popover>
<ToolSelectorTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
@ -38,7 +37,6 @@ describe('ToolSelectorTrigger', () => {
<Popover>
<ToolSelectorTrigger
selectedTagsLength={3}
open
tags={['agent', 'rag', 'search']}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
@ -61,7 +59,6 @@ describe('ToolSelectorTrigger', () => {
<Popover>
<ToolSelectorTrigger
selectedTagsLength={0}
open={false}
tags={[]}
tagsMap={tagsMap}
onTagsChange={vi.fn()}
@ -71,12 +68,14 @@ describe('ToolSelectorTrigger', () => {
)
const trigger = screen.getByRole('button', { name: 'pluginTags.allTags' })
expect(trigger).not.toHaveAttribute('data-popup-open')
await user.tab()
expect(trigger).toHaveFocus()
await user.keyboard('{Enter}')
expect(screen.getByText('Tag options')).toBeInTheDocument()
expect(trigger).toHaveAttribute('data-popup-open', '')
})
it('keeps clear as a separate action from the popover trigger', async () => {
@ -87,7 +86,6 @@ describe('ToolSelectorTrigger', () => {
<Popover>
<ToolSelectorTrigger
selectedTagsLength={tags.length}
open={false}
tags={tags}
tagsMap={tagsMap}
onTagsChange={setTags}

View File

@ -7,7 +7,6 @@ import { useTranslation } from '#i18n'
type MarketplaceTriggerProps = {
selectedTagsLength: number
open: boolean
tags: string[]
tagsMap: Record<string, Tag>
onTagsChange: (tags: string[]) => void
@ -15,7 +14,6 @@ type MarketplaceTriggerProps = {
function MarketplaceTrigger({
selectedTagsLength,
open,
tags,
tagsMap,
onTagsChange,
@ -48,7 +46,7 @@ function MarketplaceTrigger({
'h-8 justify-start px-2 py-1 text-text-tertiary focus-visible:ring-inset',
!!selectedTagsLength &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg pr-8 shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
!selectedTagsLength && 'data-popup-open:bg-state-base-hover',
)}
>
<span className="p-0.5">

View File

@ -7,7 +7,6 @@ import { useTranslation } from '#i18n'
type ToolSelectorTriggerProps = {
selectedTagsLength: number
open: boolean
tags: string[]
tagsMap: Record<string, Tag>
onTagsChange: (tags: string[]) => void
@ -15,7 +14,6 @@ type ToolSelectorTriggerProps = {
function ToolSelectorTrigger({
selectedTagsLength,
open,
tags,
tagsMap,
onTagsChange,
@ -49,7 +47,7 @@ function ToolSelectorTrigger({
!selectedTagsLength && 'size-7 min-h-0 justify-center p-0',
!!selectedTagsLength &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-7 pl-1 shadow-xs shadow-shadow-shadow-3',
open && !selectedTagsLength && 'bg-state-base-hover',
!selectedTagsLength && 'data-popup-open:bg-state-base-hover',
)}
>
<span className={cn('shrink-0', !!selectedTagsLength && 'p-0.5')}>

View File

@ -188,7 +188,7 @@ describe('Authorized Component', () => {
expect(screen.getByRole('button'))!.toBeInTheDocument()
})
it('should render with custom trigger when renderTrigger is provided', () => {
it('should render a custom trigger from the actual popover state', () => {
const pluginPayload = createPluginPayload()
const credentials = [createCredential()]
@ -203,8 +203,14 @@ describe('Authorized Component', () => {
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('custom-trigger'))!.toBeInTheDocument()
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).not.toHaveAttribute('data-popup-open')
expect(screen.getByText('Closed'))!.toBeInTheDocument()
fireEvent.click(screen.getByTestId('custom-trigger'))
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByText('Open')).toBeInTheDocument()
})
it('should show singular authorization text for 1 credential', () => {

View File

@ -207,16 +207,16 @@ const Authorized = ({
<>
<Popover open={mergedIsOpen} onOpenChange={setMergedIsOpen}>
<PopoverTrigger
render={
<div className={triggerPopupSameWidth ? 'w-full' : 'inline-block'}>
render={(props, state) => (
<div
{...props}
className={cn(triggerPopupSameWidth ? 'w-full' : 'inline-block', props.className)}
>
{renderTrigger ? (
renderTrigger(mergedIsOpen)
renderTrigger(state.open)
) : (
<Button
className={cn(
'w-full',
mergedIsOpen && 'bg-components-button-secondary-bg-hover',
)}
className={cn('w-full', state.open && 'bg-components-button-secondary-bg-hover')}
>
<StatusDot
className="mr-2"
@ -233,7 +233,7 @@ const Authorized = ({
</Button>
)}
</div>
}
)}
/>
<PopoverContent
placement={placement}

View File

@ -120,7 +120,12 @@ describe('AppSelector', () => {
renderWithQueryClient(<AppSelector onSelect={onSelect} />)
fireEvent.click(screen.getByRole('button', { name: 'app.appSelector.label' }))
const trigger = screen.getByRole('button', { name: 'app.appSelector.label' })
expect(trigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(trigger)
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByText('app.appSelector.label')).toBeInTheDocument()
fireEvent.click(screen.getByRole('combobox', { name: 'app.appSelector.label' }))

View File

@ -2,6 +2,7 @@
import type { Placement } from '@langgenius/dify-ui/popover'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
@ -126,12 +127,16 @@ export function AppSelector({
<PopoverTrigger
aria-label={t(($) => $['appSelector.label'], { ns: 'app' })}
disabled={disabled}
render={
<button type="button" className="block w-full border-0 bg-transparent p-0 text-left" />
}
>
<AppTrigger open={isShow} appDetail={currentAppInfo} />
</PopoverTrigger>
render={(props, state) => (
<button
{...props}
type="button"
className={cn('block w-full border-0 bg-transparent p-0 text-left', props.className)}
>
<AppTrigger open={state.open} appDetail={currentAppInfo} />
</button>
)}
/>
<PopoverContent
placement={placement}
sideOffset={offset}

View File

@ -209,12 +209,12 @@ const DetailHeader = ({
pluginID={plugin_id}
currentVersion={version}
onSelect={handleVersionSelect}
trigger={
trigger={(open) => (
<Badge
className={cn(
'mx-1',
versionPicker.isShow && 'bg-state-base-hover',
(versionPicker.isShow || (canUpdatePlugin && isFromMarketplace)) &&
open && 'bg-state-base-hover',
(open || (canUpdatePlugin && isFromMarketplace)) &&
'hover:bg-state-base-hover',
)}
uppercase={false}
@ -231,7 +231,7 @@ const DetailHeader = ({
}
hasRedCornerMark={hasNewVersion}
/>
}
)}
/>
)}

View File

@ -343,7 +343,7 @@ describe('ModelParameterModal', () => {
expect(screen.queryByTestId('trigger')).not.toBeInTheDocument()
})
it('should call renderTrigger with correct props', () => {
it('should call renderTrigger with the actual popover state', async () => {
// Arrange
const renderTrigger = vi.fn().mockReturnValue(<div>Custom</div>)
const value = { provider: 'openai', model: 'gpt-4' }
@ -352,7 +352,8 @@ describe('ModelParameterModal', () => {
// Act
render(<ModelParameterModal {...props} />)
// Assert
const trigger = screen.getByText('Custom').closest('button')
expect(trigger).not.toHaveAttribute('data-popup-open')
expect(renderTrigger).toHaveBeenCalledWith(
expect.objectContaining({
open: false,
@ -360,6 +361,19 @@ describe('ModelParameterModal', () => {
modelId: 'gpt-4',
}),
)
fireEvent.click(screen.getByText('Custom'))
await waitFor(() => {
expect(renderTrigger).toHaveBeenLastCalledWith(
expect.objectContaining({
open: true,
providerName: 'openai',
modelId: 'gpt-4',
}),
)
})
expect(trigger).toHaveAttribute('data-popup-open', '')
})
it('should not render portal content when closed', () => {

View File

@ -224,14 +224,18 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
</div>
) : (
<PopoverTrigger
render={
render={(props, state) => (
<button
{...props}
type="button"
className="block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]"
className={cn(
'block w-full border-none bg-transparent p-0 text-left text-inherit [font:inherit]',
props.className,
)}
>
{renderTrigger ? (
renderTrigger({
open,
open: state.open,
currentProvider,
currentModel,
providerName: value?.provider,
@ -249,7 +253,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
/>
)}
</button>
}
)}
/>
)}
<PopoverContent

View File

@ -31,9 +31,28 @@ vi.mock('@langgenius/dify-ui/popover', async () => {
return <PopoverContext.Provider value={{ open, setOpen }}>{children}</PopoverContext.Provider>
}
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
type TriggerProps = React.HTMLAttributes<HTMLElement> & {
'data-popup-open'?: string
'data-testid'?: string
}
const PopoverTrigger = ({
render,
}: {
render:
| React.ReactNode
| ((props: TriggerProps, state: { open: boolean }) => React.ReactElement)
}) => {
const { open, setOpen } = React.useContext(PopoverContext)
return <div onClick={() => setOpen(!open)}>{render}</div>
const props: TriggerProps = {
'data-testid': 'popover-trigger',
'data-popup-open': open ? '' : undefined,
onClick: () => setOpen(!open),
}
if (typeof render === 'function') return render(props, { open })
return <div {...props}>{render}</div>
}
const PopoverContent = ({ children }: { children: React.ReactNode }) => {
@ -112,8 +131,12 @@ describe('SubscriptionSelectorEntry', () => {
it('should render placeholder when open without selection', () => {
render(<SubscriptionSelectorEntry selectedId={undefined} onSelect={vi.fn()} />)
const trigger = screen.getByTestId('popover-trigger')
expect(trigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(screen.getByRole('button'))
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByText('pluginTrigger.subscription.selectPlaceholder')).toBeInTheDocument()
})

View File

@ -106,11 +106,11 @@ export const SubscriptionSelectorEntry = ({
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger
render={
<div>
<SubscriptionTriggerButton selectedId={selectedId} isOpen={isOpen} />
render={(props, state) => (
<div {...props}>
<SubscriptionTriggerButton selectedId={selectedId} isOpen={state.open} />
</div>
}
)}
/>
<PopoverContent
placement="bottom-start"

View File

@ -109,9 +109,17 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
onClick,
render,
}: {
children: React.ReactNode
children?: React.ReactNode
onClick?: React.MouseEventHandler<HTMLElement>
render?: React.ReactElement
render?:
| React.ReactElement
| ((
props: React.HTMLAttributes<HTMLElement> & {
'data-testid'?: string
'data-popup-open'?: string
},
state: { open: boolean },
) => React.ReactElement)
}) => {
const { isOpen, setOpen } = useDropdownMenuContext()
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
@ -119,6 +127,16 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', async () => {
setOpen(!isOpen)
}
if (typeof render === 'function')
return render(
{
'data-testid': 'dropdown-trigger',
'data-popup-open': isOpen ? '' : undefined,
onClick: handleClick,
},
{ open: isOpen },
)
if (render)
return React.cloneElement(
render,
@ -243,10 +261,12 @@ describe('InstallPluginDropdown', () => {
expect(screen.getByTestId('arrow-down-icon')).toHaveClass('ml-1', 'size-4')
expect(trigger).toHaveClass('custom-trigger')
expect(trigger).toHaveAttribute('data-variant', 'primary')
expect(trigger).not.toHaveAttribute('data-popup-open')
fireEvent.click(trigger)
expect(trigger).toHaveClass('custom-open')
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(screen.getByTestId('dropdown-content')).toHaveClass('custom-popup')
})

View File

@ -160,8 +160,9 @@ const InstallPluginDropdown = ({
accept={SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS}
/>
<DropdownMenuTrigger
render={
render={(props, state) => (
<Button
{...props}
variant={triggerVariant}
disabled={disabled}
title={buttonLabel}
@ -169,19 +170,18 @@ const InstallPluginDropdown = ({
className={cn(
'size-full p-2',
triggerClassName,
!disabled && isMenuOpen && triggerOpenClassName,
state.open && triggerOpenClassName,
props.className,
)}
/>
}
>
<>
<RiAddCircleFill className="size-4 shrink-0" />
<span className={cn(showTriggerArrow ? 'pl-1' : 'min-w-0 flex-1 px-0.5 text-left')}>
{buttonLabel}
</span>
{showTriggerArrow && <RiArrowDownSLine className="ml-1 size-4" />}
</>
</DropdownMenuTrigger>
>
<RiAddCircleFill className="size-4 shrink-0" />
<span className={cn(showTriggerArrow ? 'pl-1' : 'min-w-0 flex-1 px-0.5 text-left')}>
{buttonLabel}
</span>
{showTriggerArrow && <RiArrowDownSLine className="ml-1 size-4" />}
</Button>
)}
/>
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}

View File

@ -125,23 +125,28 @@ const PluginTasks = ({
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
nativeButton={false}
render={<div className={canOpenMenu ? 'cursor-pointer' : 'cursor-default'} />}
render={(props, state) => (
<div
{...props}
className={cn('cursor-pointer data-disabled:cursor-default', props.className)}
>
<TaskStatusIndicator
tip={tip}
isInstalling={isInstalling}
isInstallingWithSuccess={isInstallingWithSuccess}
isInstallingWithError={isInstallingWithError}
isSuccess={isSuccess}
isFailed={isFailed}
isOpen={state.open}
successPluginsLength={successPluginsLength}
runningPluginsLength={runningPluginsLength}
totalPluginsLength={totalPluginsLength}
onClick={() => {}}
/>
</div>
)}
disabled={!canOpenMenu}
>
<TaskStatusIndicator
tip={tip}
isInstalling={isInstalling}
isInstallingWithSuccess={isInstallingWithSuccess}
isInstallingWithError={isInstallingWithError}
isSuccess={isSuccess}
isFailed={isFailed}
isOpen={open}
successPluginsLength={successPluginsLength}
runningPluginsLength={runningPluginsLength}
totalPluginsLength={totalPluginsLength}
onClick={() => {}}
/>
</DropdownMenuTrigger>
/>
<DropdownMenuContent
placement={dropdownPlacement}
sideOffset={4}

View File

@ -172,21 +172,17 @@ vi.mock('@/app/components/base/date-and-time-picker/time-picker', () => ({
onChange: (v: unknown) => void
onClear: () => void
title?: string
renderTrigger: (params: {
inputElem: React.ReactNode
onClick: () => void
isOpen: boolean
}) => React.ReactNode
renderTrigger: (
props: React.HTMLAttributes<HTMLElement>,
state: { open: boolean },
params: { inputElem: React.ReactNode; onClick: () => void },
) => React.ReactNode
}) => {
const inputElem = <span data-testid="time-input">{value.format('HH:mm')}</span>
return (
<div data-testid="time-picker">
{renderTrigger({
inputElem,
onClick: () => {},
isOpen: false,
})}
{renderTrigger({}, { open: false }, { inputElem, onClick: () => {} })}
<div data-testid="time-picker-dropdown">
<button
data-testid="time-picker-set"

View File

@ -1,7 +1,10 @@
'use client'
import type { FC } from 'react'
import type { AutoUpdateConfig } from './types'
import type { TriggerParams } from '@/app/components/base/date-and-time-picker/types'
import type {
TimePickerProps,
TriggerParams,
} from '@/app/components/base/date-and-time-picker/types'
import { cn } from '@langgenius/dify-ui/cn'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { RiTimeLine } from '@remixicon/react'
@ -138,19 +141,26 @@ const AutoUpdateSetting: FC<Props> = ({ payload, onChange }) => {
[payload, onChange],
)
const renderTimePickerTrigger = useCallback(
({ inputElem, onClick, isOpen }: TriggerParams) => {
const renderTimePickerTrigger = useCallback<NonNullable<TimePickerProps['renderTrigger']>>(
(props, state, { inputElem, onClick }: TriggerParams) => {
return (
<button
{...props}
type="button"
className="group flex h-8 w-40 cursor-pointer items-center justify-between rounded-lg border-none bg-components-input-bg-normal px-2 text-left hover:bg-state-base-hover-alt focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={onClick}
className={cn(
'group flex h-8 w-40 cursor-pointer items-center justify-between rounded-lg border-none bg-components-input-bg-normal px-2 text-left hover:bg-state-base-hover-alt focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden',
props.className,
)}
onClick={(event) => {
onClick(event)
props.onClick?.(event)
}}
>
<div className="flex w-0 grow items-center gap-x-1">
<RiTimeLine
className={cn(
'size-4 shrink-0 text-text-tertiary',
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
state.open ? 'text-text-secondary' : 'group-hover:text-text-secondary',
)}
/>
{inputElem}

View File

@ -905,7 +905,7 @@ describe('update-plugin', () => {
onShowChange: vi.fn(),
pluginID: 'test-plugin-id',
currentVersion: '1.0.0',
trigger: <span>Select Version</span>,
trigger: () => <span>Select Version</span>,
onSelect: vi.fn(),
}
@ -1136,7 +1136,7 @@ describe('update-plugin', () => {
onShowChange: vi.fn(),
pluginID: 'test',
currentVersion: '1.0.0',
trigger: <span>Select</span>,
trigger: () => <span>Select</span>,
onSelect: vi.fn(),
}}
/>,

View File

@ -50,7 +50,7 @@ describe('PluginVersionPicker', () => {
onShowChange={vi.fn()}
pluginID="plugin-1"
currentVersion="2.0.0"
trigger={<span>trigger</span>}
trigger={() => <span>trigger</span>}
onSelect={vi.fn()}
/>,
)
@ -61,6 +61,23 @@ describe('PluginVersionPicker', () => {
expect(screen.getByText('CURRENT')).toBeInTheDocument()
})
it('passes Base trigger state and props through the preserved button root', () => {
render(
<PluginVersionPicker
isShow
onShowChange={vi.fn()}
pluginID="plugin-1"
currentVersion="2.0.0"
trigger={(open) => <span>{open ? 'open trigger' : 'closed trigger'}</span>}
onSelect={vi.fn()}
/>,
)
const trigger = screen.getByRole('button', { name: 'open trigger' })
expect(trigger).toHaveAttribute('data-popup-open', '')
expect(trigger).toHaveClass('cursor-pointer')
})
it('renders figma-aligned version rows', () => {
render(
<PluginVersionPicker
@ -68,7 +85,7 @@ describe('PluginVersionPicker', () => {
onShowChange={vi.fn()}
pluginID="plugin-1"
currentVersion="2.0.0"
trigger={<span>trigger</span>}
trigger={() => <span>trigger</span>}
onSelect={vi.fn()}
/>,
)
@ -98,7 +115,7 @@ describe('PluginVersionPicker', () => {
onShowChange={onShowChange}
pluginID="plugin-1"
currentVersion="2.0.0"
trigger={<span>trigger</span>}
trigger={() => <span>trigger</span>}
onSelect={onSelect}
/>,
)
@ -122,7 +139,7 @@ describe('PluginVersionPicker', () => {
onShowChange={vi.fn()}
pluginID="plugin-1"
currentVersion="2.0.0"
trigger={<span>trigger</span>}
trigger={() => <span>trigger</span>}
onSelect={onSelect}
/>,
)

View File

@ -17,7 +17,7 @@ type Props = Readonly<{
onShowChange: (isShow: boolean) => void
pluginID: string
currentVersion: string
trigger: React.ReactNode
trigger: (open: boolean) => React.ReactNode
placement?: Placement
sideOffset?: number
alignOffset?: number
@ -76,10 +76,19 @@ const PluginVersionPicker: FC<Props> = ({
>
<PopoverTrigger
disabled={disabled}
className={cn('inline-flex cursor-pointer items-center', disabled && 'cursor-default')}
>
{trigger}
</PopoverTrigger>
render={(props, state) => (
<button
{...props}
type="button"
className={cn(
'inline-flex cursor-pointer items-center data-disabled:cursor-default',
props.className,
)}
>
{trigger(state.open)}
</button>
)}
/>
<PopoverContent
placement={placement}

View File

@ -52,7 +52,7 @@ const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({
BlockEnum.TriggerWebhook,
BlockEnum.TriggerPlugin,
]}
trigger={() => (
trigger={
<StartNodeOption
icon={
<div className="flex h-9 w-9 items-center justify-center rounded-[10px] border-[0.5px] border-transparent bg-util-colors-blue-brand-blue-brand-500 p-2">
@ -63,7 +63,7 @@ const StartNodeSelectionPanel: FC<StartNodeSelectionPanelProps> = ({
description={t(($) => $['onboarding.triggerDescription'], { ns: 'workflow' })}
onClick={() => setShowTriggerSelector(true)}
/>
)}
}
/>
</div>
)

View File

@ -107,8 +107,10 @@ describe('BlockSelector', () => {
onSelect={onSelect}
blocks={[createBlock(BlockEnum.LLM, 'LLM'), createBlock(BlockEnum.End, 'End')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.End]}
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
trigger={(props, state) => (
<button {...props} type="button">
{state.open ? 'selector-open' : 'selector-closed'}
</button>
)}
/>,
)
@ -155,8 +157,10 @@ describe('BlockSelector', () => {
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
trigger={(props, state) => (
<button {...props} type="button">
{state.open ? 'selector-open' : 'selector-closed'}
</button>
)}
/>,
)
@ -192,7 +196,7 @@ describe('BlockSelector', () => {
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
trigger={() => <button type="button">selector-open</button>}
trigger={<button type="button">selector-open</button>}
/>,
)
@ -218,8 +222,10 @@ describe('BlockSelector', () => {
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM, BlockEnum.Start]}
showStartTab
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
trigger={(props, state) => (
<button {...props} type="button">
{state.open ? 'selector-open' : 'selector-closed'}
</button>
)}
/>,
)
@ -255,8 +261,10 @@ describe('BlockSelector', () => {
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={(open) => (
<button type="button">{open ? 'selector-open' : 'selector-closed'}</button>
trigger={(props, state) => (
<button {...props} type="button">
{state.open ? 'selector-open' : 'selector-closed'}
</button>
)}
/>,
)
@ -287,7 +295,7 @@ describe('BlockSelector', () => {
onOpenChange={setOpen}
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
trigger={() => <button type="button">selector-trigger</button>}
trigger={<button type="button">selector-trigger</button>}
/>
)
}
@ -314,7 +322,7 @@ describe('BlockSelector', () => {
<BlockSelector
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
trigger={() => <button type="button">selector-trigger</button>}
trigger={<button type="button">selector-trigger</button>}
/>,
)
@ -339,11 +347,11 @@ describe('BlockSelector', () => {
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={() => (
trigger={
<button type="button" onClick={onTriggerClick}>
open-selector
</button>
)}
}
/>,
)
@ -369,7 +377,7 @@ describe('BlockSelector', () => {
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={() => <ForwardingButtonTrigger />}
trigger={<ForwardingButtonTrigger />}
/>,
)
@ -388,7 +396,7 @@ describe('BlockSelector', () => {
onSelect={vi.fn()}
blocks={[createBlock(BlockEnum.LLM, 'LLM')]}
availableBlocksTypes={[BlockEnum.LLM]}
trigger={() => <Button variant="primary">open-shared-button-trigger</Button>}
trigger={<Button variant="primary">open-shared-button-trigger</Button>}
/>,
)

View File

@ -1,5 +1,5 @@
import type { Placement } from '@langgenius/dify-ui/popover'
import type { CSSProperties, KeyboardEvent, MouseEventHandler, ReactElement } from 'react'
import type { Placement, PopoverTriggerProps } from '@langgenius/dify-ui/popover'
import type { CSSProperties, KeyboardEvent, MouseEventHandler } from 'react'
import type {
CommonNodeType,
NodeDefault,
@ -32,13 +32,13 @@ export type BlockSelectorProps = {
open?: boolean
onOpenChange?: (open: boolean) => void
onSelect: OnSelectBlock
trigger?: (open: boolean) => ReactElement
trigger?: NonNullable<PopoverTriggerProps['render']>
triggerTooltip?: string
placement?: Placement
sideOffset?: number
alignOffset?: number
triggerStyle?: CSSProperties
triggerClassName?: (open: boolean) => string
triggerClassName?: string
triggerAriaLabel?: string
popupClassName?: string
availableBlocksTypes?: BlockEnum[]
@ -125,7 +125,7 @@ function BlockSelector({
<PopoverTrigger
aria-label={triggerAriaLabel}
disabled={disabled}
render={trigger(open)}
render={trigger}
onClick={handleTrigger}
/>
) : (
@ -136,7 +136,7 @@ function BlockSelector({
<Button
variant="primary"
size="small"
className={cn('z-10 size-4 rounded-full p-0', triggerClassName?.(open))}
className={cn('z-10 size-4 rounded-full p-0', triggerClassName)}
style={triggerStyle}
/>
}

View File

@ -68,10 +68,11 @@ const SnippetTagsFilter = ({ embedded = false, value, onChange }: SnippetTagsFil
value.length > 0 &&
'border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg py-0.5 pr-1.5 pl-1 shadow-xs shadow-shadow-shadow-3',
!embedded && 'hover:bg-components-input-bg-hover',
open &&
(embedded
? !value.length && 'bg-state-base-hover'
: 'border-components-input-border-active bg-components-input-bg-active text-text-secondary'),
embedded &&
!value.length &&
'data-popup-open:bg-state-base-hover data-popup-open:hover:bg-components-button-ghost-bg-hover',
!embedded &&
'data-popup-open:border-components-input-border-active data-popup-open:bg-components-input-bg-active data-popup-open:text-text-secondary data-popup-open:hover:bg-components-input-bg-hover',
value.length > 0 && 'text-text-secondary',
)}
>

View File

@ -147,7 +147,7 @@ const CustomEdge = ({
nextNodeTargetHandle: targetHandleId || 'target',
}}
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks)}
triggerClassName={() => 'transition-transform hover:scale-150'}
triggerClassName="transition-transform hover:scale-150"
/>
</div>
</EdgeLabelRenderer>

View File

@ -59,12 +59,13 @@ const WorkflowChecklist = ({ disabled, showGoTo = true, onItemClick }: WorkflowC
return (
<Popover open={open} onOpenChange={(newOpen) => !disabled && setOpen(newOpen)}>
<PopoverTrigger
disabled={disabled}
render={
<button
type="button"
className={cn(
'group relative ml-0.5 flex size-7 items-center justify-center rounded-md border-none bg-transparent p-0',
disabled && 'cursor-not-allowed opacity-50',
'data-disabled:cursor-not-allowed data-disabled:opacity-50',
)}
disabled={disabled || undefined}
aria-label={checklistLabel}

View File

@ -38,8 +38,7 @@ function UndoRedo({ handleUndo, handleRedo }: UndoRedoProps) {
focusableWhenDisabled
className={cn(
'size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
(nodesReadOnly || buttonsDisabled.undo) &&
'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
onClick={handleUndo}
>
@ -56,8 +55,7 @@ function UndoRedo({ handleUndo, handleRedo }: UndoRedoProps) {
focusableWhenDisabled
className={cn(
'size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
(nodesReadOnly || buttonsDisabled.redo) &&
'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
onClick={handleRedo}
>

View File

@ -164,8 +164,7 @@ const ViewWorkflowHistory = () => {
aria-label={t(($) => $['changeHistory.title'], { ns: 'workflow' })}
className={cn(
'size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
nodesReadOnly &&
'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
'data-disabled:cursor-not-allowed data-disabled:text-text-disabled data-disabled:hover:bg-transparent data-disabled:hover:text-text-disabled',
)}
onClick={() => {
if (nodesReadOnly) return

View File

@ -31,7 +31,7 @@ vi.mock('@/app/components/workflow/block-selector', () => ({
isolateKeyboardEvents,
}: any) => (
<div>
<div>{trigger()}</div>
<div>{typeof trigger === 'function' ? trigger({}, { open: false }) : trigger}</div>
<div>{`available:${(availableBlocksTypes || []).join(',')}`}</div>
<div>{`show-start:${String(showStartTab)}`}</div>
<div>{`ignore:${(ignoreNodeIds || []).join(',')}`}</div>

View File

@ -60,21 +60,19 @@ export function ChangeBlockMenuTrigger({
[handleNodeChange, nodeId, sourceHandle],
)
const renderTrigger = useCallback(() => {
return (
<button
type="button"
className="mx-1 flex h-8 w-[calc(100%-8px)] cursor-pointer items-center rounded-lg border-0 bg-transparent px-2 text-left text-sm text-text-secondary outline-hidden select-none hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden"
>
{t(($) => $['panel.changeBlock'], { ns: 'workflow' })}
</button>
)
}, [t])
const triggerElement = (
<button
type="button"
className="mx-1 flex h-8 w-[calc(100%-8px)] cursor-pointer items-center rounded-lg border-0 bg-transparent px-2 text-left text-sm text-text-secondary outline-hidden select-none hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden"
>
{t(($) => $['panel.changeBlock'], { ns: 'workflow' })}
</button>
)
return (
<BlockSelector
onSelect={handleSelect}
trigger={renderTrigger}
trigger={triggerElement}
popupClassName="min-w-[240px]"
availableBlocksTypes={availableNodes}
showStartTab={showStartTab}

View File

@ -58,7 +58,7 @@ type BlockSelectorProps = {
open?: boolean
onOpenChange?: (open: boolean) => void
onSelect?: (type: BlockEnum, pluginDefaultValue?: { pluginId: string }) => void
triggerClassName?: (open: boolean) => string
triggerClassName?: string
}
vi.mock('reactflow', () => ({
@ -83,7 +83,8 @@ vi.mock('@/app/components/workflow/block-selector', () => ({
<div>
<button
type="button"
className={triggerClassName?.(open)}
className={triggerClassName}
data-popup-open={open ? '' : undefined}
onClick={(e) => {
e.stopPropagation()
onOpenChange?.(!open)
@ -214,14 +215,14 @@ describe('node-handle', () => {
fireEvent.click(addNodeButton)
expect(addNodeButton).toHaveClass('opacity-100')
expect(addNodeButton).toHaveAttribute('data-popup-open')
// Trigger stays pointer-events-none so it never steals mousedown from
// the underlying React Flow handle (drag-to-connect must keep working).
expect(addNodeButton).toHaveClass('pointer-events-none')
fireEvent.click(handle)
expect(addNodeButton).toHaveClass('opacity-0')
expect(addNodeButton).not.toHaveAttribute('data-popup-open')
fireEvent.click(getSelectNodeButton())
@ -275,7 +276,7 @@ describe('node-handle', () => {
fireEvent.click(addNodeButton)
expect(addNodeButton).toHaveClass('opacity-100')
expect(addNodeButton).toHaveAttribute('data-popup-open')
expect(addNodeButton).toHaveClass('pointer-events-none')
fireEvent.click(getSelectNodeButton())
@ -293,7 +294,7 @@ describe('node-handle', () => {
fireEvent.click(handle)
expect(addNodeButton).toHaveClass('opacity-0')
expect(addNodeButton).not.toHaveAttribute('data-popup-open')
})
it('should keep the source add trigger visible when the node is selected', () => {
@ -323,7 +324,7 @@ describe('node-handle', () => {
const addNodeButton = getAddNodeButton()
expect(addNodeButton).toHaveClass('opacity-100')
expect(addNodeButton).toHaveAttribute('data-popup-open')
expect(addNodeButton).toHaveClass('pointer-events-none')
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false)
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(false)

View File

@ -12,12 +12,8 @@ import { useNodesReadOnly } from '../../../../../hooks/use-workflow'
import NextStep from '../index'
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({ trigger }: { trigger: ((open: boolean) => ReactNode) | ReactNode }) => {
return (
<div data-testid="next-step-block-selector">
{typeof trigger === 'function' ? trigger(false) : trigger}
</div>
)
default: ({ trigger }: { trigger: ReactNode }) => {
return <div data-testid="next-step-block-selector">{trigger}</div>
},
}))

View File

@ -81,15 +81,9 @@ vi.mock('@langgenius/dify-ui/button', () => ({
}))
vi.mock('@/app/components/workflow/block-selector', () => ({
default: ({
trigger,
onSelect,
}: {
trigger: ((open: boolean) => ReactNode) | ReactNode
onSelect: (type: BlockEnum) => void
}) => (
default: ({ trigger, onSelect }: { trigger: ReactNode; onSelect: (type: BlockEnum) => void }) => (
<div>
{typeof trigger === 'function' ? trigger(false) : trigger}
{trigger}
<button type="button" onClick={() => onSelect(BlockEnum.HttpRequest)}>
select-http
</button>

View File

@ -53,22 +53,17 @@ const Add = ({ nodeId, nodeData, sourceHandle, isParallel, isFailBranch }: AddPr
return t(($) => $['panel.selectNextStep'], { ns: 'workflow' })
}, [isFailBranch, isParallel, t])
const renderTrigger = useCallback(
(open: boolean) => {
return (
<Button
variant="ghost"
size="large"
className={`bg-dropzone-bg hover:bg-dropzone-bg-hover relative w-full justify-start rounded-lg border border-dashed border-divider-regular px-2 text-xs text-text-placeholder ${open && 'bg-components-dropzone-bg-alt!'} `}
>
<div className="mr-1.5 flex h-5 w-5 items-center justify-center rounded-[5px] bg-background-default-dimmed">
<RiAddLine aria-hidden className="size-3" />
</div>
<div className="flex items-center uppercase">{tip}</div>
</Button>
)
},
[nodesReadOnly, tip],
const triggerElement = (
<Button
variant="ghost"
size="large"
className="bg-dropzone-bg hover:bg-dropzone-bg-hover relative w-full justify-start rounded-lg border border-dashed border-divider-regular px-2 text-xs text-text-placeholder data-popup-open:bg-components-dropzone-bg-alt!"
>
<div className="mr-1.5 flex h-5 w-5 items-center justify-center rounded-[5px] bg-background-default-dimmed">
<RiAddLine aria-hidden className="size-3" />
</div>
<div className="flex items-center uppercase">{tip}</div>
</Button>
)
return (
@ -83,7 +78,7 @@ const Add = ({ nodeId, nodeData, sourceHandle, isParallel, isFailBranch }: AddPr
}}
placement="top"
sideOffset={0}
trigger={renderTrigger}
trigger={triggerElement}
popupClassName="w-[328px]!"
availableBlocksTypes={availableNextBlocks}
/>

Some files were not shown because too many files have changed in this diff Show More