|
| 1 | +<script lang="ts"> |
| 2 | + import { untrack } from 'svelte'; |
| 3 | + import type { Pagefind } from 'vite-plugin-pagefind/types'; |
| 4 | + // Utils |
| 5 | + import { docSearchSettingsStore } from 'src/stores/doc-search-settings'; |
| 6 | + import { frameworks, isFramework, preferredFrameworkStore } from 'src/stores/preferred-framework'; |
| 7 | + // Icons |
| 8 | + import IconSearch from 'lucide-svelte/icons/search'; |
| 9 | + import IconFilter from 'lucide-svelte/icons/filter'; |
| 10 | + import IconBook from 'lucide-svelte/icons/book'; |
| 11 | + import IconHash from 'lucide-svelte/icons/hash'; |
| 12 | + import IconLoader from 'lucide-svelte/icons/loader'; |
| 13 | + import IconChevronRight from 'lucide-svelte/icons/chevron-right'; |
| 14 | +
|
| 15 | + let dialog: HTMLDialogElement | null = $state(null); |
| 16 | + let pagefind: Pagefind | null = $state(null); |
| 17 | + let query = $state(''); |
| 18 | + let docSearchSettings = $state(docSearchSettingsStore.get()); |
| 19 | + let showFilters = $state(false); |
| 20 | +
|
| 21 | + $effect(() => { |
| 22 | + const unsubscribe = docSearchSettingsStore.listen((value) => { |
| 23 | + docSearchSettings = value; |
| 24 | + }); |
| 25 | +
|
| 26 | + return unsubscribe; |
| 27 | + }); |
| 28 | +
|
| 29 | + $effect(() => docSearchSettingsStore.set(docSearchSettings)); |
| 30 | +
|
| 31 | + const searchPromise = $derived.by(() => { |
| 32 | + // Define deps since async context is not reactive |
| 33 | + [pagefind, query, docSearchSettings.framework]; |
| 34 | +
|
| 35 | + return untrack(async () => { |
| 36 | + if (pagefind === null || query === '') return []; |
| 37 | +
|
| 38 | + const result = await pagefind.debouncedSearch(query, {}, 250); |
| 39 | + if (result === null) return []; |
| 40 | +
|
| 41 | + const results = await Promise.all(result.results.map((result) => result.data())); |
| 42 | + return results.filter((result) => { |
| 43 | + if (docSearchSettings.framework === 'all') { |
| 44 | + return true; |
| 45 | + } |
| 46 | +
|
| 47 | + const urlFramework = result.url.split('/').at(-2); |
| 48 | +
|
| 49 | + if (!isFramework(urlFramework)) { |
| 50 | + return true; |
| 51 | + } |
| 52 | +
|
| 53 | + if (docSearchSettings.framework === 'preferred') { |
| 54 | + return preferredFrameworkStore.get() === urlFramework; |
| 55 | + } |
| 56 | +
|
| 57 | + return urlFramework === docSearchSettings.framework; |
| 58 | + }); |
| 59 | + }); |
| 60 | + }); |
| 61 | +
|
| 62 | + const openDialog = () => dialog && dialog.showModal(); |
| 63 | +
|
| 64 | + const onClickOutside = (node: HTMLDialogElement) => { |
| 65 | + const onclick = (event: MouseEvent) => { |
| 66 | + if (event.target === null || !(event.target instanceof Element)) return; |
| 67 | + if (event.target.tagName !== 'DIALOG') return; // prevents issues with forms |
| 68 | +
|
| 69 | + const rect = event.target.getBoundingClientRect(); |
| 70 | +
|
| 71 | + const clickedInDialog = |
| 72 | + rect.top <= event.clientY && |
| 73 | + event.clientY <= rect.top + rect.height && |
| 74 | + rect.left <= event.clientX && |
| 75 | + event.clientX <= rect.left + rect.width; |
| 76 | +
|
| 77 | + if (clickedInDialog === false) { |
| 78 | + node.close(); |
| 79 | + query = ''; |
| 80 | + } |
| 81 | + }; |
| 82 | +
|
| 83 | + document.addEventListener('click', onclick); |
| 84 | +
|
| 85 | + return { |
| 86 | + destroy: () => { |
| 87 | + document.removeEventListener('click', onclick); |
| 88 | + }, |
| 89 | + }; |
| 90 | + }; |
| 91 | +
|
| 92 | + const toggleFilters = () => (showFilters = !showFilters); |
| 93 | +
|
| 94 | + $effect(() => { |
| 95 | + // @ts-expect-error - Pagefind will be present at runtime |
| 96 | + import('/pagefind/pagefind.js').then((module: Pagefind) => { |
| 97 | + pagefind = module; |
| 98 | + pagefind.init(); |
| 99 | + }); |
| 100 | +
|
| 101 | + return () => { |
| 102 | + if (!pagefind) return; |
| 103 | + pagefind.destroy(); |
| 104 | + pagefind = null; |
| 105 | + }; |
| 106 | + }); |
| 107 | +
|
| 108 | + $effect(() => { |
| 109 | + function onKeydown(event: KeyboardEvent) { |
| 110 | + if (event.key === 'k' && (event.ctrlKey || event.metaKey)) { |
| 111 | + event.preventDefault(); |
| 112 | + openDialog(); |
| 113 | + } |
| 114 | + } |
| 115 | + document.addEventListener('keydown', onKeydown); |
| 116 | + return () => { |
| 117 | + document.removeEventListener('keydown', onKeydown); |
| 118 | + }; |
| 119 | + }); |
| 120 | +</script> |
| 121 | + |
| 122 | +<!-- Trigger Button --> |
| 123 | +<button onclick={() => openDialog()} type="button" class="btn preset-outlined-surface-200-800 hover:preset-tonal"> |
| 124 | + <IconSearch class="opacity-60 size-4" /> |
| 125 | + <span class="opacity-60">Search...</span> |
| 126 | +</button> |
| 127 | + |
| 128 | +<!-- Dialog --> |
| 129 | +<dialog |
| 130 | + class="bg-surface-50-950 text-inherit rounded-container m-0 mx-auto top-[10%] p-8 w-full max-w-[90%] md:max-w-2xl lg:max-w-4xl max-h-[75%] shadow-xl space-y-8 backdrop:bg-[rgba(var(--color-surface-900)/0.5)] backdrop:backdrop-blur-sm" |
| 131 | + bind:this={dialog} |
| 132 | + use:onClickOutside |
| 133 | +> |
| 134 | + <!-- Search Field --> |
| 135 | + <div class="input-group grid-cols-[auto_1fr_auto]"> |
| 136 | + <div class="input-group-cell"> |
| 137 | + <IconSearch class="opacity-60 size-4" /> |
| 138 | + </div> |
| 139 | + <input placeholder="Search..." bind:value={query} /> |
| 140 | + <button |
| 141 | + type="button" |
| 142 | + class="btn-icon preset-tonal scale-75 translate-y-0.5" |
| 143 | + onclick={toggleFilters} |
| 144 | + title="Show Filters" |
| 145 | + tabindex="-1" |
| 146 | + > |
| 147 | + <IconFilter class="size-6" /> |
| 148 | + </button> |
| 149 | + </div> |
| 150 | + <!-- Filters --> |
| 151 | + {#if showFilters} |
| 152 | + <label class="label"> |
| 153 | + <select class="select" bind:value={docSearchSettings.framework}> |
| 154 | + <option value="preferred">Preferred Framework</option> |
| 155 | + {#each frameworks as framework} |
| 156 | + <option value={framework.slug}>Only {framework.name}</option> |
| 157 | + {/each} |
| 158 | + <option value="all">All Frameworks</option> |
| 159 | + </select> |
| 160 | + </label> |
| 161 | + {/if} |
| 162 | + <!-- Results --> |
| 163 | + <article class="[&_mark]:code [&_mark]:text-inherit"> |
| 164 | + {#await searchPromise} |
| 165 | + <p class="text-center py-10"><IconLoader class="size-4 inline ml-2 animate-spin" /></p> |
| 166 | + {:then results} |
| 167 | + {#if results.length === 0 && query !== ''} |
| 168 | + <p class="text-center py-10">No results found for <code class="code">{query}</code></p> |
| 169 | + {:else if results.length === 0} |
| 170 | + <p class="text-center py-10">What can we help you find?</p> |
| 171 | + {:else} |
| 172 | + <ol class="flex flex-col gap-4 space-y-4"> |
| 173 | + {#each results as result} |
| 174 | + <li class="space-y-2"> |
| 175 | + <!-- Page Result --> |
| 176 | + <a |
| 177 | + class="card preset-outlined-surface-100-900 hover:preset-tonal grid grid-cols-[auto_1fr_auto] gap-4 items-center p-4" |
| 178 | + href={result.url} |
| 179 | + > |
| 180 | + <span><IconBook class="size-6 opacity-60" /></span> |
| 181 | + <div class="space-y-1"> |
| 182 | + <p class="type-scale-4 font-bold">{result.meta.title}</p> |
| 183 | + <p class="type-scale-1">{result.url}</p> |
| 184 | + </div> |
| 185 | + <span><IconChevronRight class="size-4 opacity-60" /></span> |
| 186 | + </a> |
| 187 | + <!-- Inner Result --> |
| 188 | + <div |
| 189 | + class="border-l border-surface-200-800 pl-4 divide-y-[1px] divide-surface-100-900 space-y-2" |
| 190 | + > |
| 191 | + {#each result.sub_results.filter((r) => r.title !== result.meta.title) as subResult} |
| 192 | + <a |
| 193 | + class="card preset-outlined-surface-100-900 hover:preset-tonal grid grid-cols-[auto_1fr_auto] gap-4 items-center p-4 space-y-1" |
| 194 | + href={subResult.url} |
| 195 | + > |
| 196 | + <span class="hidden md:block"> |
| 197 | + <IconHash class="size-4 opacity-60" /> |
| 198 | + </span> |
| 199 | + <div class="space-y-1 overflow-hidden"> |
| 200 | + <p class="type-scale-3 font-bold">{subResult.title}</p> |
| 201 | + <p class="type-scale-1 text-surface-600-400 break-words"> |
| 202 | + {@html subResult.excerpt} |
| 203 | + </p> |
| 204 | + </div> |
| 205 | + <span class="hidden md:block"> |
| 206 | + <IconChevronRight class="size-4 opacity-60" /> |
| 207 | + </span> |
| 208 | + </a> |
| 209 | + {/each} |
| 210 | + </div> |
| 211 | + </li> |
| 212 | + {/each} |
| 213 | + </ol> |
| 214 | + {/if} |
| 215 | + {/await} |
| 216 | + </article> |
| 217 | +</dialog> |
0 commit comments