fix: home search suggests filters whose name matches the typed text (#11933)

* fix: home search suggests filters whose name matches the typed text

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

* fix: close the home filter hint once the list is navigated

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-21 21:44:33 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 2a2334862e
commit bf1eb85577
2 changed files with 52 additions and 10 deletions
@@ -71,6 +71,19 @@
return Object.values(val).some((v) => v !== undefined && v !== null && v !== '' && v !== false)
}
/**
* Whether typed text names a filter: every word of the query must occur in its label or
* key, in any order, so "archive" and "archived only" both find "Only archived".
*/
export function filterMatchesQuery(key: string, filter: FilterSchema, query: string): boolean {
const words = query.replace(/\\(.)/g, '$1').toLowerCase().split(/\s+/).filter(Boolean)
const haystack = [filter.label, key, key.replaceAll('_', ' ')]
.filter(Boolean)
.join(' ')
.toLowerCase()
return words.every((w) => haystack.includes(w))
}
/**
* Converts a FilterSchemaRec to a Zod schema for validation
*/
@@ -437,12 +450,7 @@
return Object.entries(schema)
.filter(([_, s]) => !s.hidden)
.filter(([k, _]) => !(k in value))
.filter(([k, filterSchema]) => {
if (!searchText) return true
const label = (filterSchema.label || k).toLowerCase()
const key = k.toLowerCase()
return label.includes(searchText) || key.includes(searchText)
})
.filter(([k, filterSchema]) => filterMatchesQuery(k, filterSchema, searchText))
.map(([key, filterSchema]) => ({
type: 'filter' as const,
key,
@@ -542,6 +550,15 @@
return []
})
// Free-text mode keeps the keyboard for the surrounding list, but a term that names a
// filter ("archive" → "Only archived") still lists the matching filters, click-only.
let freeTextFilterHint = $derived(
open &&
!dropdownVisible &&
currentTextSegment.text.trim().length >= 3 &&
menuItems.some((i) => i.type === 'filter')
)
// Reset highlighted index when menu items change
$effect(() => {
menuItems
@@ -647,7 +664,13 @@
function handleKeyDown(e: KeyboardEvent) {
// In free-text mode the dropdown is hidden; let arrow/enter keys pass through to
// the surrounding UI (e.g. list navigation) rather than steering a hidden menu.
if (!dropdownVisible) return
if (!dropdownVisible) {
// The hint sits over the list's first rows, so it gets out of the way once the list
// is being navigated; the next typed character brings it back.
if (freeTextFilterHint && ['Escape', 'ArrowUp', 'ArrowDown', 'Enter'].includes(e.key))
open = false
return
}
if (e.key === 'Escape') {
open = false
return
@@ -786,7 +809,7 @@
</div>
<GenericDropdown
open={dropdownVisible}
open={dropdownVisible || freeTextFilterHint}
instantClose={hideDropdownOnFreeText}
getInputRect={() => {
const r = inputElement?.getBoundingClientRect() ?? new DOMRect()
@@ -800,7 +823,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="py-1 p-2 overflow-y-auto" onmousedown={(e) => e.stopPropagation()}>
{#if !currentTag || !schema[currentTag]}
{#if presets.length}
{#if presets.length && !freeTextFilterHint}
<div class="text-2xs uppercase tracking-wide font-semibold text-secondary px-2 my-2">
Presets
</div>
@@ -820,7 +843,7 @@
onClick: item.onClick,
label: item.filterSchema.label || item.key,
description: item.filterSchema.description,
highlighted: index === highlightedIndex
highlighted: index === highlightedIndex && !freeTextFilterHint
})}
{/if}
{/each}
@@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest'
import { filterMatchesQuery } from './FilterSearchbar.svelte'
describe('filterMatchesQuery', () => {
const archived = { type: 'boolean' as const, label: 'Only archived' }
it('matches each word anywhere in the label or key', () => {
expect(filterMatchesQuery('archived', archived, 'archive')).toBe(true)
expect(filterMatchesQuery('archived', archived, 'archived only')).toBe(true)
expect(filterMatchesQuery('archived', archived, 'archived scripts')).toBe(false)
expect(
filterMatchesQuery('include_library', { type: 'boolean', label: 'Library' }, 'include lib')
).toBe(true)
})
it('reads an escaped space as a word break', () => {
expect(filterMatchesQuery('archived', archived, 'archived\\ only')).toBe(true)
})
})