refactor(web): align search input with dify ui (#37101)

This commit is contained in:
yyh 2026-06-05 16:15:28 +08:00 committed by GitHub
parent e40b30d746
commit 23cd129802
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 216 additions and 182 deletions

View File

@ -660,9 +660,6 @@
"web/app/components/apps/list.tsx": { "web/app/components/apps/list.tsx": {
"no-restricted-globals": { "no-restricted-globals": {
"count": 2 "count": 2
},
"no-restricted-imports": {
"count": 1
} }
}, },
"web/app/components/apps/new-app-card.tsx": { "web/app/components/apps/new-app-card.tsx": {
@ -1744,9 +1741,6 @@
"web/app/components/base/search-input/index.stories.tsx": { "web/app/components/base/search-input/index.stories.tsx": {
"no-console": { "no-console": {
"count": 3 "count": 3
},
"ts/no-explicit-any": {
"count": 1
} }
}, },
"web/app/components/base/svg-gallery/index.tsx": { "web/app/components/base/svg-gallery/index.tsx": {

View File

@ -203,9 +203,9 @@ vi.mock('../app-card', () => ({
})) }))
vi.mock('../new-app-card', () => ({ vi.mock('../new-app-card', () => ({
default: React.forwardRef((_props: unknown, _ref: React.ForwardedRef<unknown>) => { default: (_props: { ref?: React.Ref<unknown> }) => {
return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card') return React.createElement('div', { 'data-testid': 'new-app-card', 'role': 'button' }, 'New App Card')
}), },
})) }))
vi.mock('../empty', () => ({ vi.mock('../empty', () => ({
@ -293,7 +293,7 @@ describe('List', () => {
it('should render search input', () => { it('should render search input', () => {
renderList() renderList()
expect(screen.getByRole('textbox'))!.toBeInTheDocument() expect(screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }))!.toBeInTheDocument()
}) })
it('should render tag filter', () => { it('should render tag filter', () => {
@ -360,13 +360,13 @@ describe('List', () => {
describe('Search Functionality', () => { describe('Search Functionality', () => {
it('should render search input field', () => { it('should render search input field', () => {
renderList() renderList()
expect(screen.getByRole('textbox'))!.toBeInTheDocument() expect(screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }))!.toBeInTheDocument()
}) })
it('should handle search input change', () => { it('should handle search input change', () => {
renderList() renderList()
const input = screen.getByRole('textbox') const input = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' })
fireEvent.change(input, { target: { value: 'test search' } }) fireEvent.change(input, { target: { value: 'test search' } })
expect(mockSetKeywords).toHaveBeenCalledWith('test search') expect(mockSetKeywords).toHaveBeenCalledWith('test search')
@ -377,10 +377,7 @@ describe('List', () => {
renderList() renderList()
const clearButton = document.querySelector('.group') fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
expect(clearButton)!.toBeInTheDocument()
if (clearButton)
fireEvent.click(clearButton)
expect(mockSetKeywords).toHaveBeenCalledWith('') expect(mockSetKeywords).toHaveBeenCalledWith('')
}) })
@ -505,7 +502,7 @@ describe('List', () => {
it('should render with all filter options visible', () => { it('should render with all filter options visible', () => {
renderList() renderList()
expect(screen.getByRole('textbox'))!.toBeInTheDocument() expect(screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' }))!.toBeInTheDocument()
expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument() expect(screen.getByText('common.tag.placeholder'))!.toBeInTheDocument()
expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument() expect(screen.getByText('app.showMyCreatedAppsOnly'))!.toBeInTheDocument()
}) })

View File

@ -1,6 +1,5 @@
'use client' 'use client'
import type { FC } from 'react'
import type { AppListQuery } from '@/contract/console/apps' import type { AppListQuery } from '@/contract/console/apps'
import { Checkbox } from '@langgenius/dify-ui/checkbox' import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
@ -8,7 +7,7 @@ import { keepPreviousData, useInfiniteQuery, useSuspenseQuery } from '@tanstack/
import { useDebounce } from 'ahooks' import { useDebounce } from 'ahooks'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input' import { SearchInput } from '@/app/components/base/search-input'
import TabSliderNew from '@/app/components/base/tab-slider-new' import TabSliderNew from '@/app/components/base/tab-slider-new'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
@ -39,9 +38,9 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
type Props = { type Props = {
controlRefreshList?: number controlRefreshList?: number
} }
const List: FC<Props> = ({ function List({
controlRefreshList = 0, controlRefreshList = 0,
}) => { }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions()) const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext() const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
@ -224,13 +223,12 @@ const List: FC<Props> = ({
</div> </div>
</label> </label>
<TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} /> <TagFilter type="app" value={tagIDs} onChange={setTagIDs} onOpenTagManagement={() => setShowTagManagementModal(true)} />
<Input <SearchInput
showLeftIcon className="w-52"
showClearIcon
wrapperClassName="w-[200px]"
value={keywords} value={keywords}
onChange={e => setKeywords(e.target.value)} onValueChange={setKeywords}
onClear={() => setKeywords('')} placeholder={t('operation.search', { ns: 'common' })}
aria-label={t('gotoAnything.actions.searchApplications', { ns: 'app' })}
/> />
</div> </div>
</div> </div>

View File

@ -4,6 +4,7 @@ import { CheckboxList } from '..'
describe('checkbox list component', () => { describe('checkbox list component', () => {
const selectAllName = 'common.operation.selectAll' const selectAllName = 'common.operation.selectAll'
const getSearchInput = () => screen.getByRole('searchbox', { name: 'common.operation.search' })
const options = [ const options = [
{ label: 'Option 1', value: 'option1' }, { label: 'Option 1', value: 'option1' },
{ label: 'Option 2', value: 'option2' }, { label: 'Option 2', value: 'option2' },
@ -30,7 +31,7 @@ describe('checkbox list component', () => {
it('filters options by label', async () => { it('filters options by label', async () => {
render(<CheckboxList options={options} />) render(<CheckboxList options={options} />)
const input = screen.getByRole('textbox') const input = getSearchInput()
await userEvent.type(input, 'app') await userEvent.type(input, 'app')
expect(screen.getByText('Apple'))!.toBeInTheDocument() expect(screen.getByText('Apple'))!.toBeInTheDocument()
@ -116,7 +117,7 @@ describe('checkbox list component', () => {
it('hides select-all checkbox when searching', async () => { it('hides select-all checkbox when searching', async () => {
render(<CheckboxList options={options} />) render(<CheckboxList options={options} />)
await userEvent.type(screen.getByRole('textbox'), 'app') await userEvent.type(getSearchInput(), 'app')
expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument() expect(screen.queryByRole('checkbox', { name: selectAllName })).not.toBeInTheDocument()
}) })
@ -182,7 +183,7 @@ describe('checkbox list component', () => {
/>, />,
) )
const input = screen.getByRole('textbox') const input = getSearchInput()
await userEvent.type(input, 'ban') await userEvent.type(input, 'ban')
await userEvent.click(screen.getByText('common.operation.resetKeywords')) await userEvent.click(screen.getByText('common.operation.resetKeywords'))
expect(input)!.toHaveValue('') expect(input)!.toHaveValue('')
@ -293,7 +294,7 @@ describe('checkbox list component', () => {
it('filters options correctly when searching', async () => { it('filters options correctly when searching', async () => {
render(<CheckboxList options={options} />) render(<CheckboxList options={options} />)
const input = screen.getByRole('textbox') const input = getSearchInput()
await userEvent.type(input, 'option') await userEvent.type(input, 'option')
expect(screen.getByText('Option 1'))!.toBeInTheDocument() expect(screen.getByText('Option 1'))!.toBeInTheDocument()
@ -305,7 +306,7 @@ describe('checkbox list component', () => {
it('shows no data message when no options match search', async () => { it('shows no data message when no options match search', async () => {
render(<CheckboxList options={options} />) render(<CheckboxList options={options} />)
const input = screen.getByRole('textbox') const input = getSearchInput()
await userEvent.type(input, 'xyz') await userEvent.type(input, 'xyz')
expect(screen.getByText(/common.operation.noSearchResults/i))!.toBeInTheDocument() expect(screen.getByText(/common.operation.noSearchResults/i))!.toBeInTheDocument()
@ -372,7 +373,7 @@ describe('checkbox list component', () => {
/>, />,
) )
const input = screen.getByRole('textbox') const input = getSearchInput()
await userEvent.type(input, 'opt') await userEvent.type(input, 'opt')
expect(screen.getByText(/operation.searchCount/i))!.toBeInTheDocument() expect(screen.getByText(/operation.searchCount/i))!.toBeInTheDocument()

View File

@ -8,7 +8,7 @@ import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge' import Badge from '@/app/components/base/badge'
import SearchInput from '@/app/components/base/search-input' import { SearchInput } from '@/app/components/base/search-input'
import SearchMenu from '@/assets/search-menu.svg' import SearchMenu from '@/assets/search-menu.svg'
type CheckboxListOption = { type CheckboxListOption = {
@ -134,7 +134,7 @@ export const CheckboxList = ({
{showSearch && ( {showSearch && (
<SearchInput <SearchInput
value={searchQuery} value={searchQuery}
onChange={setSearchQuery} onValueChange={setSearchQuery}
placeholder={t('placeholder.search', { ns: 'common' })} placeholder={t('placeholder.search', { ns: 'common' })}
className="w-40" className="w-40"
/> />

View File

@ -1,23 +1,31 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import SearchInput from '..' import { useState } from 'react'
import { SearchInput } from '..'
describe('SearchInput', () => { describe('SearchInput', () => {
describe('Render', () => { describe('Render', () => {
it('renders correctly with default props', () => { it('renders correctly with default props', () => {
render(<SearchInput value="" onChange={() => {}} />) render(<SearchInput value="" onValueChange={() => {}} />)
const input = screen.getByPlaceholderText('common.operation.search') const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
expect(input).toBeInTheDocument() expect(input).toBeInTheDocument()
expect(input).toHaveValue('') expect(input).toHaveValue('')
expect(input).toHaveAttribute('name', 'query')
expect(input).toHaveAttribute('autocomplete', 'off')
}) })
it('renders custom placeholder', () => { it('renders custom placeholder', () => {
render(<SearchInput value="" onChange={() => {}} placeholder="Custom Placeholder" />) render(<SearchInput value="" onValueChange={() => {}} placeholder="Custom Placeholder" />)
expect(screen.getByPlaceholderText('Custom Placeholder')).toBeInTheDocument() expect(screen.getByRole('searchbox', { name: 'common.operation.search' })).toHaveAttribute('placeholder', 'Custom Placeholder')
})
it('uses custom aria label', () => {
render(<SearchInput value="" onValueChange={() => {}} aria-label="Search providers" />)
expect(screen.getByRole('searchbox', { name: 'Search providers' })).toBeInTheDocument()
}) })
it('shows clear button when value is present', () => { it('shows clear button when value is present', () => {
const onChange = vi.fn() const onValueChange = vi.fn()
render(<SearchInput value="has value" onChange={onChange} />) render(<SearchInput value="has value" onValueChange={onValueChange} />)
const clearButton = screen.getByLabelText('common.operation.clear') const clearButton = screen.getByLabelText('common.operation.clear')
expect(clearButton).toBeInTheDocument() expect(clearButton).toBeInTheDocument()
@ -25,65 +33,113 @@ describe('SearchInput', () => {
}) })
describe('Interaction', () => { describe('Interaction', () => {
it('calls onChange when typing', () => { it('calls onValueChange when typing', () => {
const onChange = vi.fn() const onValueChange = vi.fn()
render(<SearchInput value="" onChange={onChange} />) render(<SearchInput value="" onValueChange={onValueChange} />)
const input = screen.getByPlaceholderText('common.operation.search') const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
fireEvent.change(input, { target: { value: 'test' } }) fireEvent.change(input, { target: { value: 'test' } })
expect(onChange).toHaveBeenCalledWith('test') expect(onValueChange).toHaveBeenCalledWith('test')
}) })
it('handles composition events', () => { it('handles composition events', () => {
const onChange = vi.fn() const onValueChange = vi.fn()
render(<SearchInput value="initial" onChange={onChange} />) render(<SearchInput value="initial" onValueChange={onValueChange} />)
const input = screen.getByPlaceholderText('common.operation.search') const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
// Start composition
fireEvent.compositionStart(input) fireEvent.compositionStart(input)
fireEvent.change(input, { target: { value: 'final' } }) fireEvent.change(input, { target: { value: 'final' } })
// While composing, onChange should NOT be called expect(onValueChange).not.toHaveBeenCalled()
expect(onChange).not.toHaveBeenCalled()
expect(input).toHaveValue('final') expect(input).toHaveValue('final')
// End composition
fireEvent.compositionEnd(input) fireEvent.compositionEnd(input)
expect(onChange).toHaveBeenCalledTimes(1) expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('final') expect(onValueChange).toHaveBeenCalledWith('final')
}) })
it('calls onChange with empty string when clear button is clicked', () => { it('does not keep stale composition commits after the next distinct change', () => {
const onChange = vi.fn() const onValueChange = vi.fn()
render(<SearchInput value="has value" onChange={onChange} />)
function ControlledSearchInput() {
const [value, setValue] = useState('initial')
return (
<SearchInput
value={value}
onValueChange={(nextValue) => {
onValueChange(nextValue)
setValue(nextValue)
}}
/>
)
}
render(<ControlledSearchInput />)
const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
fireEvent.compositionStart(input)
fireEvent.change(input, { target: { value: 'final' } })
fireEvent.compositionEnd(input)
fireEvent.change(input, { target: { value: 'finalx' } })
fireEvent.change(input, { target: { value: 'final' } })
expect(onValueChange).toHaveBeenCalledTimes(3)
expect(onValueChange).toHaveBeenNthCalledWith(1, 'final')
expect(onValueChange).toHaveBeenNthCalledWith(2, 'finalx')
expect(onValueChange).toHaveBeenNthCalledWith(3, 'final')
})
it('clears composition value without committing stale text', () => {
const onValueChange = vi.fn()
function ControlledSearchInput() {
const [value, setValue] = useState('initial')
return (
<SearchInput
value={value}
onValueChange={(nextValue) => {
onValueChange(nextValue)
setValue(nextValue)
}}
/>
)
}
render(<ControlledSearchInput />)
const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
fireEvent.compositionStart(input)
fireEvent.change(input, { target: { value: 'final' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.clear' }))
fireEvent.compositionEnd(input)
expect(input).toHaveValue('')
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange).toHaveBeenCalledWith('')
})
it('calls onValueChange with empty string when clear button is clicked', () => {
const onValueChange = vi.fn()
render(<SearchInput value="has value" onValueChange={onValueChange} />)
const clearButton = screen.getByLabelText('common.operation.clear') const clearButton = screen.getByLabelText('common.operation.clear')
fireEvent.click(clearButton) fireEvent.click(clearButton)
expect(onChange).toHaveBeenCalledWith('') expect(onValueChange).toHaveBeenCalledWith('')
}) })
it('updates focus state on focus/blur', () => { it('uses dify-ui input spacing for the search adornment', () => {
const { container } = render(<SearchInput value="" onChange={() => {}} />) render(<SearchInput value="" onValueChange={() => {}} />)
const wrapper = container.firstChild as HTMLElement const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
const input = screen.getByPlaceholderText('common.operation.search') expect(input).toHaveClass('ps-7')
expect(input).not.toHaveClass('h-[18px]')
fireEvent.focus(input)
expect(wrapper).toHaveClass(/bg-components-input-bg-active/)
fireEvent.blur(input)
expect(wrapper).not.toHaveClass(/bg-components-input-bg-active/)
}) })
}) })
describe('Style', () => { describe('Style', () => {
it('applies white style', () => {
const { container } = render(<SearchInput value="" onChange={() => {}} white />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('bg-white!')
})
it('applies custom className', () => { it('applies custom className', () => {
const { container } = render(<SearchInput value="" onChange={() => {}} className="custom-test" />) const { container } = render(<SearchInput value="" onValueChange={() => {}} className="custom-test" />)
const wrapper = container.firstChild as HTMLElement const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('custom-test') expect(wrapper).toHaveClass('custom-test')
}) })

View File

@ -1,7 +1,8 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { ComponentProps } from 'react'
import { Kbd } from '@langgenius/dify-ui/kbd' import { Kbd } from '@langgenius/dify-ui/kbd'
import { useState } from 'react' import { useState } from 'react'
import SearchInput from '.' import { SearchInput } from '.'
const meta = { const meta = {
title: 'Base/Data Entry/SearchInput', title: 'Base/Data Entry/SearchInput',
@ -20,25 +21,21 @@ const meta = {
control: 'text', control: 'text',
description: 'Search input value', description: 'Search input value',
}, },
onChange: { onValueChange: {
action: 'changed', action: 'value changed',
description: 'Change handler', description: 'Value change handler',
}, },
placeholder: { placeholder: {
control: 'text', control: 'text',
description: 'Placeholder text', description: 'Placeholder text',
}, },
white: {
control: 'boolean',
description: 'White background variant',
},
className: { className: {
control: 'text', control: 'text',
description: 'Additional CSS classes', description: 'Additional CSS classes',
}, },
}, },
args: { args: {
onChange: (v) => { onValueChange: (v: string) => {
console.log('Search value changed:', v) console.log('Search value changed:', v)
}, },
}, },
@ -47,8 +44,7 @@ const meta = {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Interactive demo wrapper const SearchInputDemo = (args: ComponentProps<typeof SearchInput>) => {
const SearchInputDemo = (args: any) => {
const [value, setValue] = useState(args.value || '') const [value, setValue] = useState(args.value || '')
return ( return (
@ -56,7 +52,7 @@ const SearchInputDemo = (args: any) => {
<SearchInput <SearchInput
{...args} {...args}
value={value} value={value}
onChange={(v) => { onValueChange={(v: string) => {
setValue(v) setValue(v)
console.log('Search value changed:', v) console.log('Search value changed:', v)
}} }}
@ -77,31 +73,19 @@ export const Default: Story = {
render: args => <SearchInputDemo {...args} />, render: args => <SearchInputDemo {...args} />,
args: { args: {
placeholder: 'Search...', placeholder: 'Search...',
white: false,
value: '', value: '',
onChange: (v) => { onValueChange: (v: string) => {
console.log('Search value changed:', v) console.log('Search value changed:', v)
}, },
}, },
} }
// White variant
export const WhiteBackground: Story = {
render: args => <SearchInputDemo {...args} />,
args: {
placeholder: 'Search...',
white: true,
value: '',
},
}
// With initial value // With initial value
export const WithInitialValue: Story = { export const WithInitialValue: Story = {
render: args => <SearchInputDemo {...args} />, render: args => <SearchInputDemo {...args} />,
args: { args: {
value: 'Initial search query', value: 'Initial search query',
placeholder: 'Search...', placeholder: 'Search...',
white: false,
}, },
} }
@ -110,7 +94,6 @@ export const CustomPlaceholder: Story = {
render: args => <SearchInputDemo {...args} />, render: args => <SearchInputDemo {...args} />,
args: { args: {
placeholder: 'Search documents, files, and more...', placeholder: 'Search documents, files, and more...',
white: false,
value: '', value: '',
}, },
} }
@ -138,7 +121,7 @@ const UserListSearchDemo = () => {
<h3 className="mb-4 text-lg font-semibold">Team Members</h3> <h3 className="mb-4 text-lg font-semibold">Team Members</h3>
<SearchInput <SearchInput
value={searchQuery} value={searchQuery}
onChange={setSearchQuery} onValueChange={setSearchQuery}
placeholder="Search by name, email, or role..." placeholder="Search by name, email, or role..."
/> />
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
@ -212,9 +195,8 @@ const ProductSearchDemo = () => {
<h3 className="mb-4 text-lg font-semibold">Product Catalog</h3> <h3 className="mb-4 text-lg font-semibold">Product Catalog</h3>
<SearchInput <SearchInput
value={searchQuery} value={searchQuery}
onChange={setSearchQuery} onValueChange={setSearchQuery}
placeholder="Search products..." placeholder="Search products..."
white
/> />
<div className="mt-4 grid grid-cols-2 gap-3"> <div className="mt-4 grid grid-cols-2 gap-3">
{filteredProducts.length > 0 {filteredProducts.length > 0
@ -272,10 +254,8 @@ const DocumentationSearchDemo = () => {
<p className="mb-4 text-sm text-gray-600">Search our comprehensive guides and API references</p> <p className="mb-4 text-sm text-gray-600">Search our comprehensive guides and API references</p>
<SearchInput <SearchInput
value={searchQuery} value={searchQuery}
onChange={setSearchQuery} onValueChange={setSearchQuery}
placeholder="Search documentation..." placeholder="Search documentation..."
white
className="h-10!"
/> />
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{filteredDocs.length > 0 {filteredDocs.length > 0
@ -338,10 +318,8 @@ const CommandPaletteDemo = () => {
<div className="border-b border-gray-200 p-4"> <div className="border-b border-gray-200 p-4">
<SearchInput <SearchInput
value={searchQuery} value={searchQuery}
onChange={setSearchQuery} onValueChange={setSearchQuery}
placeholder="Type a command or search..." placeholder="Type a command or search..."
white
className="h-10!"
/> />
</div> </div>
<div className="max-h-[400px] overflow-y-auto"> <div className="max-h-[400px] overflow-y-auto">
@ -413,13 +391,13 @@ const LiveSearchWithCountDemo = () => {
</div> </div>
<SearchInput <SearchInput
value={searchQuery} value={searchQuery}
onChange={setSearchQuery} onValueChange={setSearchQuery}
placeholder="Search resources..." placeholder="Search resources..."
/> />
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{filteredItems.map((item, index) => ( {filteredItems.map(item => (
<div <div
key={index} key={item}
className="cursor-pointer rounded-lg border border-gray-200 p-3 transition-colors hover:border-blue-300 hover:bg-blue-50" className="cursor-pointer rounded-lg border border-gray-200 p-3 transition-colors hover:border-blue-300 hover:bg-blue-50"
> >
<div className="text-sm font-medium">{item}</div> <div className="text-sm font-medium">{item}</div>
@ -445,24 +423,22 @@ const SizeVariationsDemo = () => {
<div style={{ width: '500px' }} className="space-y-4"> <div style={{ width: '500px' }} className="space-y-4">
<div> <div>
<label className="mb-2 block text-xs font-medium text-gray-600">Default Size</label> <label className="mb-2 block text-xs font-medium text-gray-600">Default Size</label>
<SearchInput value={value1} onChange={setValue1} placeholder="Search..." /> <SearchInput value={value1} onValueChange={setValue1} placeholder="Search..." />
</div> </div>
<div> <div>
<label className="mb-2 block text-xs font-medium text-gray-600">Medium Size</label> <label className="mb-2 block text-xs font-medium text-gray-600">Medium Size</label>
<SearchInput <SearchInput
value={value2} value={value2}
onChange={setValue2} onValueChange={setValue2}
placeholder="Search..." placeholder="Search..."
className="h-10!"
/> />
</div> </div>
<div> <div>
<label className="mb-2 block text-xs font-medium text-gray-600">Large Size</label> <label className="mb-2 block text-xs font-medium text-gray-600">Large Size</label>
<SearchInput <SearchInput
value={value3} value={value3}
onChange={setValue3} onValueChange={setValue3}
placeholder="Search..." placeholder="Search..."
className="h-12!"
/> />
</div> </div>
</div> </div>
@ -480,6 +456,5 @@ export const Playground: Story = {
args: { args: {
value: '', value: '',
placeholder: 'Search...', placeholder: 'Search...',
white: false,
}, },
} }

View File

@ -1,86 +1,100 @@
import type { FC } from 'react' import type { ComponentProps } from 'react'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { RiCloseCircleFill, RiSearchLine } from '@remixicon/react' import { Input } from '@langgenius/dify-ui/input'
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type SearchInputProps = { type SearchInputProps = {
value: string
onValueChange: (value: string) => void
placeholder?: string placeholder?: string
className?: string className?: string
value: string } & Pick<ComponentProps<'input'>, 'aria-label'>
onChange: (v: string) => void
white?: boolean
}
const SearchInput: FC<SearchInputProps> = ({ export function SearchInput({
placeholder, placeholder,
className, className,
value, value,
onChange, onValueChange,
white, 'aria-label': ariaLabel,
}) => { }: SearchInputProps) {
const { t } = useTranslation() const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const [focus, setFocus] = useState<boolean>(false) const isComposingRef = useRef<boolean>(false)
const isComposing = useRef<boolean>(false) const compositionCommitRef = useRef<string | null>(null)
const [compositionValue, setCompositionValue] = useState<string>('') const [compositionValue, setCompositionValue] = useState('')
const inputValue = isComposingRef.current ? compositionValue : value
const handleClear = () => {
isComposingRef.current = false
compositionCommitRef.current = null
setCompositionValue('')
onValueChange('')
inputRef.current?.focus()
}
return ( return (
<div className={cn( <div className={cn(
'group flex h-8 items-center overflow-hidden rounded-lg border-none bg-components-input-bg-normal px-2 hover:bg-components-input-bg-hover', 'relative',
focus && 'bg-components-input-bg-active!',
white && 'border-gray-300! bg-white! shadow-xs hover:border-gray-300! hover:bg-white!',
className, className,
)} )}
> >
<div className="pointer-events-none mr-1.5 flex size-4 shrink-0 items-center justify-center"> <span className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder" aria-hidden="true" />
<RiSearchLine className="size-4 text-components-input-text-placeholder" aria-hidden="true" /> <Input
</div>
<input
ref={inputRef} ref={inputRef}
type="text" type="search"
name="query" name="query"
aria-label={ariaLabel ?? t('operation.search', { ns: 'common' })}
className={cn( className={cn(
'caret-#295EFF block h-[18px] grow appearance-none border-0 bg-transparent system-sm-regular text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder', 'ps-7',
white && 'bg-white! group-hover:bg-white! placeholder:text-gray-400! hover:bg-white!', !!inputValue && 'pe-7',
'[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none',
)} )}
placeholder={placeholder || t('operation.search', { ns: 'common' })!} placeholder={placeholder ?? t('operation.search', { ns: 'common' })}
value={isComposing.current ? compositionValue : value} value={inputValue}
onChange={(e) => { onValueChange={(nextValue) => {
const newValue = e.target.value if (isComposingRef.current) {
if (isComposing.current) setCompositionValue(nextValue)
setCompositionValue(newValue) return
else }
onChange(newValue) if (compositionCommitRef.current !== null) {
if (compositionCommitRef.current !== nextValue) {
compositionCommitRef.current = null
onValueChange(nextValue)
return
}
compositionCommitRef.current = null
return
}
onValueChange(nextValue)
}} }}
onCompositionStart={() => { onCompositionStart={() => {
isComposing.current = true isComposingRef.current = true
compositionCommitRef.current = null
setCompositionValue(value) setCompositionValue(value)
}} }}
onCompositionEnd={(e) => { onCompositionEnd={(e) => {
isComposing.current = false if (!isComposingRef.current)
return
isComposingRef.current = false
setCompositionValue('') setCompositionValue('')
onChange(e.currentTarget.value) compositionCommitRef.current = e.currentTarget.value
onValueChange(e.currentTarget.value)
}} }}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
autoComplete="off" autoComplete="off"
enterKeyHint="search"
/> />
{value && ( {!!inputValue && (
<button <button
type="button" type="button"
aria-label={t('operation.clear', { ns: 'common' })} aria-label={t('operation.clear', { ns: 'common' })}
className="group/clear flex size-4 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent p-0" className="group/clear absolute top-1/2 right-2 flex size-4 -translate-y-1/2 cursor-pointer touch-manipulation items-center justify-center rounded-md border-none bg-transparent p-0 outline-hidden focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:ring-inset"
onClick={() => { onClick={handleClear}
onChange('')
inputRef.current?.focus()
}}
> >
<RiCloseCircleFill className="size-4 text-text-quaternary group-hover/clear:text-text-tertiary" /> <span className="i-ri-close-circle-fill size-4 text-text-quaternary group-hover/clear:text-text-tertiary" aria-hidden="true" />
</button> </button>
)} )}
</div> </div>
) )
} }
export default SearchInput

View File

@ -461,7 +461,7 @@ describe('AccountSetting', () => {
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PROVIDER }) renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PROVIDER })
// Act // Act
const input = screen.getByRole('textbox') const input = screen.getByRole('searchbox', { name: 'common.operation.search' })
fireEvent.change(input, { target: { value: 'test-search' } }) fireEvent.change(input, { target: { value: 'test-search' } })
// Assert // Assert

View File

@ -5,7 +5,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area' import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SearchInput from '@/app/components/base/search-input' import { SearchInput } from '@/app/components/base/search-input'
import BillingPage from '@/app/components/billing/billing-page' import BillingPage from '@/app/components/billing/billing-page'
import CustomPage from '@/app/components/custom/custom-page' import CustomPage from '@/app/components/custom/custom-page'
import { import {
@ -218,8 +218,8 @@ export default function AccountSetting({
{activeItem?.key === ACCOUNT_SETTING_TAB.PROVIDER && ( {activeItem?.key === ACCOUNT_SETTING_TAB.PROVIDER && (
<div className="flex grow justify-end"> <div className="flex grow justify-end">
<SearchInput <SearchInput
className="w-[200px]" className="w-52"
onChange={setSearchValue} onValueChange={setSearchValue}
value={searchValue} value={searchValue}
/> />
</div> </div>

View File

@ -42,20 +42,20 @@ vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
})) }))
vi.mock('@/app/components/base/search-input', () => ({ vi.mock('@/app/components/base/search-input', () => ({
default: ({ SearchInput: ({
value, value,
onChange, onValueChange,
placeholder, placeholder,
}: { }: {
value: string value: string
onChange: (value: string) => void onValueChange: (value: string) => void
placeholder?: string placeholder?: string
className?: string className?: string
}) => ( }) => (
<input <input
aria-label={placeholder} aria-label={placeholder}
value={value} value={value}
onChange={e => onChange(e.target.value)} onChange={e => onValueChange(e.target.value)}
/> />
), ),
})) }))
@ -220,14 +220,14 @@ vi.mock('@langgenius/dify-ui/popover', async () => {
} }
return ( return (
<PopoverContext.Provider value={{ open, setOpen }}> <PopoverContext value={{ open, setOpen }}>
{children} {children}
</PopoverContext.Provider> </PopoverContext>
) )
} }
const PopoverTrigger = ({ render }: { render: React.ReactNode }) => { const PopoverTrigger = ({ render }: { render: React.ReactNode }) => {
const { open, setOpen } = React.useContext(PopoverContext) const { open, setOpen } = React.use(PopoverContext)
return ( return (
<div data-testid="agent-strategy-trigger" onClick={() => setOpen(!open)}> <div data-testid="agent-strategy-trigger" onClick={() => setOpen(!open)}>
{render} {render}
@ -236,7 +236,7 @@ vi.mock('@langgenius/dify-ui/popover', async () => {
} }
const PopoverContent = ({ children }: { children: React.ReactNode }) => { const PopoverContent = ({ children }: { children: React.ReactNode }) => {
const { open } = React.useContext(PopoverContext) const { open } = React.use(PopoverContext)
return open ? <div data-testid="agent-strategy-popover">{children}</div> : null return open ? <div data-testid="agent-strategy-popover">{children}</div> : null
} }

View File

@ -14,11 +14,10 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@langgenius/dify-ui/tooltip' } from '@langgenius/dify-ui/tooltip'
import { RiArrowDownSLine, RiErrorWarningFill } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query' import { useSuspenseQuery } from '@tanstack/react-query'
import { memo, useEffect, useMemo, useRef, useState } from 'react' import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import SearchInput from '@/app/components/base/search-input' import { SearchInput } from '@/app/components/base/search-input'
import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon'
import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks' import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types'
@ -45,7 +44,7 @@ const NotFoundWarn = (props: {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={<div><RiErrorWarningFill className="size-4 text-text-destructive" /></div>} render={<div><span className="i-ri-error-warning-fill size-4 text-text-destructive" aria-hidden="true" /></div>}
/> />
<TooltipContent className="w-[180px]"> <TooltipContent className="w-[180px]">
<div className="space-y-1 text-xs"> <div className="space-y-1 text-xs">
@ -204,7 +203,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
description={t('nodes.agent.strategyNotFoundDesc', { ns: 'workflow' })} description={t('nodes.agent.strategyNotFoundDesc', { ns: 'workflow' })}
/> />
) )
: <RiArrowDownSLine className="size-4 text-text-tertiary" />} : <span className="i-ri-arrow-down-s-line size-4 text-text-tertiary" aria-hidden="true" />}
{showSwitchVersion && value && ( {showSwitchVersion && value && (
<SwitchPluginVersion <SwitchPluginVersion
uniqueIdentifier={value.plugin_unique_identifier} uniqueIdentifier={value.plugin_unique_identifier}
@ -234,7 +233,7 @@ export const AgentStrategySelector = memo((props: AgentStrategySelectorProps) =>
> >
<div className="w-[388px] overflow-hidden rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow"> <div className="w-[388px] overflow-hidden rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow">
<header className="flex gap-1 p-2"> <header className="flex gap-1 p-2">
<SearchInput placeholder={t('nodes.agent.strategy.searchPlaceholder', { ns: 'workflow' })} value={query} onChange={setQuery} className="w-full" /> <SearchInput placeholder={t('nodes.agent.strategy.searchPlaceholder', { ns: 'workflow' })} value={query} onValueChange={setQuery} className="w-full" />
<ViewTypeSelect viewType={viewType} onChange={setViewType} /> <ViewTypeSelect viewType={viewType} onChange={setViewType} />
</header> </header>
<main className="relative flex w-full flex-col overflow-hidden md:max-h-[300px] xl:max-h-[400px] 2xl:max-h-[564px]" ref={wrapElemRef}> <main className="relative flex w-full flex-col overflow-hidden md:max-h-[300px] xl:max-h-[400px] 2xl:max-h-[564px]" ref={wrapElemRef}>