Skip to content

Commit 590f2ea

Browse files
danieladugyanGoogle Antigravity CLI
andauthored
New design for songbook feature (#1228)
Co-authored-by: Google Antigravity CLI <noreply@google.com>
1 parent 32b76eb commit 590f2ea

19 files changed

Lines changed: 1122 additions & 190 deletions

src/lib/components/MemberSelector.svelte

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { Button } from "$lib/components/ui/button";
1313
import { onMount } from "svelte";
1414
import { cn } from "$lib/utils";
15+
import { debounce } from "$lib/utils/debounce";
1516
1617
let {
1718
selectedMembers = $bindable([]),
@@ -72,7 +73,6 @@
7273
});
7374
7475
let input = $state("");
75-
let timeout: ReturnType<typeof setTimeout> | null = null;
7676
let results: SearchDataWithType[] = $state([]);
7777
let filteredResults: MemberSearchReturnAttributes[] = $derived<
7878
MemberSearchReturnAttributes[]
@@ -86,37 +86,33 @@
8686
}),
8787
);
8888
89-
async function handleSearch() {
90-
if (timeout) clearTimeout(timeout);
89+
const debouncedSearch = debounce(async (searchQuery: string) => {
90+
const url = new URL("/api/search", window.location.origin);
91+
url.searchParams.set("query", searchQuery);
92+
url.searchParams.set("indexes", JSON.stringify(["members"]));
93+
url.searchParams.set("limit", "10");
94+
url.searchParams.set("offset", "0");
95+
const response = await fetch(url, {
96+
method: "GET",
97+
});
9198
99+
if (response.ok) {
100+
results = [...(await response.json())];
101+
} else {
102+
results = [];
103+
}
104+
isSearching = false;
105+
}, 200);
106+
107+
function handleSearch() {
92108
if (!input) {
109+
debouncedSearch.cancel();
93110
isSearching = false;
94111
results = [];
95112
return;
96-
} else {
97-
timeout = setTimeout(async () => {
98-
if (!input) {
99-
results = [];
100-
return;
101-
}
102-
const url = new URL("/api/search", window.location.origin);
103-
url.searchParams.set("query", input);
104-
url.searchParams.set("indexes", JSON.stringify(["members"]));
105-
url.searchParams.set("limit", "10");
106-
url.searchParams.set("offset", "0");
107-
const response = await fetch(url, {
108-
method: "GET",
109-
});
110-
111-
if (response.ok) {
112-
results = [...(await response.json())];
113-
} else {
114-
results = [];
115-
}
116-
isSearching = false;
117-
}, 200);
118-
isSearching = true;
119113
}
114+
isSearching = true;
115+
debouncedSearch(input);
120116
}
121117
122118
function captureAddedItems() {

src/lib/components/Pagination.svelte

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
import { page } from "$app/state";
33
import * as Pagination from "$lib/components/ui/pagination";
44
import { cn } from "$lib/utils";
5+
import type { ClassValue } from "svelte/elements";
56
import { SvelteURLSearchParams } from "svelte/reactivity";
67
7-
let { pageCount = 10, class: klass }: { pageCount?: number; class?: string } =
8-
$props();
8+
let {
9+
pageCount = 10,
10+
class: klass,
11+
}: { pageCount?: number; class?: ClassValue } = $props();
912
1013
let thisPage = $derived(
1114
Number.parseInt(page.url.searchParams?.get("page") ?? "1"),

src/lib/components/search/CommandDialog.svelte

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import SongSearchResult from "./SongSearchResult.svelte";
2727
import DocumentSearchResult from "./DocumentSearchResult.svelte";
2828
import { resolve } from "$app/paths";
29+
import { debounce } from "$lib/utils/debounce";
2930
3031
let { open = $bindable(false) } = $props();
3132
@@ -37,7 +38,6 @@
3738
let input = $state("");
3839
let currentIndex = $state(-1);
3940
let isSearching = $state(false);
40-
let timeout: ReturnType<typeof setTimeout> | null = null;
4141
let results: SearchDataWithType[] = $state([]);
4242
4343
let groupedResults = $derived<{
@@ -72,25 +72,20 @@
7272
songs: results.filter((r) => r.type === "songs"),
7373
});
7474
75-
function handleSearch() {
76-
// Cancel the previous timeout
77-
if (timeout) clearTimeout(timeout);
75+
const debouncedSearch = debounce(() => {
76+
formElement?.requestSubmit();
77+
currentIndex = -1;
78+
}, 300);
7879
79-
// When user requests a search with empty string
80-
// Happens when the user deletes the last key of the input
81-
// We shouldn't search then
80+
function handleSearch() {
8281
if (!input) {
82+
debouncedSearch.cancel();
8383
isSearching = false;
8484
results = [];
8585
return;
86-
} else {
87-
// Do the search after 300ms
88-
timeout = setTimeout(() => {
89-
formElement?.requestSubmit();
90-
currentIndex = -1;
91-
}, 300);
92-
isSearching = true;
9386
}
87+
isSearching = true;
88+
debouncedSearch();
9489
}
9590
9691
function captureListItems() {
@@ -186,6 +181,7 @@
186181
// Reset input when dialog closes
187182
$effect(() => {
188183
if (!open) {
184+
debouncedSearch.cancel();
189185
input = "";
190186
results = [];
191187
currentIndex = -1;

src/lib/utils/debounce.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { debounce } from "./debounce";
3+
4+
describe("debounce", () => {
5+
beforeEach(() => {
6+
vi.useFakeTimers();
7+
});
8+
9+
afterEach(() => {
10+
vi.useRealTimers();
11+
});
12+
13+
it("should debounce function calls", () => {
14+
const fn = vi.fn();
15+
const debounced = debounce(fn, 100);
16+
17+
debounced("a");
18+
debounced("b");
19+
debounced("c");
20+
21+
expect(fn).not.toHaveBeenCalled();
22+
23+
vi.advanceTimersByTime(50);
24+
expect(fn).not.toHaveBeenCalled();
25+
26+
vi.advanceTimersByTime(50);
27+
expect(fn).toHaveBeenCalledTimes(1);
28+
expect(fn).toHaveBeenCalledWith("c");
29+
});
30+
31+
it("should support cancel", () => {
32+
const fn = vi.fn();
33+
const debounced = debounce(fn, 100);
34+
35+
debounced("a");
36+
vi.advanceTimersByTime(50);
37+
debounced.cancel();
38+
39+
vi.advanceTimersByTime(50);
40+
expect(fn).not.toHaveBeenCalled();
41+
});
42+
43+
it("should support flush", () => {
44+
const fn = vi.fn();
45+
const debounced = debounce(fn, 100);
46+
47+
debounced("a");
48+
debounced.flush();
49+
50+
expect(fn).toHaveBeenCalledTimes(1);
51+
expect(fn).toHaveBeenCalledWith("a");
52+
53+
vi.advanceTimersByTime(100);
54+
expect(fn).toHaveBeenCalledTimes(1); // Not called again
55+
});
56+
});

src/lib/utils/debounce.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* Creates a debounced function that delays invoking the provided function
3+
* until after `delay` milliseconds have elapsed since the last time the debounced
4+
* function was invoked.
5+
*
6+
* The debounced function comes with `.cancel()` and `.flush()` methods.
7+
* - `.cancel()` cancels any pending invocation.
8+
* - `.flush()` immediately invokes any pending execution.
9+
*
10+
* @template Args - The types of the arguments of the function to debounce.
11+
* @param fn - The function to debounce.
12+
* @param delay - The number of milliseconds to delay.
13+
* @returns The debounced function with cancel and flush capabilities.
14+
*/
15+
export function debounce<Args extends unknown[]>(
16+
fn: (...args: Args) => void | Promise<void>,
17+
delay: number,
18+
): ((...args: Args) => void) & { cancel: () => void; flush: () => void } {
19+
let timeout: ReturnType<typeof setTimeout> | null = null;
20+
let lastArgs: Args | null = null;
21+
22+
const debounced = (...args: Args) => {
23+
lastArgs = args;
24+
if (timeout) {
25+
clearTimeout(timeout);
26+
}
27+
timeout = setTimeout(() => {
28+
if (lastArgs) {
29+
fn(...lastArgs);
30+
lastArgs = null;
31+
}
32+
timeout = null;
33+
}, delay);
34+
};
35+
36+
debounced.cancel = () => {
37+
if (timeout) {
38+
clearTimeout(timeout);
39+
timeout = null;
40+
}
41+
lastArgs = null;
42+
};
43+
44+
debounced.flush = () => {
45+
if (timeout) {
46+
clearTimeout(timeout);
47+
timeout = null;
48+
}
49+
if (lastArgs) {
50+
fn(...lastArgs);
51+
lastArgs = null;
52+
}
53+
};
54+
55+
return debounced;
56+
}

src/routes/(app)/news/NewsSearch.svelte

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import ArticleSearchResult from "./ArticleSearchResult.svelte";
1111
import Input from "$lib/components/ui/input/input.svelte";
1212
import { ScrollArea } from "$lib/components/ui/scroll-area";
13+
import { debounce } from "$lib/utils/debounce";
1314
1415
import Search from "@lucide/svelte/icons/search";
1516
@@ -19,7 +20,6 @@
1920
let inputElement: HTMLInputElement | null = $state(null);
2021
2122
let input = $state("");
22-
let timeout: ReturnType<typeof setTimeout> | null = null;
2323
let results: SearchDataWithType[] = $state([]);
2424
2525
let groupedResults = $derived<{
@@ -28,22 +28,17 @@
2828
articles: results.filter((r) => r.type === "articles"),
2929
});
3030
31-
function handleSearch() {
32-
// Cancel the previous timeout
33-
if (timeout) clearTimeout(timeout);
31+
const debouncedSearch = debounce(() => {
32+
formElement?.requestSubmit();
33+
}, 300);
3434
35-
// When user requests a search with empty string
36-
// Happens when the user deletes the last key of the input
37-
// We shouldn't search then
35+
function handleSearch() {
3836
if (!input) {
37+
debouncedSearch.cancel();
3938
results = [];
4039
return;
41-
} else {
42-
// Do the search after 300ms
43-
timeout = setTimeout(() => {
44-
formElement?.requestSubmit();
45-
}, 300);
4640
}
41+
debouncedSearch();
4742
}
4843
</script>
4944

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script lang="ts">
2+
let { children } = $props();
3+
</script>
4+
5+
<div class="layout-container py-8">
6+
{@render children()}
7+
</div>

0 commit comments

Comments
 (0)