feat(frontend): analysis/logs 筛选下拉改可搜索 Select
数据源换 ui/select;命名空间复用 SearchableValueSelect 工具条态(可搜、全部置顶、loading);与 ahead 功能 tip 分开。
This commit is contained in:
1 parent
11d0fff764
commit
016d3ecdfd
5 files changed
+197
-86
No files matched your search
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SearchableValueSelectProps {
|
||||
/** Visible label above the field; omit or set hideLabel for toolbar chips. */
|
||||
label?: string
|
||||
hideLabel?: boolean
|
||||
ariaLabel?: string
|
||||
values: string[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
/** Show a leading empty option (e.g. all namespaces). */
|
||||
allowEmpty?: boolean
|
||||
/** Label for the empty option and closed-state display when value is empty. */
|
||||
emptyLabel?: string
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
triggerClassName?: string
|
||||
}
|
||||
|
||||
/** Searchable single-value combobox for long option lists (namespaces, labels, …). */
|
||||
export function SearchableValueSelect({
|
||||
label,
|
||||
hideLabel = false,
|
||||
ariaLabel,
|
||||
values,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
allowEmpty = false,
|
||||
emptyLabel,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
className,
|
||||
triggerClassName,
|
||||
}: SearchableValueSelectProps) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const id = useId()
|
||||
const emptyText = emptyLabel ?? t('select.any')
|
||||
const choices = values.filter(item => item.toLowerCase().includes(filter.toLowerCase()))
|
||||
// active index accounts for optional empty row at top
|
||||
const optionCount = (allowEmpty ? 1 : 0) + choices.length
|
||||
const closedDisplay = value || (allowEmpty ? emptyText : '')
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!root.current?.contains(event.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [])
|
||||
|
||||
function choose(next: string) {
|
||||
onChange(next)
|
||||
setFilter('')
|
||||
setActive(0)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setActive(current => Math.max(0, Math.min(current + 1, optionCount - 1)))
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setActive(current => Math.max(0, current - 1))
|
||||
}
|
||||
if (event.key === 'Enter' && open) {
|
||||
event.preventDefault()
|
||||
if (allowEmpty && active === 0) choose('')
|
||||
else {
|
||||
const item = choices[allowEmpty ? active - 1 : active]
|
||||
if (item) choose(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={root} className={cn('relative min-w-48', className)}>
|
||||
{!hideLabel && label ? (
|
||||
<label className="mb-1 block text-sm font-medium" htmlFor={id}>{label}</label>
|
||||
) : null}
|
||||
<Input
|
||||
id={id}
|
||||
role="combobox"
|
||||
aria-label={hideLabel ? (ariaLabel ?? label) : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-expanded={open}
|
||||
aria-controls={`${id}-options`}
|
||||
disabled={disabled}
|
||||
value={open ? filter : closedDisplay}
|
||||
placeholder={placeholder ?? t('select.placeholder')}
|
||||
className={cn('h-9', triggerClassName)}
|
||||
onFocus={() => { setFilter(''); setOpen(true) }}
|
||||
onChange={event => { setFilter(event.target.value); setActive(0); setOpen(true) }}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{open && (
|
||||
<div id={`${id}-options`} role="listbox" className="absolute z-30 mt-1 max-h-56 w-full overflow-auto rounded-md border bg-popover p-1 shadow-lg">
|
||||
{allowEmpty && (
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={!value}
|
||||
className={cn('block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted', active === 0 && 'bg-primary/10 text-primary')}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => choose('')}
|
||||
>
|
||||
{emptyText}
|
||||
</button>
|
||||
)}
|
||||
{loading ? (
|
||||
<p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.loading')}</p>
|
||||
) : choices.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.noMatch')}</p>
|
||||
) : (
|
||||
choices.slice(0, 200).map((item, index) => {
|
||||
const optionIndex = (allowEmpty ? 1 : 0) + index
|
||||
return (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={item === value}
|
||||
className={cn(
|
||||
'block w-full rounded px-2 py-1.5 text-left text-sm',
|
||||
optionIndex === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted',
|
||||
)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
onClick={() => choose(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchableValueSelect
|
||||
@@ -16,3 +16,4 @@ export { getStatusColorClass } from './status-config';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { TimeRangePicker } from './TimeRangePicker';
|
||||
export { StatCard } from './StatCard';
|
||||
export { SearchableValueSelect } from './SearchableValueSelect'
|
||||
Reference in new issue
Block a user