|
16 | 16 | import {PlainInput} from '$lib/forms'; |
17 | 17 | import {pick} from '$lib/util/object'; |
18 | 18 | import t from '$lib/i18n'; |
19 | | - import type {Writable} from 'svelte/store'; |
20 | 19 | import Dropdown from '../Dropdown.svelte'; |
21 | | - import {Previous, Debounced, watch} from 'runed'; |
| 20 | + import {Previous} from 'runed'; |
| 21 | + import {untrack} from 'svelte'; |
22 | 22 | import {DEFAULT_DEBOUNCE_TIME} from '$lib/util/time'; |
| 23 | + import {debouncedFilter} from '$lib/util/debouncedFilter.svelte'; |
23 | 24 |
|
24 | 25 | type DumbFilters = $$Generic<Record<string, unknown>>; |
25 | 26 | type Filters = DumbFilters & Record<typeof searchKey, string>; |
|
29 | 30 | interface Props { |
30 | 31 | searchKey: keyof ConditionalPick<DumbFilters, string>; |
31 | 32 | autofocus?: true; |
32 | | - filters: Writable<Filters>; |
| 33 | + filters: Filters; |
33 | 34 | filterDefaults: Filters; |
34 | 35 | onFiltersChanged?: OnFiltersChanged; |
35 | 36 | hasActiveFilter?: boolean; |
|
46 | 47 | let { |
47 | 48 | searchKey, |
48 | 49 | autofocus, |
49 | | - filters: allFilters, |
| 50 | + filters, |
50 | 51 | filterDefaults: allFilterDefaults, |
51 | 52 | onFiltersChanged, |
52 | 53 | hasActiveFilter = $bindable(false), |
|
56 | 57 | activeFilterSlot, |
57 | 58 | filterSlot, |
58 | 59 | }: Props = $props(); |
59 | | - let undebouncedSearch: string | undefined = $derived($allFilters[searchKey]); |
60 | 60 |
|
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); |
74 | 65 | }); |
75 | 66 |
|
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]; |
84 | 69 | } |
85 | 70 |
|
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(); |
91 | 74 | } |
92 | 75 |
|
93 | 76 | function pickActiveFilters(values: Filters, defaultValues: Filters): Readonly<Filter<Filters>[]> { |
94 | 77 | 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]) { |
98 | 81 | filters.push({ key, value, clear: () => resetFilter(key) } as Filter<Filters>); |
99 | 82 | } |
100 | 83 | } |
101 | | - return Object.freeze(filters); |
| 84 | + return filters; |
102 | 85 | } |
103 | 86 |
|
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); |
106 | 88 | let activeFilters: Readonly<Filter<Filters>[]> = $derived(pickActiveFilters(filters, filterDefaults)); |
107 | 89 | let prevActiveFilters = new Previous(() => activeFilters, []); |
108 | 90 | $effect(() => { |
|
119 | 101 | {@render activeFilterSlot?.({ activeFilters })} |
120 | 102 | <div class="flex grow"> |
121 | 103 | <PlainInput |
122 | | - bind:value={undebouncedSearch} |
| 104 | + bind:value={() => search.value, (v) => (search.value = v ?? '')} |
123 | 105 | bind:this={searchInput} |
124 | 106 | placeholder={$t('filter.placeholder')} |
125 | 107 | style="seach-input border-none h-8 px-1 focus:outline-none min-w-[120px] flex-grow" |
|
131 | 113 | <Loader loading /> |
132 | 114 | </div> |
133 | 115 | {/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)} |
136 | 118 | <button class="btn btn-square btn-sm join-item" onclick={onClearFiltersClick}> |
137 | 119 | <span class="text-lg">✕</span> |
138 | 120 | </button> |
|
0 commit comments