refactor(web): redesign SelectTrigger as pure re-export and migrate WorkplaceSelector to Select

SelectTrigger was a heavily styled function coupling ARIA trigger
behavior with form-input appearance (input bg, size variants, built-in
chevron, clearable/loading). This made it unusable for non-form contexts
like WorkplaceSelector and inconsistent with DropdownMenuTrigger which
is a pure re-export.

- SelectTrigger is now a pure re-export of BaseSelect.Trigger (aligned
  with DropdownMenuTrigger pattern)
- Added SelectIcon re-export for composed triggers needing chevron state
- Removed SelectPrimitiveTrigger, selectTriggerVariants, contentPadding,
  and SelectTriggerProps
- 3 form callers migrated to inline composition with className
- WorkplaceSelector migrated from @headlessui/react Menu to base-ui
  Select with correct listbox semantics, fixing the focus ring issue

Made-with: Cursor
This commit is contained in:
yyh
2026-04-16 11:06:03 +08:00
parent cf4d7afb9c
commit f734c35443
5 changed files with 103 additions and 155 deletions

View File

@ -11,6 +11,7 @@ import Textarea from '@/app/components/base/textarea'
import {
Select,
SelectContent,
SelectIcon,
SelectItem,
SelectTrigger,
SelectValue,
@ -134,8 +135,11 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
{type === InputVarType.checkbox && (
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
<Select value={checkboxDefaultSelectValue} onValueChange={value => onPayloadChange('default')(value === CHECKBOX_DEFAULT_TRUE_VALUE)}>
<SelectTrigger size="large" className="w-full">
<SelectValue placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
<SelectTrigger className="group flex h-9 w-full items-center gap-0.5 rounded-[10px] bg-components-input-bg-normal px-2.5 py-1 system-md-regular text-components-input-text-filled outline-hidden hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-placeholder:text-components-input-text-placeholder">
<SelectValue className="min-w-0 grow truncate px-1.5 py-1" placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
<SelectIcon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary data-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden />
</SelectIcon>
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
<SelectItem value={CHECKBOX_DEFAULT_TRUE_VALUE}>{t('variableConfig.startChecked', { ns: 'appDebug' })}</SelectItem>
@ -157,8 +161,11 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
value={tempPayload.default ? String(tempPayload.default) : EMPTY_SELECT_VALUE}
onValueChange={value => onPayloadChange('default')(value === EMPTY_SELECT_VALUE ? undefined : value)}
>
<SelectTrigger size="large" className="w-full">
<SelectValue placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
<SelectTrigger className="group flex h-9 w-full items-center gap-0.5 rounded-[10px] bg-components-input-bg-normal px-2.5 py-1 system-md-regular text-components-input-text-filled outline-hidden hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-placeholder:text-components-input-text-placeholder">
<SelectValue className="min-w-0 grow truncate px-1.5 py-1" placeholder={t('variableConfig.selectDefaultValue', { ns: 'appDebug' })} />
<SelectIcon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary data-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden />
</SelectIcon>
</SelectTrigger>
<SelectContent listClassName="max-h-[140px] overflow-y-auto">
<SelectItem value={EMPTY_SELECT_VALUE}>{t('variableConfig.noDefaultValue', { ns: 'appDebug' })}</SelectItem>

View File

@ -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, SelectIcon, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
enum DATA_FORMAT {
TEXT = 'text',
@ -311,8 +311,11 @@ const MarkdownForm = ({ node }: { node: HastElement }) => {
defaultValue={formValues[name] as string | undefined}
onValueChange={val => updateValue(name, val as string)}
>
<SelectTrigger className="w-full">
<SelectValue />
<SelectTrigger className="group flex h-8 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 system-sm-regular text-components-input-text-filled outline-hidden hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-placeholder:text-components-input-text-placeholder">
<SelectValue className="min-w-0 grow truncate p-1" />
<SelectIcon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary data-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden />
</SelectIcon>
</SelectTrigger>
<SelectContent>
{options.map(option => (

View File

@ -1,127 +1,46 @@
'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 { parsePlacement } from '@/app/components/base/ui/placement'
export const Select = BaseSelect.Root
export const SelectValue = BaseSelect.Value
export const SelectTrigger = BaseSelect.Trigger
export const SelectIcon = BaseSelect.Icon
/** @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',
},
},
)
export const SelectPrimitiveItem = BaseSelect.Item
export const SelectItemText = BaseSelect.ItemText
const contentPadding: Record<string, string> = {
small: 'px-[3px] py-1',
regular: 'p-1',
large: 'px-1.5 py-1',
export function SelectGroupLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof BaseSelect.GroupLabel>) {
return (
<BaseSelect.GroupLabel
className={cn('px-3 pt-1 pb-0.5 system-xs-medium-uppercase text-text-tertiary', className)}
{...props}
/>
)
}
type SelectTriggerProps = React.ComponentPropsWithoutRef<typeof BaseSelect.Trigger> & {
clearable?: boolean
onClear?: () => void
loading?: boolean
} & VariantProps<typeof selectTriggerVariants>
export function SelectTrigger({
export function SelectItemIndicator({
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 = (
<span className="shrink-0 text-text-quaternary" aria-hidden="true">
<span className="i-ri-loader-4-line h-3.5 w-3.5 animate-spin" />
</span>
)
}
else if (isDestructive) {
trailingIcon = (
<span className="shrink-0 text-text-destructive-secondary" aria-hidden="true">
<span className="i-ri-error-warning-line h-4 w-4" />
</span>
)
}
else if (clearable) {
trailingIcon = (
<span
role="button"
aria-label="Clear selection"
tabIndex={-1}
className="shrink-0 cursor-pointer text-text-quaternary group-data-disabled:hidden group-data-readonly:hidden hover:text-text-secondary"
onClick={(e) => {
e.stopPropagation()
onClear?.()
}}
onMouseDown={e => e.stopPropagation()}
>
<span className="i-ri-close-circle-fill h-3.5 w-3.5" aria-hidden="true" />
</span>
)
}
else {
trailingIcon = (
<BaseSelect.Icon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary group-data-readonly:hidden data-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />
</BaseSelect.Icon>
)
}
}: Omit<React.ComponentPropsWithoutRef<typeof BaseSelect.ItemIndicator>, 'children'>) {
return (
<BaseSelect.Trigger
className={cn(
'group relative flex w-full items-center border-0 bg-components-input-bg-normal text-left text-components-input-text-filled outline-hidden',
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt',
'data-placeholder:text-components-input-text-placeholder',
selectTriggerVariants({ size, variant }),
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
'data-disabled:data-placeholder:text-components-input-text-disabled',
className,
)}
<BaseSelect.ItemIndicator
className={cn('flex shrink-0 items-center text-text-accent', className)}
{...props}
>
<span className={cn('min-w-0 grow truncate', paddingClass)}>
{children}
</span>
{trailingIcon}
</BaseSelect.Trigger>
<span className="i-ri-check-line h-4 w-4" aria-hidden />
</BaseSelect.ItemIndicator>
)
}

View File

@ -1,9 +1,16 @@
import type { Plan } from '@/app/components/billing/type'
import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react'
import { cn } from '@langgenius/dify-ui/cn'
import { RiArrowDownSLine } from '@remixicon/react'
import { Fragment } from 'react'
import { useTranslation } from 'react-i18next'
import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItemIndicator,
SelectItemText,
SelectPrimitiveItem,
SelectTrigger,
} from '@/app/components/base/ui/select'
import { toast } from '@/app/components/base/ui/toast'
import PlanBadge from '@/app/components/header/plan-badge'
import { useWorkspacesContext } from '@/context/workspace-context'
@ -27,49 +34,58 @@ const WorkplaceSelector = () => {
}
}
return (
<Menu as="div" className="min-w-0">
{({ open }) => (
<>
<MenuButton className={cn(`
group flex w-full cursor-pointer items-center
p-0.5 hover:bg-state-base-hover ${open && 'bg-state-base-hover'} rounded-[10px]
`)}
>
<div className="mr-1.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px] max-[800px]:mr-0">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="flex min-w-0 items-center">
<div className="max-w-[149px] min-w-0 truncate system-sm-medium text-text-secondary max-[800px]:hidden">{currentWorkspace?.name}</div>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-secondary" />
</div>
</MenuButton>
<Transition as={Fragment} enter="transition ease-out duration-100" enterFrom="transform opacity-0 scale-95" enterTo="transform opacity-100 scale-100" leave="transition ease-in duration-75" leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95">
<MenuItems
anchor="bottom start"
className={cn(`
shadows-shadow-lg absolute left-[-15px] z-[1000] mt-1 flex max-h-[400px] w-[280px] flex-col items-start overflow-y-auto
rounded-xl bg-components-panel-bg-blur backdrop-blur-[5px]
`)}
<Select
value={currentWorkspace?.id ?? ''}
onValueChange={(value) => {
if (value)
void handleSwitchWorkspace(value)
}}
>
<SelectTrigger
className={cn(
'group flex min-w-0 cursor-pointer items-center rounded-[10px] p-0.5 outline-hidden',
'hover:bg-state-base-hover data-popup-open:bg-state-base-hover',
)}
>
<div className="mr-1.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px] max-[800px]:mr-0">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="flex min-w-0 items-center">
<div className="max-w-[149px] min-w-0 truncate system-sm-medium text-text-secondary max-[800px]:hidden">{currentWorkspace?.name}</div>
<span aria-hidden className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-secondary" />
</div>
</SelectTrigger>
<SelectContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[280px] max-h-[400px]"
>
<SelectGroup>
<SelectGroupLabel>
{t('userProfile.workspace', { ns: 'common' })}
</SelectGroupLabel>
{workspaces.map(workspace => (
<SelectPrimitiveItem
key={workspace.id}
value={workspace.id}
className={cn(
'flex h-8 cursor-pointer items-center gap-1 rounded-lg px-2 outline-hidden',
'data-highlighted:bg-state-base-hover',
)}
>
<div className="flex w-full flex-col items-start self-stretch rounded-xl border-[0.5px] border-components-panel-border p-1 pb-2 shadow-lg">
<div className="flex items-start self-stretch px-3 pt-1 pb-0.5">
<span className="flex-1 system-xs-medium-uppercase text-text-tertiary">{t('userProfile.workspace', { ns: 'common' })}</span>
</div>
{workspaces.map(workspace => (
<div className="flex items-center gap-2 self-stretch rounded-lg py-1 pr-2 pl-3 hover:bg-state-base-hover" key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">{workspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="line-clamp-1 grow cursor-pointer overflow-hidden system-md-regular text-ellipsis text-text-secondary">{workspace.name}</div>
<PlanBadge plan={workspace.plan as Plan} />
</div>
))}
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px]">
<span className="h-6 bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">{workspace.name[0]?.toLocaleUpperCase()}</span>
</div>
</MenuItems>
</Transition>
</>
)}
</Menu>
<SelectItemText className="min-w-0 grow truncate px-1 system-md-regular text-text-secondary">
{workspace.name}
</SelectItemText>
<PlanBadge plan={workspace.plan as Plan} />
<SelectItemIndicator />
</SelectPrimitiveItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
export default WorkplaceSelector

View File

@ -10,7 +10,7 @@ import PromptEditor from '@/app/components/base/prompt-editor'
import Radio from '@/app/components/base/radio'
import Switch from '@/app/components/base/switch'
import TagInput from '@/app/components/base/tag-input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Select, SelectContent, SelectIcon, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Slider } from '@/app/components/base/ui/slider'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { BlockEnum } from '@/app/components/workflow/types'
@ -294,8 +294,11 @@ function ParameterItem({
value={renderValue as string}
onValueChange={v => handleInputChange(v ?? undefined)}
>
<SelectTrigger className="w-full">
<SelectValue />
<SelectTrigger className="group flex h-8 w-full items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 system-sm-regular text-components-input-text-filled outline-hidden hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-placeholder:text-components-input-text-placeholder">
<SelectValue className="min-w-0 grow truncate p-1" />
<SelectIcon className="shrink-0 text-text-quaternary transition-colors group-hover:text-text-secondary data-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden />
</SelectIcon>
</SelectTrigger>
<SelectContent>
{parameterRule.options!.map(option => (