searchsocket 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ <!--
2
+ SearchDialog — Cmd+K search dialog for SearchSocket
3
+ Minimal Tailwind 4 starting point. Customize freely.
4
+ For non-SvelteKit apps, replace `goto` with `window.location.href = url`.
5
+ -->
6
+ <script lang="ts">
7
+ import { createSearch } from "searchsocket/svelte";
8
+ import { buildResultUrl } from "searchsocket/client";
9
+ import { goto } from "$app/navigation";
10
+
11
+ let {
12
+ open = $bindable(false),
13
+ endpoint = "/api/search",
14
+ placeholder = "Search...",
15
+ }: {
16
+ open?: boolean;
17
+ endpoint?: string;
18
+ placeholder?: string;
19
+ } = $props();
20
+
21
+ const search = createSearch({ endpoint, topK: 8, groupBy: "page" });
22
+
23
+ let activeIndex = $state(-1);
24
+ let inputEl = $state<HTMLInputElement | null>(null);
25
+ let listboxId = "ss-listbox";
26
+
27
+ // Reset active index when results change
28
+ $effect(() => {
29
+ search.results;
30
+ activeIndex = -1;
31
+ });
32
+
33
+ // Focus input when dialog opens
34
+ $effect(() => {
35
+ if (open && inputEl) inputEl.focus();
36
+ });
37
+
38
+ // Lock body scroll when open
39
+ $effect(() => {
40
+ if (open) document.body.style.overflow = "hidden";
41
+ return () => { document.body.style.overflow = ""; };
42
+ });
43
+
44
+ // Global Cmd+K / Ctrl+K shortcut
45
+ $effect(() => {
46
+ function onKeydown(e: KeyboardEvent) {
47
+ if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
48
+ e.preventDefault();
49
+ open = !open;
50
+ }
51
+ }
52
+ document.addEventListener("keydown", onKeydown);
53
+ return () => document.removeEventListener("keydown", onKeydown);
54
+ });
55
+
56
+ // Cleanup search state on destroy
57
+ $effect(() => {
58
+ return () => search.destroy();
59
+ });
60
+
61
+ function activeOptionId(): string | undefined {
62
+ return activeIndex >= 0 && activeIndex < search.results.length
63
+ ? `ss-option-${activeIndex}`
64
+ : undefined;
65
+ }
66
+
67
+ function handleKeydown(e: KeyboardEvent) {
68
+ const count = search.results.length;
69
+ switch (e.key) {
70
+ case "ArrowDown":
71
+ e.preventDefault();
72
+ activeIndex = count > 0 ? (activeIndex + 1) % count : -1;
73
+ break;
74
+ case "ArrowUp":
75
+ e.preventDefault();
76
+ activeIndex = count > 0 ? (activeIndex - 1 + count) % count : -1;
77
+ break;
78
+ case "Enter":
79
+ e.preventDefault();
80
+ if (activeIndex >= 0 && activeIndex < count) navigateTo(search.results[activeIndex]);
81
+ break;
82
+ case "Escape":
83
+ e.preventDefault();
84
+ open = false;
85
+ break;
86
+ }
87
+ }
88
+
89
+ function navigateTo(result: (typeof search.results)[number]) {
90
+ open = false;
91
+ search.query = "";
92
+ goto(buildResultUrl(result));
93
+ }
94
+
95
+ function highlightParts(text: string, query: string): Array<{ text: string; match: boolean }> {
96
+ if (!query.trim()) return [{ text, match: false }];
97
+ const escaped = query.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
98
+ const splitter = new RegExp(`(${escaped})`, "gi");
99
+ const tester = new RegExp(`^(?:${escaped})$`, "i");
100
+ return text.split(splitter).filter(Boolean).map((part) => ({ text: part, match: tester.test(part) }));
101
+ }
102
+ </script>
103
+
104
+ {#if open}
105
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
106
+ <div
107
+ class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh]"
108
+ onclick={() => (open = false)}
109
+ onkeydown={handleKeydown}
110
+ >
111
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
112
+ <div
113
+ class="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900"
114
+ role="dialog"
115
+ aria-modal="true"
116
+ aria-label="Site search"
117
+ onclick={(e) => e.stopPropagation()}
118
+ >
119
+ <input
120
+ bind:this={inputEl}
121
+ type="text"
122
+ role="combobox"
123
+ aria-expanded={search.results.length > 0}
124
+ aria-haspopup="listbox"
125
+ aria-controls={listboxId}
126
+ aria-autocomplete="list"
127
+ aria-activedescendant={activeOptionId()}
128
+ {placeholder}
129
+ value={search.query}
130
+ oninput={(e) => (search.query = e.currentTarget.value)}
131
+ class="w-full border-b bg-transparent px-4 py-3 text-base outline-none dark:border-neutral-700"
132
+ />
133
+
134
+ {#if search.loading}
135
+ <div class="px-4 py-3 text-sm text-neutral-500" aria-live="polite">Searching...</div>
136
+ {/if}
137
+
138
+ {#if search.error}
139
+ <div class="px-4 py-3 text-sm text-red-600" role="alert">{search.error.message}</div>
140
+ {/if}
141
+
142
+ {#if search.results.length > 0}
143
+ <ul role="listbox" id={listboxId} class="max-h-80 overflow-y-auto">
144
+ {#each search.results as result, i}
145
+ <li
146
+ role="option"
147
+ id="ss-option-{i}"
148
+ aria-selected={i === activeIndex}
149
+ class="flex cursor-pointer flex-col gap-0.5 px-4 py-2.5 {i === activeIndex ? 'bg-neutral-100 dark:bg-neutral-800' : ''}"
150
+ onclick={() => navigateTo(result)}
151
+ onmouseenter={() => (activeIndex = i)}
152
+ >
153
+ <span class="font-medium">
154
+ {#each highlightParts(result.title, search.query) as part}
155
+ {#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
156
+ {/each}
157
+ </span>
158
+ {#if result.snippet}
159
+ <span class="text-sm text-neutral-500 dark:text-neutral-400">
160
+ {#each highlightParts(result.snippet, search.query) as part}
161
+ {#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
162
+ {/each}
163
+ </span>
164
+ {/if}
165
+ </li>
166
+ {/each}
167
+ </ul>
168
+ {/if}
169
+
170
+ {#if search.query && !search.loading && search.results.length === 0 && !search.error}
171
+ <div class="px-4 py-3 text-sm text-neutral-500">No results found.</div>
172
+ {/if}
173
+ </div>
174
+ </div>
175
+ {/if}
@@ -0,0 +1,151 @@
1
+ <!--
2
+ SearchInput — Inline search input with dropdown results for SearchSocket
3
+ Minimal Tailwind 4 starting point. Customize freely.
4
+ For non-SvelteKit apps, replace `goto` with `window.location.href = url`.
5
+ -->
6
+ <script lang="ts">
7
+ import { createSearch } from "searchsocket/svelte";
8
+ import { buildResultUrl } from "searchsocket/client";
9
+ import { goto } from "$app/navigation";
10
+
11
+ let {
12
+ endpoint = "/api/search",
13
+ placeholder = "Search...",
14
+ }: {
15
+ endpoint?: string;
16
+ placeholder?: string;
17
+ } = $props();
18
+
19
+ const search = createSearch({ endpoint, topK: 8, groupBy: "page" });
20
+
21
+ let activeIndex = $state(-1);
22
+ let inputEl = $state<HTMLInputElement | null>(null);
23
+ let containerEl = $state<HTMLDivElement | null>(null);
24
+ let dropdownOpen = $state(false);
25
+ let listboxId = "ss-inline-listbox";
26
+
27
+ let showDropdown = $derived(dropdownOpen && search.query.trim().length > 0);
28
+
29
+ // Reset active index when results change
30
+ $effect(() => {
31
+ search.results;
32
+ activeIndex = -1;
33
+ });
34
+
35
+ // Cleanup search state on destroy
36
+ $effect(() => {
37
+ return () => search.destroy();
38
+ });
39
+
40
+ function activeOptionId(): string | undefined {
41
+ return activeIndex >= 0 && activeIndex < search.results.length
42
+ ? `ss-inline-option-${activeIndex}`
43
+ : undefined;
44
+ }
45
+
46
+ function handleKeydown(e: KeyboardEvent) {
47
+ const count = search.results.length;
48
+ switch (e.key) {
49
+ case "ArrowDown":
50
+ e.preventDefault();
51
+ activeIndex = count > 0 ? (activeIndex + 1) % count : -1;
52
+ break;
53
+ case "ArrowUp":
54
+ e.preventDefault();
55
+ activeIndex = count > 0 ? (activeIndex - 1 + count) % count : -1;
56
+ break;
57
+ case "Enter":
58
+ e.preventDefault();
59
+ if (activeIndex >= 0 && activeIndex < count) navigateTo(search.results[activeIndex]);
60
+ break;
61
+ case "Escape":
62
+ e.preventDefault();
63
+ dropdownOpen = false;
64
+ inputEl?.blur();
65
+ break;
66
+ }
67
+ }
68
+
69
+ function handleFocusOut(e: FocusEvent) {
70
+ if (containerEl && !containerEl.contains(e.relatedTarget as Node)) {
71
+ dropdownOpen = false;
72
+ }
73
+ }
74
+
75
+ function navigateTo(result: (typeof search.results)[number]) {
76
+ dropdownOpen = false;
77
+ search.query = "";
78
+ goto(buildResultUrl(result));
79
+ }
80
+
81
+ function highlightParts(text: string, query: string): Array<{ text: string; match: boolean }> {
82
+ if (!query.trim()) return [{ text, match: false }];
83
+ const escaped = query.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
84
+ const splitter = new RegExp(`(${escaped})`, "gi");
85
+ const tester = new RegExp(`^(?:${escaped})$`, "i");
86
+ return text.split(splitter).filter(Boolean).map((part) => ({ text: part, match: tester.test(part) }));
87
+ }
88
+ </script>
89
+
90
+ <div class="relative w-full" bind:this={containerEl} onfocusout={handleFocusOut}>
91
+ <input
92
+ bind:this={inputEl}
93
+ type="text"
94
+ role="combobox"
95
+ aria-expanded={showDropdown && search.results.length > 0}
96
+ aria-haspopup="listbox"
97
+ aria-controls={listboxId}
98
+ aria-autocomplete="list"
99
+ aria-activedescendant={activeOptionId()}
100
+ {placeholder}
101
+ value={search.query}
102
+ oninput={(e) => (search.query = e.currentTarget.value)}
103
+ onfocus={() => (dropdownOpen = true)}
104
+ onkeydown={handleKeydown}
105
+ class="w-full rounded-md border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:border-neutral-700 dark:bg-neutral-900"
106
+ />
107
+
108
+ {#if showDropdown}
109
+ <div class="absolute top-full left-0 right-0 z-50 mt-1 overflow-hidden rounded-md border bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900">
110
+ {#if search.loading}
111
+ <div class="px-3 py-2 text-sm text-neutral-500" aria-live="polite">Searching...</div>
112
+ {/if}
113
+
114
+ {#if search.error}
115
+ <div class="px-3 py-2 text-sm text-red-600" role="alert">{search.error.message}</div>
116
+ {/if}
117
+
118
+ {#if search.results.length > 0}
119
+ <ul role="listbox" id={listboxId} class="max-h-72 overflow-y-auto">
120
+ {#each search.results as result, i}
121
+ <li
122
+ role="option"
123
+ id="ss-inline-option-{i}"
124
+ aria-selected={i === activeIndex}
125
+ class="flex cursor-pointer flex-col gap-0.5 px-3 py-2 {i === activeIndex ? 'bg-neutral-100 dark:bg-neutral-800' : ''}"
126
+ onclick={() => navigateTo(result)}
127
+ onmouseenter={() => (activeIndex = i)}
128
+ >
129
+ <span class="font-medium">
130
+ {#each highlightParts(result.title, search.query) as part}
131
+ {#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
132
+ {/each}
133
+ </span>
134
+ {#if result.snippet}
135
+ <span class="text-sm text-neutral-500 dark:text-neutral-400">
136
+ {#each highlightParts(result.snippet, search.query) as part}
137
+ {#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
138
+ {/each}
139
+ </span>
140
+ {/if}
141
+ </li>
142
+ {/each}
143
+ </ul>
144
+ {/if}
145
+
146
+ {#if search.query && !search.loading && search.results.length === 0 && !search.error}
147
+ <div class="px-3 py-2 text-sm text-neutral-500">No results found.</div>
148
+ {/if}
149
+ </div>
150
+ {/if}
151
+ </div>
@@ -0,0 +1,75 @@
1
+ <!--
2
+ SearchResults — Standalone result list for SearchSocket
3
+ Minimal Tailwind 4 starting point. Customize freely.
4
+ Use this when you manage search state yourself and just need a result display.
5
+ -->
6
+ <script lang="ts">
7
+ import { buildResultUrl } from "searchsocket/client";
8
+
9
+ interface SearchResultItem {
10
+ url: string;
11
+ title: string;
12
+ sectionTitle?: string;
13
+ snippet: string;
14
+ score: number;
15
+ routeFile: string;
16
+ chunks?: { sectionTitle?: string; snippet: string; headingPath: string[]; score: number }[];
17
+ }
18
+
19
+ let {
20
+ results = [],
21
+ query = "",
22
+ loading = false,
23
+ error = null,
24
+ }: {
25
+ results?: SearchResultItem[];
26
+ query?: string;
27
+ loading?: boolean;
28
+ error?: Error | null;
29
+ } = $props();
30
+
31
+ function highlightParts(text: string, q: string): Array<{ text: string; match: boolean }> {
32
+ if (!q.trim()) return [{ text, match: false }];
33
+ const escaped = q.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
34
+ const splitter = new RegExp(`(${escaped})`, "gi");
35
+ const tester = new RegExp(`^(?:${escaped})$`, "i");
36
+ return text.split(splitter).filter(Boolean).map((part) => ({ text: part, match: tester.test(part) }));
37
+ }
38
+ </script>
39
+
40
+ <div class="w-full">
41
+ {#if loading}
42
+ <div class="py-3 text-sm text-neutral-500" aria-live="polite">Searching...</div>
43
+ {/if}
44
+
45
+ {#if error}
46
+ <div class="py-3 text-sm text-red-600" role="alert">{error.message}</div>
47
+ {/if}
48
+
49
+ {#if results.length > 0}
50
+ <ul class="divide-y divide-neutral-200 dark:divide-neutral-700">
51
+ {#each results as result}
52
+ <li>
53
+ <a href={buildResultUrl(result)} class="flex flex-col gap-1 py-3 no-underline hover:opacity-80">
54
+ <span class="font-medium">
55
+ {#each highlightParts(result.title, query) as part}
56
+ {#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
57
+ {/each}
58
+ </span>
59
+ {#if result.snippet}
60
+ <span class="text-sm text-neutral-500 dark:text-neutral-400">
61
+ {#each highlightParts(result.snippet, query) as part}
62
+ {#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
63
+ {/each}
64
+ </span>
65
+ {/if}
66
+ </a>
67
+ </li>
68
+ {/each}
69
+ </ul>
70
+ {/if}
71
+
72
+ {#if query && !loading && results.length === 0 && !error}
73
+ <div class="py-3 text-sm text-neutral-500">No results found.</div>
74
+ {/if}
75
+ </div>