dify
This commit is contained in:
166
dify/web/app/components/base/select/custom.tsx
Normal file
166
dify/web/app/components/base/select/custom.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import type {
|
||||
PortalToFollowElemOptions,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type Option = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type CustomSelectProps<T extends Option> = {
|
||||
options: T[]
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
containerProps?: PortalToFollowElemOptions & {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
triggerProps?: {
|
||||
className?: string
|
||||
},
|
||||
popupProps?: {
|
||||
wrapperClassName?: string
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
title?: string
|
||||
},
|
||||
CustomTrigger?: (option: T | undefined, open: boolean) => React.JSX.Element
|
||||
CustomOption?: (option: T, selected: boolean) => React.JSX.Element
|
||||
}
|
||||
const CustomSelect = <T extends Option>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
containerProps,
|
||||
triggerProps,
|
||||
popupProps,
|
||||
CustomTrigger,
|
||||
CustomOption,
|
||||
}: CustomSelectProps<T>) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
open,
|
||||
onOpenChange,
|
||||
placement,
|
||||
offset,
|
||||
triggerPopupSameWidth = true,
|
||||
} = containerProps || {}
|
||||
const {
|
||||
className: triggerClassName,
|
||||
} = triggerProps || {}
|
||||
const {
|
||||
wrapperClassName: popupWrapperClassName,
|
||||
className: popupClassName,
|
||||
itemClassName: popupItemClassName,
|
||||
} = popupProps || {}
|
||||
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
const mergedOpen = open ?? localOpen
|
||||
|
||||
const handleOpenChange = useCallback((openValue: boolean) => {
|
||||
onOpenChange?.(openValue)
|
||||
setLocalOpen(openValue)
|
||||
}, [onOpenChange])
|
||||
|
||||
const selectedOption = options.find(option => option.value === value)
|
||||
const triggerText = selectedOption?.label || t('common.placeholder.select')
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement || 'bottom-start'}
|
||||
offset={offset || 4}
|
||||
open={mergedOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
triggerPopupSameWidth={triggerPopupSameWidth}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => handleOpenChange(!mergedOpen)}
|
||||
asChild
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'system-sm-regular group flex h-8 cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-2 text-components-input-text-filled hover:bg-state-base-hover-alt',
|
||||
mergedOpen && 'bg-state-base-hover-alt',
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
{CustomTrigger ? CustomTrigger(selectedOption, mergedOpen) : (
|
||||
<>
|
||||
<div
|
||||
className='grow'
|
||||
title={triggerText}
|
||||
>
|
||||
{triggerText}
|
||||
</div>
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
mergedOpen && 'text-text-secondary',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={cn(
|
||||
'z-10',
|
||||
popupWrapperClassName,
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg shadow-shadow-shadow-5',
|
||||
popupClassName,
|
||||
)}
|
||||
>
|
||||
{
|
||||
options.map((option) => {
|
||||
const selected = value === option.value
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'system-sm-medium flex h-8 cursor-pointer items-center rounded-lg px-2 text-text-secondary hover:bg-state-base-hover',
|
||||
popupItemClassName,
|
||||
)}
|
||||
title={option.label}
|
||||
onClick={() => {
|
||||
onChange?.(option.value)
|
||||
handleOpenChange(false)
|
||||
}}
|
||||
>
|
||||
{CustomOption ? CustomOption(option, selected) : (
|
||||
<>
|
||||
<div className='mr-1 grow truncate px-1'>
|
||||
{option.label}
|
||||
</div>
|
||||
{
|
||||
selected && <RiCheckLine className='h-4 w-4 shrink-0 text-text-accent' />
|
||||
}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomSelect
|
||||
547
dify/web/app/components/base/select/index.stories.tsx
Normal file
547
dify/web/app/components/base/select/index.stories.tsx
Normal file
@@ -0,0 +1,547 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs'
|
||||
import { useState } from 'react'
|
||||
import Select, { PortalSelect, SimpleSelect } from '.'
|
||||
import type { Item } from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/Select',
|
||||
component: SimpleSelect,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Select component with three variants: Select (with search), SimpleSelect (basic dropdown), and PortalSelect (portal-based positioning). Built on Headless UI.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
notClearable: {
|
||||
control: 'boolean',
|
||||
description: 'Hide clear button',
|
||||
},
|
||||
hideChecked: {
|
||||
control: 'boolean',
|
||||
description: 'Hide check icon on selected item',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
onSelect: (item) => {
|
||||
console.log('Selected:', item)
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof SimpleSelect>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const fruits: Item[] = [
|
||||
{ value: 'apple', name: 'Apple' },
|
||||
{ value: 'banana', name: 'Banana' },
|
||||
{ value: 'cherry', name: 'Cherry' },
|
||||
{ value: 'date', name: 'Date' },
|
||||
{ value: 'elderberry', name: 'Elderberry' },
|
||||
]
|
||||
|
||||
const countries: Item[] = [
|
||||
{ value: 'us', name: 'United States' },
|
||||
{ value: 'uk', name: 'United Kingdom' },
|
||||
{ value: 'ca', name: 'Canada' },
|
||||
{ value: 'au', name: 'Australia' },
|
||||
{ value: 'de', name: 'Germany' },
|
||||
{ value: 'fr', name: 'France' },
|
||||
{ value: 'jp', name: 'Japan' },
|
||||
{ value: 'cn', name: 'China' },
|
||||
]
|
||||
|
||||
// SimpleSelect Demo
|
||||
const SimpleSelectDemo = (args: any) => {
|
||||
const [selected, setSelected] = useState(args.defaultValue || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
{...args}
|
||||
items={fruits}
|
||||
defaultValue={selected}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
/>
|
||||
{selected && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default SimpleSelect
|
||||
export const Default: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'apple',
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
|
||||
// With placeholder (no selection)
|
||||
export const WithPlaceholder: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Choose an option...',
|
||||
defaultValue: '',
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'banana',
|
||||
disabled: true,
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
|
||||
// Not clearable
|
||||
export const NotClearable: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'cherry',
|
||||
notClearable: true,
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
|
||||
// Hide checked icon
|
||||
export const HideChecked: Story = {
|
||||
render: args => <SimpleSelectDemo {...args} />,
|
||||
args: {
|
||||
placeholder: 'Select a fruit...',
|
||||
defaultValue: 'apple',
|
||||
hideChecked: true,
|
||||
items: [],
|
||||
},
|
||||
}
|
||||
|
||||
// Select with search
|
||||
const WithSearchDemo = () => {
|
||||
const [selected, setSelected] = useState('us')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<Select
|
||||
items={countries}
|
||||
defaultValue={selected}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value as string)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
allowSearch={true}
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithSearch: Story = {
|
||||
render: () => <WithSearchDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// PortalSelect
|
||||
const PortalSelectVariantDemo = () => {
|
||||
const [selected, setSelected] = useState('apple')
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<PortalSelect
|
||||
value={selected}
|
||||
items={fruits}
|
||||
onSelect={(item) => {
|
||||
setSelected(item.value as string)
|
||||
console.log('Selected:', item)
|
||||
}}
|
||||
placeholder="Select a fruit..."
|
||||
/>
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Selected: <span className="font-semibold">{selected}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PortalSelectVariant: Story = {
|
||||
render: () => <PortalSelectVariantDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Custom render option
|
||||
const CustomRenderOptionDemo = () => {
|
||||
const [selected, setSelected] = useState('us')
|
||||
|
||||
const countriesWithFlags = [
|
||||
{ value: 'us', name: 'United States', flag: '🇺🇸' },
|
||||
{ value: 'uk', name: 'United Kingdom', flag: '🇬🇧' },
|
||||
{ value: 'ca', name: 'Canada', flag: '🇨🇦' },
|
||||
{ value: 'au', name: 'Australia', flag: '🇦🇺' },
|
||||
{ value: 'de', name: 'Germany', flag: '🇩🇪' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={countriesWithFlags}
|
||||
defaultValue={selected}
|
||||
onSelect={item => setSelected(item.value as string)}
|
||||
renderOption={({ item, selected }) => (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{item.flag}</span>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{selected && <span className="text-blue-600">✓</span>}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CustomRenderOption: Story = {
|
||||
render: () => <CustomRenderOptionDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Loading state
|
||||
export const LoadingState: Story = {
|
||||
render: () => {
|
||||
return (
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={[]}
|
||||
defaultValue=""
|
||||
onSelect={() => undefined}
|
||||
placeholder="Loading options..."
|
||||
isLoading={true}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Form field
|
||||
const FormFieldDemo = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
country: 'us',
|
||||
language: 'en',
|
||||
timezone: 'pst',
|
||||
})
|
||||
|
||||
const languages = [
|
||||
{ value: 'en', name: 'English' },
|
||||
{ value: 'es', name: 'Spanish' },
|
||||
{ value: 'fr', name: 'French' },
|
||||
{ value: 'de', name: 'German' },
|
||||
{ value: 'zh', name: 'Chinese' },
|
||||
]
|
||||
|
||||
const timezones = [
|
||||
{ value: 'pst', name: 'Pacific Time (PST)' },
|
||||
{ value: 'mst', name: 'Mountain Time (MST)' },
|
||||
{ value: 'cst', name: 'Central Time (CST)' },
|
||||
{ value: 'est', name: 'Eastern Time (EST)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">User Preferences</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Country</label>
|
||||
<SimpleSelect
|
||||
items={countries}
|
||||
defaultValue={formData.country}
|
||||
onSelect={item => setFormData({ ...formData, country: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Language</label>
|
||||
<SimpleSelect
|
||||
items={languages}
|
||||
defaultValue={formData.language}
|
||||
onSelect={item => setFormData({ ...formData, language: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Timezone</label>
|
||||
<SimpleSelect
|
||||
items={timezones}
|
||||
defaultValue={formData.timezone}
|
||||
onSelect={item => setFormData({ ...formData, timezone: item.value as string })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 rounded-lg bg-gray-50 p-3 text-xs text-gray-700">
|
||||
<div><strong>Country:</strong> {formData.country}</div>
|
||||
<div><strong>Language:</strong> {formData.language}</div>
|
||||
<div><strong>Timezone:</strong> {formData.timezone}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FormField: Story = {
|
||||
render: () => <FormFieldDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Filter selector
|
||||
const FilterSelectorDemo = () => {
|
||||
const [status, setStatus] = useState('all')
|
||||
const [priority, setPriority] = useState('all')
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'all', name: 'All Status' },
|
||||
{ value: 'active', name: 'Active' },
|
||||
{ value: 'pending', name: 'Pending' },
|
||||
{ value: 'completed', name: 'Completed' },
|
||||
{ value: 'cancelled', name: 'Cancelled' },
|
||||
]
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'all', name: 'All Priorities' },
|
||||
{ value: 'high', name: 'High Priority' },
|
||||
{ value: 'medium', name: 'Medium Priority' },
|
||||
{ value: 'low', name: 'Low Priority' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Task Filters</h3>
|
||||
<div className="mb-6 flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Status</label>
|
||||
<SimpleSelect
|
||||
items={statusOptions}
|
||||
defaultValue={status}
|
||||
onSelect={item => setStatus(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Priority</label>
|
||||
<SimpleSelect
|
||||
items={priorityOptions}
|
||||
defaultValue={priority}
|
||||
onSelect={item => setPriority(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-blue-50 p-4 text-sm">
|
||||
<div className="mb-2 font-medium text-gray-700">Active Filters:</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="rounded bg-blue-200 px-2 py-1 text-xs text-blue-800">
|
||||
Status: {status}
|
||||
</span>
|
||||
<span className="rounded bg-blue-200 px-2 py-1 text-xs text-blue-800">
|
||||
Priority: {priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FilterSelector: Story = {
|
||||
render: () => <FilterSelectorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Version selector with badge
|
||||
const VersionSelectorDemo = () => {
|
||||
const [selectedVersion, setSelectedVersion] = useState('2.1.0')
|
||||
|
||||
const versions = [
|
||||
{ value: '3.0.0', name: 'v3.0.0 (Beta)' },
|
||||
{ value: '2.1.0', name: 'v2.1.0 (Latest)' },
|
||||
{ value: '2.0.5', name: 'v2.0.5' },
|
||||
{ value: '2.0.4', name: 'v2.0.4' },
|
||||
{ value: '1.9.8', name: 'v1.9.8' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Select Version</h3>
|
||||
<PortalSelect
|
||||
value={selectedVersion}
|
||||
items={versions}
|
||||
onSelect={item => setSelectedVersion(item.value as string)}
|
||||
installedValue="2.0.5"
|
||||
placeholder="Choose version..."
|
||||
/>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-3 text-sm text-gray-700">
|
||||
{selectedVersion !== '2.0.5' && (
|
||||
<div className="mb-2 text-yellow-600">
|
||||
⚠️ Version change detected
|
||||
</div>
|
||||
)}
|
||||
<div>Current: <strong>{selectedVersion}</strong></div>
|
||||
<div className="mt-1 text-xs text-gray-500">Installed: 2.0.5</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VersionSelector: Story = {
|
||||
render: () => <VersionSelectorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Settings dropdown
|
||||
const SettingsDropdownDemo = () => {
|
||||
const [theme, setTheme] = useState('light')
|
||||
const [fontSize, setFontSize] = useState('medium')
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light', name: '☀️ Light Mode' },
|
||||
{ value: 'dark', name: '🌙 Dark Mode' },
|
||||
{ value: 'auto', name: '🔄 Auto (System)' },
|
||||
]
|
||||
|
||||
const fontSizeOptions = [
|
||||
{ value: 'small', name: 'Small (12px)' },
|
||||
{ value: 'medium', name: 'Medium (14px)' },
|
||||
{ value: 'large', name: 'Large (16px)' },
|
||||
{ value: 'xlarge', name: 'Extra Large (18px)' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ width: '400px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Display Settings</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Theme</label>
|
||||
<SimpleSelect
|
||||
items={themeOptions}
|
||||
defaultValue={theme}
|
||||
onSelect={item => setTheme(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Font Size</label>
|
||||
<SimpleSelect
|
||||
items={fontSizeOptions}
|
||||
defaultValue={fontSize}
|
||||
onSelect={item => setFontSize(item.value as string)}
|
||||
notClearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SettingsDropdown: Story = {
|
||||
render: () => <SettingsDropdownDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Comparison of variants
|
||||
const VariantComparisonDemo = () => {
|
||||
const [simple, setSimple] = useState('apple')
|
||||
const [withSearch, setWithSearch] = useState('us')
|
||||
const [portal, setPortal] = useState('banana')
|
||||
|
||||
return (
|
||||
<div style={{ width: '700px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-6 text-lg font-semibold">Select Variants Comparison</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">SimpleSelect (Basic)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<SimpleSelect
|
||||
items={fruits}
|
||||
defaultValue={simple}
|
||||
onSelect={item => setSimple(item.value as string)}
|
||||
placeholder="Choose a fruit..."
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Standard dropdown without search</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">Select (With Search)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<Select
|
||||
items={countries}
|
||||
defaultValue={withSearch}
|
||||
onSelect={item => setWithSearch(item.value as string)}
|
||||
allowSearch={true}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Dropdown with search/filter capability</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-2 text-sm font-medium text-gray-700">PortalSelect (Portal-based)</h4>
|
||||
<div style={{ width: '300px' }}>
|
||||
<PortalSelect
|
||||
value={portal}
|
||||
items={fruits}
|
||||
onSelect={item => setPortal(item.value as string)}
|
||||
placeholder="Choose a fruit..."
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">Portal-based positioning for better overflow handling</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VariantComparison: Story = {
|
||||
render: () => <VariantComparisonDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Interactive playground
|
||||
const PlaygroundDemo = () => {
|
||||
const [selected, setSelected] = useState('apple')
|
||||
|
||||
return (
|
||||
<div style={{ width: '350px' }}>
|
||||
<SimpleSelect
|
||||
items={fruits}
|
||||
defaultValue={selected}
|
||||
onSelect={item => setSelected(item.value as string)}
|
||||
placeholder="Select an option..."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PlaygroundDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
416
dify/web/app/components/base/select/index.tsx
Normal file
416
dify/web/app/components/base/select/index.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Combobox, ComboboxButton, ComboboxInput, ComboboxOption, ComboboxOptions, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
|
||||
import { ChevronDownIcon, ChevronUpIcon, XMarkIcon } from '@heroicons/react/20/solid'
|
||||
import Badge from '../badge/index'
|
||||
import { RiCheckLine, RiLoader4Line } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
|
||||
const defaultItems = [
|
||||
{ value: 1, name: 'option1' },
|
||||
{ value: 2, name: 'option2' },
|
||||
{ value: 3, name: 'option3' },
|
||||
{ value: 4, name: 'option4' },
|
||||
{ value: 5, name: 'option5' },
|
||||
{ value: 6, name: 'option6' },
|
||||
{ value: 7, name: 'option7' },
|
||||
]
|
||||
|
||||
export type Item = {
|
||||
value: number | string
|
||||
name: string
|
||||
isGroup?: boolean
|
||||
disabled?: boolean
|
||||
extra?: React.ReactNode
|
||||
} & Record<string, any>
|
||||
|
||||
export type ISelectProps = {
|
||||
className?: string
|
||||
wrapperClassName?: string
|
||||
renderTrigger?: (value: Item | null, isOpen: boolean) => React.JSX.Element | null
|
||||
items?: Item[]
|
||||
defaultValue?: number | string
|
||||
disabled?: boolean
|
||||
onSelect: (value: Item) => void
|
||||
allowSearch?: boolean
|
||||
bgClassName?: string
|
||||
placeholder?: string
|
||||
overlayClassName?: string
|
||||
optionWrapClassName?: string
|
||||
optionClassName?: string
|
||||
hideChecked?: boolean
|
||||
notClearable?: boolean
|
||||
renderOption?: ({
|
||||
item,
|
||||
selected,
|
||||
}: {
|
||||
item: Item
|
||||
selected: boolean
|
||||
}) => React.ReactNode
|
||||
isLoading?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
const Select: FC<ISelectProps> = ({
|
||||
className,
|
||||
items = defaultItems,
|
||||
defaultValue = 1,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
allowSearch = true,
|
||||
bgClassName = 'bg-components-input-bg-normal',
|
||||
overlayClassName,
|
||||
optionClassName,
|
||||
renderOption,
|
||||
}) => {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
|
||||
// Ensure selectedItem is properly set when defaultValue or items change
|
||||
useEffect(() => {
|
||||
let defaultSelect = null
|
||||
// Handle cases where defaultValue might be undefined, null, or empty string
|
||||
defaultSelect = (defaultValue && items.find((item: Item) => item.value === defaultValue)) || null
|
||||
setSelectedItem(defaultSelect)
|
||||
}, [defaultValue, items])
|
||||
|
||||
const filteredItems: Item[]
|
||||
= query === ''
|
||||
? items
|
||||
: items.filter((item) => {
|
||||
return item.name.toLowerCase().includes(query.toLowerCase())
|
||||
})
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
as="div"
|
||||
disabled={disabled}
|
||||
value={selectedItem}
|
||||
className={className}
|
||||
onChange={(value: Item) => {
|
||||
if (!disabled) {
|
||||
setSelectedItem(value)
|
||||
setOpen(false)
|
||||
onSelect(value)
|
||||
}
|
||||
}}>
|
||||
<div className={classNames('relative')}>
|
||||
<div className='group text-text-secondary'>
|
||||
{allowSearch
|
||||
? <ComboboxInput
|
||||
className={`w-full rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
onChange={(event) => {
|
||||
if (!disabled)
|
||||
setQuery(event.target.value)
|
||||
}}
|
||||
displayValue={(item: Item) => item?.name}
|
||||
/>
|
||||
: <ComboboxButton onClick={
|
||||
() => {
|
||||
if (!disabled)
|
||||
setOpen(!open)
|
||||
}
|
||||
} className={classNames(`flex h-9 w-full items-center rounded-lg border-0 ${bgClassName} py-1.5 pl-3 pr-10 shadow-sm focus-visible:bg-state-base-hover focus-visible:outline-none group-hover:bg-state-base-hover sm:text-sm sm:leading-6`, optionClassName)}>
|
||||
<div className='w-0 grow truncate text-left' title={selectedItem?.name}>{selectedItem?.name}</div>
|
||||
</ComboboxButton>}
|
||||
<ComboboxButton className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" onClick={
|
||||
() => {
|
||||
if (!disabled)
|
||||
setOpen(!open)
|
||||
}
|
||||
}>
|
||||
{open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
|
||||
</ComboboxButton>
|
||||
</div>
|
||||
|
||||
{(filteredItems.length > 0 && open) && (
|
||||
<ComboboxOptions className={`absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm ${overlayClassName}`}>
|
||||
{filteredItems.map((item: Item) => (
|
||||
<ComboboxOption
|
||||
key={item.value}
|
||||
value={item}
|
||||
className={({ active }: { active: boolean }) =>
|
||||
classNames(
|
||||
'relative cursor-default select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
|
||||
active ? 'bg-state-base-hover' : '',
|
||||
optionClassName,
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ /* active, */ selected }) => (
|
||||
<>
|
||||
{renderOption
|
||||
? renderOption({ item, selected })
|
||||
: (
|
||||
<>
|
||||
<span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute inset-y-0 right-0 flex items-center pr-4 text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ComboboxOption>
|
||||
))}
|
||||
</ComboboxOptions>
|
||||
)}
|
||||
</div>
|
||||
</Combobox >
|
||||
)
|
||||
}
|
||||
|
||||
const SimpleSelect: FC<ISelectProps> = ({
|
||||
className,
|
||||
wrapperClassName = '',
|
||||
renderTrigger,
|
||||
items = defaultItems,
|
||||
defaultValue = 1,
|
||||
disabled = false,
|
||||
onSelect,
|
||||
onOpenChange,
|
||||
placeholder,
|
||||
optionWrapClassName,
|
||||
optionClassName,
|
||||
hideChecked,
|
||||
notClearable,
|
||||
renderOption,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const localPlaceholder = placeholder || t('common.placeholder.select')
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null)
|
||||
|
||||
// Enhanced: Preserve user selection, only reset when necessary
|
||||
useEffect(() => {
|
||||
// Only reset if no current selection or current selection is invalid
|
||||
const isCurrentSelectionValid = selectedItem && items.some(item => item.value === selectedItem.value)
|
||||
|
||||
if (!isCurrentSelectionValid) {
|
||||
let defaultSelect = null
|
||||
// Handle cases where defaultValue might be undefined, null, or empty string
|
||||
defaultSelect = items.find((item: Item) => item.value === defaultValue) ?? null
|
||||
setSelectedItem(defaultSelect)
|
||||
}
|
||||
}, [defaultValue, items, selectedItem])
|
||||
|
||||
const listboxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<Listbox ref={listboxRef}
|
||||
value={selectedItem}
|
||||
onChange={(value: Item) => {
|
||||
if (!disabled) {
|
||||
setSelectedItem(value)
|
||||
onSelect(value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ open }) => (
|
||||
<div className={classNames('group/simple-select relative h-9', wrapperClassName)}>
|
||||
{renderTrigger && <ListboxButton className='w-full'>{renderTrigger(selectedItem, open)}</ListboxButton>}
|
||||
{!renderTrigger && (
|
||||
<ListboxButton onClick={() => {
|
||||
onOpenChange?.(open)
|
||||
}} className={classNames(`flex h-full w-full items-center rounded-lg border-0 bg-components-input-bg-normal pl-3 pr-10 focus-visible:bg-state-base-hover-alt focus-visible:outline-none group-hover/simple-select:bg-state-base-hover-alt sm:text-sm sm:leading-6 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`, className)}>
|
||||
<span className={classNames('system-sm-regular block truncate text-left text-components-input-text-filled', !selectedItem?.name && 'text-components-input-text-placeholder')}>{selectedItem?.name ?? localPlaceholder}</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
{isLoading ? <RiLoader4Line className='h-3.5 w-3.5 animate-spin text-text-secondary' />
|
||||
: (selectedItem && !notClearable)
|
||||
? (
|
||||
<XMarkIcon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedItem(null)
|
||||
onSelect({ name: '', value: '' })
|
||||
}}
|
||||
className="h-4 w-4 cursor-pointer text-text-quaternary"
|
||||
aria-hidden="false"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
open ? (
|
||||
<ChevronUpIcon
|
||||
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDownIcon
|
||||
className="h-4 w-4 text-text-quaternary group-hover/simple-select:text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</ListboxButton>
|
||||
)}
|
||||
|
||||
{(!disabled) && (
|
||||
<ListboxOptions className={classNames('absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur px-1 py-1 text-base shadow-lg backdrop-blur-sm focus:outline-none sm:text-sm', optionWrapClassName)}>
|
||||
{items.map((item: Item) =>
|
||||
item.isGroup ? (
|
||||
<div
|
||||
key={item.value}
|
||||
className="select-none px-3 py-1.5 text-xs font-medium uppercase tracking-wide text-text-tertiary"
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
) : (
|
||||
<ListboxOption
|
||||
key={item.value}
|
||||
className={
|
||||
classNames(
|
||||
'relative cursor-pointer select-none rounded-lg py-2 pl-3 pr-9 text-text-secondary hover:bg-state-base-hover',
|
||||
optionClassName,
|
||||
)
|
||||
}
|
||||
value={item}
|
||||
disabled={item.disabled || disabled}
|
||||
>
|
||||
{({ /* active, */ selected }) => (
|
||||
<>
|
||||
{renderOption
|
||||
? renderOption({ item, selected })
|
||||
: (<>
|
||||
<span className={classNames('block', selected && 'font-normal')}>{item.name}</span>
|
||||
{selected && !hideChecked && (
|
||||
<span
|
||||
className={classNames(
|
||||
'absolute inset-y-0 right-0 flex items-center pr-2 text-text-accent',
|
||||
)}
|
||||
>
|
||||
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>)}
|
||||
</>
|
||||
)}
|
||||
</ListboxOption>
|
||||
),
|
||||
)}
|
||||
</ListboxOptions>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
)
|
||||
}
|
||||
|
||||
type PortalSelectProps = {
|
||||
value: string | number
|
||||
onSelect: (value: Item) => void
|
||||
items: Item[]
|
||||
placeholder?: string
|
||||
installedValue?: string | number
|
||||
renderTrigger?: (value?: Item) => React.JSX.Element | null
|
||||
triggerClassName?: string
|
||||
triggerClassNameFn?: (open: boolean) => string
|
||||
popupClassName?: string
|
||||
popupInnerClassName?: string
|
||||
readonly?: boolean
|
||||
hideChecked?: boolean
|
||||
}
|
||||
const PortalSelect: FC<PortalSelectProps> = ({
|
||||
value,
|
||||
onSelect,
|
||||
items,
|
||||
placeholder,
|
||||
installedValue,
|
||||
renderTrigger,
|
||||
triggerClassName,
|
||||
triggerClassNameFn,
|
||||
popupClassName,
|
||||
popupInnerClassName,
|
||||
readonly,
|
||||
hideChecked,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const localPlaceholder = placeholder || t('common.placeholder.select')
|
||||
const selectedItem = value ? items.find(item => item.value === value) : undefined
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={4}
|
||||
triggerPopupSameWidth={true}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)} className='w-full'>
|
||||
{renderTrigger
|
||||
? renderTrigger(selectedItem)
|
||||
: (
|
||||
<div
|
||||
className={classNames(`
|
||||
group flex h-9 items-center justify-between rounded-lg border-0 bg-components-input-bg-normal px-2.5 text-sm hover:bg-state-base-hover-alt ${readonly ? 'cursor-not-allowed' : 'cursor-pointer'}
|
||||
`, triggerClassName, triggerClassNameFn?.(open))}
|
||||
title={selectedItem?.name}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
grow truncate text-text-secondary
|
||||
${!selectedItem?.name && 'text-components-input-text-placeholder'}
|
||||
`}
|
||||
>
|
||||
{selectedItem?.name ?? localPlaceholder}
|
||||
</span>
|
||||
<div className='mx-0.5'>{installedValue && selectedItem && selectedItem.value !== installedValue && <Badge>{installedValue} {'->'} {selectedItem.value} </Badge>}</div>
|
||||
<ChevronDownIcon className='h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={`z-20 ${popupClassName}`}>
|
||||
<div
|
||||
className={classNames('max-h-60 overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-none sm:text-sm', popupInnerClassName)}
|
||||
>
|
||||
{items.map((item: Item) => (
|
||||
<div
|
||||
key={item.value}
|
||||
className={`
|
||||
flex h-9 cursor-pointer items-center justify-between rounded-lg px-2.5 text-text-secondary hover:bg-state-base-hover
|
||||
${item.value === value && 'bg-state-base-hover'}
|
||||
`}
|
||||
title={item.name}
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className='w-0 grow truncate'
|
||||
title={item.name}
|
||||
>
|
||||
<span className='truncate'>{item.name}</span>
|
||||
{item.value === installedValue && (
|
||||
<Badge uppercase={true} className='ml-1 shrink-0'>INSTALLED</Badge>
|
||||
)}
|
||||
</span>
|
||||
{!hideChecked && item.value === value && (
|
||||
<RiCheckLine className='h-4 w-4 shrink-0 text-text-accent' />
|
||||
)}
|
||||
{item.extra}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export { SimpleSelect, PortalSelect }
|
||||
export default React.memo(Select)
|
||||
61
dify/web/app/components/base/select/locale-signin.tsx
Normal file
61
dify/web/app/components/base/select/locale-signin.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { Fragment } from 'react'
|
||||
import { GlobeAltIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type ISelectProps = {
|
||||
items: Array<{ value: string; name: string }>
|
||||
value?: string
|
||||
className?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export default function LocaleSigninSelect({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
}: ISelectProps) {
|
||||
const item = items.filter(item => item.value === value)[0]
|
||||
|
||||
return (
|
||||
<div className="w-56 text-right">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton className="h-[44px]justify-center inline-flex w-full items-center rounded-lg border border-components-button-secondary-border px-[10px] py-[6px] text-[13px] font-medium text-text-primary hover:bg-state-base-hover">
|
||||
<GlobeAltIcon className="mr-1 h-5 w-5" aria-hidden="true" />
|
||||
{item?.name}
|
||||
</MenuButton>
|
||||
</div>
|
||||
<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 className="absolute right-0 z-10 mt-2 w-[200px] origin-top-right divide-y divide-divider-regular rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg focus:outline-none">
|
||||
<div className="max-h-96 overflow-y-auto px-1 py-1 [mask-image:linear-gradient(to_bottom,transparent_0px,black_8px,black_calc(100%-8px),transparent_100%)]">
|
||||
{items.map((item) => {
|
||||
return <MenuItem key={item.value}>
|
||||
<button type="button"
|
||||
className={'group flex w-full items-center rounded-lg px-3 py-2 text-sm text-text-secondary data-[active]:bg-state-base-hover'}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault()
|
||||
onChange?.(item.value)
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
</MenuItem>
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
dify/web/app/components/base/select/locale.tsx
Normal file
61
dify/web/app/components/base/select/locale.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { Fragment } from 'react'
|
||||
import { GlobeAltIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type ISelectProps = {
|
||||
items: Array<{ value: string; name: string }>
|
||||
value?: string
|
||||
className?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
}: ISelectProps) {
|
||||
const item = items.filter(item => item.value === value)[0]
|
||||
|
||||
return (
|
||||
<div className="w-56 text-right">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton className="h-[44px]justify-center inline-flex w-full items-center rounded-lg border border-components-button-secondary-border px-[10px] py-[6px] text-[13px] font-medium text-text-primary hover:bg-state-base-hover">
|
||||
<GlobeAltIcon className="mr-1 h-5 w-5" aria-hidden="true" />
|
||||
{item?.name}
|
||||
</MenuButton>
|
||||
</div>
|
||||
<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 className="absolute right-0 z-10 mt-2 w-[200px] origin-top-right divide-y divide-divider-regular rounded-md bg-components-panel-bg shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="px-1 py-1 ">
|
||||
{items.map((item) => {
|
||||
return <MenuItem key={item.value}>
|
||||
<button type="button"
|
||||
className={'group flex w-full items-center rounded-lg px-3 py-2 text-sm text-text-secondary data-[active]:bg-state-base-hover'}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault()
|
||||
onChange?.(item.value)
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
</MenuItem>
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
203
dify/web/app/components/base/select/pure.tsx
Normal file
203
dify/web/app/components/base/select/pure.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import type {
|
||||
PortalToFollowElemOptions,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type Option = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type SharedPureSelectProps = {
|
||||
options: Option[]
|
||||
containerProps?: PortalToFollowElemOptions & {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
triggerProps?: {
|
||||
className?: string
|
||||
},
|
||||
popupProps?: {
|
||||
wrapperClassName?: string
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
title?: string
|
||||
titleClassName?: string
|
||||
},
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
triggerPopupSameWidth?: boolean
|
||||
}
|
||||
|
||||
type SingleSelectProps = {
|
||||
multiple?: false
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
type MultiSelectProps = {
|
||||
multiple: true
|
||||
value?: string[]
|
||||
onChange?: (value: string[]) => void
|
||||
}
|
||||
|
||||
export type PureSelectProps = SharedPureSelectProps & (SingleSelectProps | MultiSelectProps)
|
||||
const PureSelect = (props: PureSelectProps) => {
|
||||
const {
|
||||
options,
|
||||
containerProps,
|
||||
triggerProps,
|
||||
popupProps,
|
||||
placeholder,
|
||||
disabled,
|
||||
triggerPopupSameWidth,
|
||||
multiple,
|
||||
value,
|
||||
onChange,
|
||||
} = props
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
open,
|
||||
onOpenChange,
|
||||
placement,
|
||||
offset,
|
||||
} = containerProps || {}
|
||||
const {
|
||||
className: triggerClassName,
|
||||
} = triggerProps || {}
|
||||
const {
|
||||
wrapperClassName: popupWrapperClassName,
|
||||
className: popupClassName,
|
||||
itemClassName: popupItemClassName,
|
||||
title: popupTitle,
|
||||
titleClassName: popupTitleClassName,
|
||||
} = popupProps || {}
|
||||
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
const mergedOpen = open ?? localOpen
|
||||
|
||||
const handleOpenChange = useCallback((openValue: boolean) => {
|
||||
onOpenChange?.(openValue)
|
||||
setLocalOpen(openValue)
|
||||
}, [onOpenChange])
|
||||
|
||||
const triggerText = useMemo(() => {
|
||||
const placeholderText = placeholder || t('common.placeholder.select')
|
||||
if (multiple)
|
||||
return value?.length ? t('common.dynamicSelect.selected', { count: value.length }) : placeholderText
|
||||
|
||||
return options.find(option => option.value === value)?.label || placeholderText
|
||||
}, [multiple, value, options, placeholder])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement || 'bottom-start'}
|
||||
offset={offset || 4}
|
||||
open={mergedOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
triggerPopupSameWidth={triggerPopupSameWidth}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={() => !disabled && handleOpenChange(!mergedOpen)}
|
||||
asChild >
|
||||
<div
|
||||
className={cn(
|
||||
'system-sm-regular group flex h-8 items-center rounded-lg bg-components-input-bg-normal px-2 text-components-input-text-filled',
|
||||
!disabled && 'cursor-pointer hover:bg-state-base-hover-alt',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
mergedOpen && !disabled && 'bg-state-base-hover-alt',
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='grow'
|
||||
title={triggerText}
|
||||
>
|
||||
{triggerText}
|
||||
</div>
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
mergedOpen && 'text-text-secondary',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={cn(
|
||||
'z-[9999]',
|
||||
popupWrapperClassName,
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-h-80 overflow-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg',
|
||||
popupClassName,
|
||||
)}
|
||||
>
|
||||
{
|
||||
popupTitle && (
|
||||
<div className={cn(
|
||||
'system-xs-medium-uppercase flex h-[22px] items-center px-3 text-text-tertiary',
|
||||
popupTitleClassName,
|
||||
)}>
|
||||
{popupTitle}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
'system-sm-medium flex h-8 cursor-pointer items-center rounded-lg px-2 text-text-secondary hover:bg-state-base-hover',
|
||||
popupItemClassName,
|
||||
)}
|
||||
title={option.label}
|
||||
onClick={() => {
|
||||
if (disabled) return
|
||||
if (multiple) {
|
||||
const currentValues = value ?? []
|
||||
const nextValues = currentValues.includes(option.value)
|
||||
? currentValues.filter(valueItem => valueItem !== option.value)
|
||||
: [...currentValues, option.value]
|
||||
onChange?.(nextValues)
|
||||
return
|
||||
}
|
||||
onChange?.(option.value)
|
||||
handleOpenChange(false)
|
||||
}}
|
||||
>
|
||||
<div className='mr-1 grow truncate px-1'>
|
||||
{option.label}
|
||||
</div>
|
||||
{
|
||||
(
|
||||
multiple
|
||||
? (value ?? []).includes(option.value)
|
||||
: value === option.value
|
||||
) && <RiCheckLine className='h-4 w-4 shrink-0 text-text-accent' />
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default PureSelect
|
||||
Reference in New Issue
Block a user