Files
aiturk-hermes-ide/apps/desktop/src/app/settings/searchable-select.tsx
T

119 lines
4.0 KiB
TypeScript

import { useCallback, useRef, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { controlVariants } from '@/components/ui/control'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
/**
* cmdk filter score for one option. Case-insensitive substring match, with
* the final path segment (after the last "/") ranked above matches anywhere
* else so "york" ranks "America/New_York" over "America/New_York/Special".
* Exported for tests.
*/
export function rankSearchOption(option: string, search: string): number {
const lower = search.toLowerCase()
const itemLower = option.toLowerCase()
const slash = itemLower.lastIndexOf('/')
if (slash !== -1 && itemLower.slice(slash + 1).includes(lower)) {
return 2
}
if (itemLower.includes(lower)) {
return 1
}
return 0
}
/**
* Searchable select for large option lists (e.g. ~590 IANA timezones).
* Built on Popover + cmdk Command — the same stack as Shadcn's Combobox.
*
* The trigger renders like the existing closed `<Select>` but opens into a
* searchable Command palette. Closed-world only: the user must pick from the
* list; arbitrary text entry is not supported.
*
* `ConfigField` routes here when `schema.searchable === true`.
*/
export function SearchableSelect({
value,
onChange,
options,
placeholder,
emptyMessage,
clearLabel
}: {
value: string
onChange: (value: string) => void
options: string[]
placeholder?: string
emptyMessage?: string
/** When set, prepends a "clear" item that sets the value to ''.
* Matches the existing <Select> pattern of EMPTY_SELECT_VALUE + "(none)". */
clearLabel?: string
}) {
const { t } = useI18n()
const searchPlaceholder = placeholder ?? t.settings.runtime.search
const [open, setOpen] = useState(false)
const triggerRef = useRef<HTMLButtonElement>(null)
const handleSelect = useCallback(
(selected: string) => {
onChange(selected)
setOpen(false)
},
[onChange]
)
const displayValue = value !== '' && value !== undefined ? value : searchPlaceholder
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<button
aria-expanded={open}
aria-haspopup="listbox"
className={cn(
controlVariants(),
'flex items-center justify-between gap-2 whitespace-nowrap',
!value && 'text-muted-foreground'
)}
data-slot="searchable-select-trigger"
ref={triggerRef}
role="combobox"
type="button"
>
<span className="truncate">{displayValue}</span>
<Codicon className="shrink-0 opacity-60" name={open ? 'chevron-up' : 'chevron-down'} size="1rem" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[var(--radix-popover-trigger-width)] p-0">
<Command filter={rankSearchOption}>
<CommandInput autoFocus placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage ?? t.settings.runtime.noResults}</CommandEmpty>
<CommandGroup>
{clearLabel && (
<CommandItem onSelect={() => handleSelect('')} value={clearLabel}>
<Codicon className={cn('mr-2 size-4', value === '' ? 'opacity-100' : 'opacity-0')} name="check" />
{clearLabel}
</CommandItem>
)}
{options.map(option => (
<CommandItem key={option} onSelect={() => handleSelect(option)} value={option}>
<Codicon className={cn('mr-2 size-4', option === value ? 'opacity-100' : 'opacity-0')} name="check" />
{option}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}