Files
windmill/frontend/src/lib/components/instanceSettings/SettingsSearchInput.svelte
T
GuilhemandClaude Opus 4.6 4d1d17580b add fuzzy search to instance settings (#8000)
* feat: add fuzzy search to instance settings sidebar

Adds a search input at the top of the superadmin settings sidebar that
uses uFuzzy for fuzzy matching against all setting labels, descriptions,
and categories. Selecting a result navigates to the correct tab and
scrolls to the specific setting card with a brief highlight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: improve settings search display and description matching

- Extract only the label portion from uFuzzy highlighted text for
  cleaner dropdown display
- Show description only when the match is in the description and NOT
  in the label
- Truncate descriptions to 80 chars in searchable items
- Add maxHeight prop to SelectDropdown for configurable height

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: fix search description truncation and handle undefined marked values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove description from settings search dropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add smooth outline transition for setting highlight animation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: highlight first search result by default for enter-to-select

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* nit

* clean code

* fix: address review feedback - sanitize html, remove max-w-40, document description field

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: constrain search dropdown width to prevent long title overflow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* perf: add 150ms debounce to settings search filter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: clean up timeouts on destroy and re-invocation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: extract settings search into reusable SettingsSearchInput component

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use twMerge for class prop in SettingsSearchInput

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: wrap debounced state write in untrack to prevent re-triggering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:12:27 +00:00

90 lines
2.6 KiB
Svelte

<script lang="ts">
import SearchItems from '../SearchItems.svelte'
import SelectDropdown from '../select/SelectDropdown.svelte'
import type { ProcessedItem } from '../select/utils.svelte'
import TextInput from '../text_input/TextInput.svelte'
import { Search } from 'lucide-svelte'
import {
extractMarkedLabel,
type SearchableSettingItem
} from '../instanceSettings'
import { twMerge } from 'tailwind-merge'
import { untrack } from 'svelte'
let {
searchableItems,
onSelect,
class: className = ''
}: {
searchableItems: SearchableSettingItem[]
onSelect: (item: SearchableSettingItem) => void
class?: string
} = $props()
let settingsSearchFilter = $state('')
let debouncedSearchFilter = $state('')
let filteredSearchItems: (SearchableSettingItem & { marked: string })[] = $state([])
let searchInputEl: HTMLDivElement | undefined = $state()
// Debounce search to avoid running uFuzzy on every keystroke
$effect(() => {
const val = settingsSearchFilter
const timeout = setTimeout(() => untrack(() => (debouncedSearchFilter = val)), 150)
return () => clearTimeout(timeout)
})
const searchDropdownOpen = $derived(
settingsSearchFilter.trim().length > 0 && filteredSearchItems.length > 0
)
let searchProcessedItems: ProcessedItem<SearchableSettingItem & { marked: string }>[] = $derived(
filteredSearchItems.map((item) => ({
label: item.label,
value: item,
subtitle: item.category
}))
)
function handleSelect(item: SearchableSettingItem) {
settingsSearchFilter = ''
onSelect(item)
}
</script>
<SearchItems
filter={debouncedSearchFilter}
items={searchableItems}
bind:filteredItems={filteredSearchItems}
f={(x) => x.label + ' ' + (x.description ?? '') + ' ' + x.category}
/>
<div class={twMerge('relative', className)}>
<div bind:this={searchInputEl} class="relative w-full">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 text-tertiary" size={14} />
<TextInput
inputProps={{ placeholder: 'Search settings...' }}
bind:value={settingsSearchFilter}
class="pl-7 text-xs w-full"
/>
</div>
<SelectDropdown
processedItems={searchProcessedItems}
value={undefined}
open={searchDropdownOpen}
disablePortal
class="max-w-full"
itemLabelWrapperClasses="hidden"
itemButtonWrapperClasses="overflow-hidden"
getInputRect={searchInputEl ? () => searchInputEl!.getBoundingClientRect() : undefined}
onSelectValue={(item) => handleSelect(item.value)}
highlightFirstOnOpen
maxHeight={400}
>
{#snippet startSnippet({ item })}
<div class="text-xs truncate w-full min-w-0"
>{@html extractMarkedLabel(item.value.marked, item.value.label.length)}</div
>
{/snippet}
</SelectDropdown>
</div>