Skip to content

Commit 39a14e9

Browse files
authored
Fix user-filter input loss under rapid typing (#2309)
* fix(admin): preserve user-filter input under rapid typing * Replace sveltekit-search-params with runed/kit useSearchParams
1 parent 2b6936a commit 39a14e9

16 files changed

Lines changed: 279 additions & 227 deletions

File tree

frontend/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@
108108
"set-cookie-parser": "^2.7.2",
109109
"svelte-exmarkdown": "catalog:",
110110
"svelte-intl-precompile": "^0.12.3",
111-
"sveltekit-search-params": "^3.0.0",
112111
"tus-js-client": "^4.3.1"
113112
},
114113
"pnpm": {

frontend/pnpm-lock.yaml

Lines changed: 2 additions & 97 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src/lib/components/FilterBar/FilterBar.svelte

Lines changed: 22 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
import {PlainInput} from '$lib/forms';
1717
import {pick} from '$lib/util/object';
1818
import t from '$lib/i18n';
19-
import type {Writable} from 'svelte/store';
2019
import Dropdown from '../Dropdown.svelte';
21-
import {Previous, Debounced, watch} from 'runed';
20+
import {Previous} from 'runed';
21+
import {untrack} from 'svelte';
2222
import {DEFAULT_DEBOUNCE_TIME} from '$lib/util/time';
23+
import {debouncedFilter} from '$lib/util/debouncedFilter.svelte';
2324
2425
type DumbFilters = $$Generic<Record<string, unknown>>;
2526
type Filters = DumbFilters & Record<typeof searchKey, string>;
@@ -29,7 +30,7 @@
2930
interface Props {
3031
searchKey: keyof ConditionalPick<DumbFilters, string>;
3132
autofocus?: true;
32-
filters: Writable<Filters>;
33+
filters: Filters;
3334
filterDefaults: Filters;
3435
onFiltersChanged?: OnFiltersChanged;
3536
hasActiveFilter?: boolean;
@@ -46,7 +47,7 @@
4647
let {
4748
searchKey,
4849
autofocus,
49-
filters: allFilters,
50+
filters,
5051
filterDefaults: allFilterDefaults,
5152
onFiltersChanged,
5253
hasActiveFilter = $bindable(false),
@@ -56,53 +57,34 @@
5657
activeFilterSlot,
5758
filterSlot,
5859
}: Props = $props();
59-
let undebouncedSearch: string | undefined = $derived($allFilters[searchKey]);
6060
61-
const watcher = $derived.by(() => {
62-
if (debounce === false) {
63-
return () => undebouncedSearch;
64-
} else {
65-
const debounceTime: number = debounce === true ? DEFAULT_DEBOUNCE_TIME : debounce;
66-
const debouncer = new Debounced(() => undebouncedSearch, debounceTime);
67-
return () => debouncer.current;
68-
}
69-
})
70-
71-
watch(() => watcher(), (value) => {
72-
if ($allFilters[searchKey] === value) return;
73-
$allFilters[searchKey] = value as Filters[typeof searchKey];
61+
// `untrack`: these props are intentionally read once at setup (silences state_referenced_locally)
62+
const search = untrack(() => {
63+
const debounceMs = debounce === true ? DEFAULT_DEBOUNCE_TIME : debounce === false ? 0 : debounce;
64+
return debouncedFilter(filters, searchKey, debounceMs);
7465
});
7566
76-
function onClearFiltersClick(): void {
77-
if (!searchInput) return;
78-
searchInput.clear();
79-
$allFilters = {
80-
...$allFilters,
81-
...filterDefaults,
82-
};
83-
searchInput.focus();
67+
function resetFilter(key: string): void {
68+
(filters as Record<string, unknown>)[key] = (filterDefaults as Record<string, unknown>)[key];
8469
}
8570
86-
function resetFilter(key: string): void {
87-
$allFilters = {
88-
...$allFilters,
89-
[key]: filterDefaults[key],
90-
};
71+
function onClearFiltersClick(): void {
72+
Object.keys(filterDefaults).forEach(resetFilter);
73+
searchInput?.focus();
9174
}
9275
9376
function pickActiveFilters(values: Filters, defaultValues: Filters): Readonly<Filter<Filters>[]> {
9477
const filters: Filter<Filters>[] = [];
95-
for (const key in values) {
96-
const value = values[key];
97-
if (value !== defaultValues[key]) {
78+
for (const key of Object.keys(defaultValues)) {
79+
const value = (values as Record<string, unknown>)[key];
80+
if (value !== (defaultValues as Record<string, unknown>)[key]) {
9881
filters.push({ key, value, clear: () => resetFilter(key) } as Filter<Filters>);
9982
}
10083
}
101-
return Object.freeze(filters);
84+
return filters;
10285
}
10386
104-
let filters = $derived(Object.freeze(filterKeys ? pick($allFilters, filterKeys) : $allFilters));
105-
let filterDefaults = $derived(Object.freeze(filterKeys ? pick(allFilterDefaults, filterKeys) : allFilterDefaults));
87+
let filterDefaults = $derived(filterKeys ? pick(allFilterDefaults, filterKeys) : allFilterDefaults);
10688
let activeFilters: Readonly<Filter<Filters>[]> = $derived(pickActiveFilters(filters, filterDefaults));
10789
let prevActiveFilters = new Previous(() => activeFilters, []);
10890
$effect(() => {
@@ -119,7 +101,7 @@
119101
{@render activeFilterSlot?.({ activeFilters })}
120102
<div class="flex grow">
121103
<PlainInput
122-
bind:value={undebouncedSearch}
104+
bind:value={() => search.value, (v) => (search.value = v ?? '')}
123105
bind:this={searchInput}
124106
placeholder={$t('filter.placeholder')}
125107
style="seach-input border-none h-8 px-1 focus:outline-none min-w-[120px] flex-grow"
@@ -131,8 +113,8 @@
131113
<Loader loading />
132114
</div>
133115
{/if}
134-
<!-- The user sees the "undebounced" search value, so the X button should consider that (and not the debounced value) -->
135-
{#if !!undebouncedSearch || activeFilters.find((f) => f.key !== searchKey)}
116+
<!-- show the X if the input has unflushed typed text, or any non-search filter is active -->
117+
{#if search.value || activeFilters.some((f) => f.key !== searchKey)}
136118
<button class="btn btn-square btn-sm join-item" onclick={onClearFiltersClick}>
137119
<span class="text-lg">✕</span>
138120
</button>

0 commit comments

Comments
 (0)