From b08665e598dae27ed70e1e7c79ff9b1cae1144c2 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 16 Apr 2026 12:53:21 +0800 Subject: [PATCH] refactor(web): redesign Select component and migrate WorkplaceSelector (#35293) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../__tests__/form-fields.spec.tsx | 31 ++-- .../config-var/config-modal/form-fields.tsx | 22 ++- .../tools/external-data-tool-modal.tsx | 5 +- .../components/base/markdown-blocks/form.tsx | 7 +- .../components/base/ui/context-menu/index.tsx | 40 ++--- .../base/ui/dropdown-menu/index.tsx | 36 ++-- web/app/components/base/ui/menu-shared.ts | 7 - web/app/components/base/ui/overlay-shared.ts | 7 + .../base/ui/select/__tests__/index.spec.tsx | 138 ++++++--------- web/app/components/base/ui/select/index.tsx | 167 ++++++++---------- .../__tests__/index.spec.tsx | 144 ++++++++------- .../workplace-selector/index.tsx | 96 +++++----- .../__tests__/parameter-item.select.spec.tsx | 33 ++-- .../model-parameter-modal/parameter-item.tsx | 7 +- .../__tests__/tts-params-panel.spec.tsx | 133 ++++++++------ .../model-selector/tts-params-panel.tsx | 12 +- web/eslint-suppressions.json | 3 - 17 files changed, 447 insertions(+), 441 deletions(-) delete mode 100644 web/app/components/base/ui/menu-shared.ts create mode 100644 web/app/components/base/ui/overlay-shared.ts diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx index 0740f0cde3..101cbab8fb 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -44,18 +44,25 @@ vi.mock('@/app/components/base/select', () => ({ ), })) -vi.mock('@/app/components/base/ui/select', () => ({ - Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => ( -
- - {children} -
- ), - SelectTrigger: ({ children }: { children: ReactNode }) =>
{children}
, - SelectValue: () => select-value, - SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, - SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, -})) +vi.mock('@/app/components/base/ui/select', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + Select: ({ value, onValueChange, children }: { value: string, onValueChange: (value: string) => void, children: ReactNode }) => ( +
+ + {children} +
+ ), + SelectTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + SelectValue: () => select-value, + SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, + SelectItemText: ({ children }: { children: ReactNode }) => {children}, + SelectItemIndicator: () => , + } +}) vi.mock('../field', () => ({ default: ({ children, title }: { children: ReactNode, title: string }) => ( diff --git a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx index fa318ae35d..279a9279cf 100644 --- a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx @@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, + SelectItemIndicator, + SelectItemText, SelectTrigger, SelectValue, } from '@/app/components/base/ui/select' @@ -138,8 +140,14 @@ const ConfigModalFormFields: FC = ({ - {t('variableConfig.startChecked', { ns: 'appDebug' })} - {t('variableConfig.noDefaultSelected', { ns: 'appDebug' })} + + {t('variableConfig.startChecked', { ns: 'appDebug' })} + + + + {t('variableConfig.noDefaultSelected', { ns: 'appDebug' })} + + @@ -161,9 +169,15 @@ const ConfigModalFormFields: FC = ({ - {t('variableConfig.noDefaultValue', { ns: 'appDebug' })} + + {t('variableConfig.noDefaultValue', { ns: 'appDebug' })} + + {options.filter(option => option.trim() !== '').map(option => ( - {option} + + {option} + + ))} diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index 93d3cf4d24..68a1ee875d 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -11,7 +11,7 @@ import FormGeneration from '@/app/components/base/features/new-feature-panel/mod import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education' import { Button } from '@/app/components/base/ui/button' import { Dialog, DialogContent } from '@/app/components/base/ui/dialog' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select' import { toast } from '@/app/components/base/ui/toast' import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector' import { useDocLink, useLocale } from '@/context/i18n' @@ -129,7 +129,8 @@ const ExternalDataToolModal: FC = ({ {providers.map(option => ( - {option.name} + {option.name} + ))} diff --git a/web/app/components/base/markdown-blocks/form.tsx b/web/app/components/base/markdown-blocks/form.tsx index 7e8dcac0b2..b7643c5cd5 100644 --- a/web/app/components/base/markdown-blocks/form.tsx +++ b/web/app/components/base/markdown-blocks/form.tsx @@ -10,7 +10,7 @@ import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-tim import Input from '@/app/components/base/input' import Textarea from '@/app/components/base/textarea' import { Button } from '@/app/components/base/ui/button' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@/app/components/base/ui/select' enum DATA_FORMAT { TEXT = 'text', @@ -316,7 +316,10 @@ const MarkdownForm = ({ node }: { node: HastElement }) => { {options.map(option => ( - {option} + + {option} + + ))} diff --git a/web/app/components/base/ui/context-menu/index.tsx b/web/app/components/base/ui/context-menu/index.tsx index 663f09e96e..4331f095b3 100644 --- a/web/app/components/base/ui/context-menu/index.tsx +++ b/web/app/components/base/ui/context-menu/index.tsx @@ -5,14 +5,14 @@ import { ContextMenu as BaseContextMenu } from '@base-ui/react/context-menu' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { - menuBackdropClassName, - menuGroupLabelClassName, - menuIndicatorClassName, - menuPopupAnimationClassName, - menuPopupBaseClassName, - menuRowClassName, - menuSeparatorClassName, -} from '@/app/components/base/ui/menu-shared' + overlayBackdropClassName, + overlayGroupLabelClassName, + overlayIndicatorClassName, + overlayPopupAnimationClassName, + overlayPopupBaseClassName, + overlayRowClassName, + overlaySeparatorClassName, +} from '@/app/components/base/ui/overlay-shared' import { parsePlacement } from '@/app/components/base/ui/placement' export const ContextMenu = BaseContextMenu.Root @@ -65,7 +65,7 @@ function renderContextMenuPopup({ return ( {withBackdrop && ( - + )} ) @@ -142,7 +142,7 @@ export function ContextMenuLinkItem({ }: ContextMenuLinkItemProps) { return ( @@ -155,7 +155,7 @@ export function ContextMenuRadioItem({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -167,7 +167,7 @@ export function ContextMenuCheckboxItem({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -179,7 +179,7 @@ export function ContextMenuCheckboxItemIndicator({ }: Omit, 'children'>) { return ( @@ -193,7 +193,7 @@ export function ContextMenuRadioItemIndicator({ }: Omit, 'children'>) { return ( @@ -213,7 +213,7 @@ export function ContextMenuSubTrigger({ }: ContextMenuSubTriggerProps) { return ( {children} @@ -261,7 +261,7 @@ export function ContextMenuGroupLabel({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -273,7 +273,7 @@ export function ContextMenuSeparator({ }: React.ComponentPropsWithoutRef) { return ( ) diff --git a/web/app/components/base/ui/dropdown-menu/index.tsx b/web/app/components/base/ui/dropdown-menu/index.tsx index 83c34ecf27..ca73e8b003 100644 --- a/web/app/components/base/ui/dropdown-menu/index.tsx +++ b/web/app/components/base/ui/dropdown-menu/index.tsx @@ -5,13 +5,13 @@ import { Menu } from '@base-ui/react/menu' import { cn } from '@langgenius/dify-ui/cn' import * as React from 'react' import { - menuGroupLabelClassName, - menuIndicatorClassName, - menuPopupAnimationClassName, - menuPopupBaseClassName, - menuRowClassName, - menuSeparatorClassName, -} from '@/app/components/base/ui/menu-shared' + overlayGroupLabelClassName, + overlayIndicatorClassName, + overlayPopupAnimationClassName, + overlayPopupBaseClassName, + overlayRowClassName, + overlaySeparatorClassName, +} from '@/app/components/base/ui/overlay-shared' import { parsePlacement } from '@/app/components/base/ui/placement' export const DropdownMenu = Menu.Root @@ -26,7 +26,7 @@ export function DropdownMenuRadioItem({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -38,7 +38,7 @@ export function DropdownMenuRadioItemIndicator({ }: Omit, 'children'>) { return ( @@ -52,7 +52,7 @@ export function DropdownMenuCheckboxItem({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -64,7 +64,7 @@ export function DropdownMenuCheckboxItemIndicator({ }: Omit, 'children'>) { return ( @@ -78,7 +78,7 @@ export function DropdownMenuGroupLabel({ }: React.ComponentPropsWithoutRef) { return ( ) @@ -135,8 +135,8 @@ function renderDropdownMenuPopup({ > {children} @@ -235,7 +235,7 @@ export function DropdownMenuItem({ }: DropdownMenuItemProps) { return ( ) @@ -253,7 +253,7 @@ export function DropdownMenuLinkItem({ }: DropdownMenuLinkItemProps) { return ( @@ -266,7 +266,7 @@ export function DropdownMenuSeparator({ }: React.ComponentPropsWithoutRef) { return ( ) diff --git a/web/app/components/base/ui/menu-shared.ts b/web/app/components/base/ui/menu-shared.ts deleted file mode 100644 index b0c379dae2..0000000000 --- a/web/app/components/base/ui/menu-shared.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const menuRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-hidden data-highlighted:bg-state-base-hover data-disabled:cursor-not-allowed data-disabled:opacity-30' -export const menuIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent' -export const menuGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase' -export const menuSeparatorClassName = 'my-1 h-px bg-divider-subtle' -export const menuPopupBaseClassName = 'max-h-(--available-height) overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-hidden focus:outline-hidden focus-visible:outline-hidden backdrop-blur-[5px]' -export const menuPopupAnimationClassName = 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' -export const menuBackdropClassName = 'fixed inset-0 z-1002 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' diff --git a/web/app/components/base/ui/overlay-shared.ts b/web/app/components/base/ui/overlay-shared.ts new file mode 100644 index 0000000000..f21eab44ca --- /dev/null +++ b/web/app/components/base/ui/overlay-shared.ts @@ -0,0 +1,7 @@ +export const overlayRowClassName = 'mx-1 flex h-8 cursor-pointer select-none items-center gap-1 rounded-lg px-2 outline-hidden data-highlighted:bg-state-base-hover data-disabled:cursor-not-allowed data-disabled:opacity-30' +export const overlayIndicatorClassName = 'ml-auto flex shrink-0 items-center text-text-accent' +export const overlayGroupLabelClassName = 'px-3 pb-0.5 pt-1 text-text-tertiary system-xs-medium-uppercase' +export const overlaySeparatorClassName = 'my-1 h-px bg-divider-subtle' +export const overlayPopupBaseClassName = 'max-h-(--available-height) overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-1 text-sm text-text-secondary shadow-lg outline-hidden focus:outline-hidden focus-visible:outline-hidden backdrop-blur-[5px]' +export const overlayPopupAnimationClassName = 'origin-(--transform-origin) transition-[transform,scale,opacity] data-ending-style:scale-95 data-starting-style:scale-95 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' +export const overlayBackdropClassName = 'fixed inset-0 z-1002 bg-transparent transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 motion-reduce:transition-none' diff --git a/web/app/components/base/ui/select/__tests__/index.spec.tsx b/web/app/components/base/ui/select/__tests__/index.spec.tsx index 124eb4d60e..e8083f04b2 100644 --- a/web/app/components/base/ui/select/__tests__/index.spec.tsx +++ b/web/app/components/base/ui/select/__tests__/index.spec.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../index' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '../index' const renderOpenSelect = ({ rootProps = {}, @@ -33,8 +33,14 @@ const renderOpenSelect = ({ }} {...contentProps} > - Seattle - New York + + Seattle + + + + New York + + , ) @@ -50,8 +56,14 @@ describe('Select wrappers', () => { - Seattle - New York + + Seattle + + + + New York + + , @@ -66,22 +78,6 @@ describe('Select wrappers', () => { }) describe('SelectTrigger', () => { - it('should render clear button when clearable is true and loading is false', () => { - renderOpenSelect({ - triggerProps: { clearable: true }, - }) - - expect(screen.getByRole('button', { name: /clear selection/i })).toBeInTheDocument() - }) - - it('should hide clear button when loading is true', () => { - renderOpenSelect({ - triggerProps: { clearable: true, loading: true }, - }) - - expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument() - }) - it('should forward native trigger props when trigger props are provided', () => { renderOpenSelect({ triggerProps: { @@ -94,48 +90,6 @@ describe('Select wrappers', () => { expect(trigger).toBeDisabled() }) - it('should call onClear and stop click propagation when clear button is clicked', () => { - const onClear = vi.fn() - const onTriggerClick = vi.fn() - - renderOpenSelect({ - triggerProps: { - clearable: true, - onClear, - onClick: onTriggerClick, - }, - }) - - fireEvent.click(screen.getByRole('button', { name: /clear selection/i })) - - expect(onClear).toHaveBeenCalledTimes(1) - expect(onTriggerClick).not.toHaveBeenCalled() - }) - - it('should stop mouse down propagation when clear button receives mouse down', () => { - const onTriggerMouseDown = vi.fn() - - renderOpenSelect({ - triggerProps: { - clearable: true, - onMouseDown: onTriggerMouseDown, - }, - }) - - fireEvent.mouseDown(screen.getByRole('button', { name: /clear selection/i })) - - expect(onTriggerMouseDown).not.toHaveBeenCalled() - }) - - it('should not throw when clear button is clicked without onClear handler', () => { - renderOpenSelect({ - triggerProps: { clearable: true }, - }) - - const clearButton = screen.getByRole('button', { name: /clear selection/i }) - expect(() => fireEvent.click(clearButton)).not.toThrow() - }) - it('should apply regular size variant classes by default', () => { renderOpenSelect() @@ -182,26 +136,6 @@ describe('Select wrappers', () => { expect(trigger.className).toContain('data-disabled:data-placeholder:text-components-input-text-disabled') }) - it('should show error icon and apply destructive styling when variant is destructive', () => { - renderOpenSelect({ - triggerProps: { variant: 'destructive' }, - }) - - const trigger = screen.getByRole('combobox', { name: 'city select' }) - expect(trigger.className).toContain('border-components-input-border-destructive') - expect(trigger.className).toContain('bg-components-input-bg-destructive') - const errorIcon = trigger.querySelector('.i-ri-error-warning-line') - expect(errorIcon).toBeInTheDocument() - }) - - it('should hide clear button when variant is destructive even if clearable', () => { - renderOpenSelect({ - triggerProps: { clearable: true, variant: 'destructive' }, - }) - - expect(screen.queryByRole('button', { name: /clear selection/i })).not.toBeInTheDocument() - }) - it('should apply readonly styling via data attributes when Root is readOnly', () => { renderOpenSelect({ rootProps: { readOnly: true }, @@ -236,6 +170,14 @@ describe('Select wrappers', () => { const trigger = screen.getByRole('combobox', { name: 'city select' }) expect(trigger.className).toContain('data-placeholder:text-components-input-text-placeholder') }) + + it('should render built-in chevron icon', () => { + renderOpenSelect() + + const trigger = screen.getByRole('combobox', { name: 'city select' }) + const chevron = trigger.querySelector('.i-ri-arrow-down-s-line') + expect(chevron).toBeInTheDocument() + }) }) describe('SelectContent', () => { @@ -291,7 +233,10 @@ describe('Select wrappers', () => { 'onFocus': onListFocus, }} > - Seattle + + Seattle + + , ) @@ -330,9 +275,13 @@ describe('Select wrappers', () => { - Seattle + + Seattle + + - New York + New York + , @@ -342,5 +291,22 @@ describe('Select wrappers', () => { expect(onValueChange).not.toHaveBeenCalled() }) + + it('should support custom composition with SelectItemText without indicator', () => { + render( + , + ) + + expect(screen.getByRole('option', { name: 'Custom Item' })).toBeInTheDocument() + }) }) }) diff --git a/web/app/components/base/ui/select/index.tsx b/web/app/components/base/ui/select/index.tsx index 1d50c36a0a..81514a9ad5 100644 --- a/web/app/components/base/ui/select/index.tsx +++ b/web/app/components/base/ui/select/index.tsx @@ -1,115 +1,43 @@ 'use client' -import type { VariantProps } from 'class-variance-authority' import type { Placement } from '@/app/components/base/ui/placement' import { Select as BaseSelect } from '@base-ui/react/select' import { cn } from '@langgenius/dify-ui/cn' -import { cva } from 'class-variance-authority' import * as React from 'react' +import { + overlayGroupLabelClassName, + overlaySeparatorClassName, +} from '@/app/components/base/ui/overlay-shared' import { parsePlacement } from '@/app/components/base/ui/placement' export const Select = BaseSelect.Root export const SelectValue = BaseSelect.Value /** @public */ export const SelectGroup = BaseSelect.Group -/** @public */ -export const SelectGroupLabel = BaseSelect.GroupLabel -/** @public */ -export const SelectSeparator = BaseSelect.Separator -const selectTriggerVariants = cva( - '', - { - variants: { - size: { - small: 'h-6 gap-px rounded-md px-[5px] py-0 system-xs-regular', - regular: 'h-8 gap-0.5 rounded-lg px-2 py-1 system-sm-regular', - large: 'h-9 gap-0.5 rounded-[10px] px-2.5 py-1 system-md-regular', - }, - variant: { - default: '', - destructive: 'border border-components-input-border-destructive bg-components-input-bg-destructive shadow-xs hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive', - }, - }, - defaultVariants: { - size: 'regular', - variant: 'default', - }, - }, -) - -const contentPadding: Record = { - small: 'px-[3px] py-1', - regular: 'p-1', - large: 'px-1.5 py-1', +const selectSizeClassName: Record = { + small: 'h-6 gap-px rounded-md px-2 py-1 system-xs-regular', + regular: 'h-8 gap-0.5 rounded-lg px-3 py-2 system-sm-regular', + large: 'h-9 gap-0.5 rounded-[10px] px-4 py-2 system-md-regular', } type SelectTriggerProps = React.ComponentPropsWithoutRef & { - clearable?: boolean - onClear?: () => void - loading?: boolean -} & VariantProps + size?: 'small' | 'regular' | 'large' +} export function SelectTrigger({ className, children, size = 'regular', - variant = 'default', - clearable = false, - onClear, - loading = false, ...props }: SelectTriggerProps) { - const paddingClass = contentPadding[size ?? 'regular'] - const isDestructive = variant === 'destructive' - - let trailingIcon: React.ReactNode = null - if (loading) { - trailingIcon = ( -