<script> import { onMount } from 'svelte'; import Icon from '@sveltejs/site-kit/components/Icon.svelte'; import { afterNavigate } from '$app/navigation'; import { searching, query, recent } from './stores.js'; import { focusable_children, trap } from '../actions/focus.js'; import SearchResults from './SearchResults.svelte'; import SearchWorker from '$lib/workers/search.js?worker'; let modal; let search = null; let recent_searches = []; let worker; let ready = false; let uid = 1; const pending = new Set(); onMount(async () => { worker = new SearchWorker(); worker.addEventListener('message', (event) => { const { type, payload } = event.data; if (type === 'ready') { ready = true; } if (type === 'results') { search = payload; } if (type === 'recents') { recent_searches = payload; } }); worker.postMessage({ type: 'init', payload: { origin: location.origin, }, }); }); afterNavigate(() => { // TODO this also needs to apply when only the hash changes // (should before/afterNavigate fire at that time? unclear) close(); }); function close() { if ($searching) { $searching = false; const scroll = -parseInt(document.body.style.top || '0'); document.body.style.position = ''; document.body.style.top = ''; document.body.tabIndex = -1; document.body.focus(); document.body.removeAttribute('tabindex'); window.scrollTo(0, scroll); } search = null; } /** @param {string} href */ function navigate(href) { $recent = [href, ...$recent.filter((x) => x !== href)]; close(); } $: if (ready) { const id = uid++; pending.add(id); worker.postMessage({ type: 'query', id, payload: $query }); } $: if (ready) { worker.postMessage({ type: 'recents', payload: $recent }); } $: if ($searching) { document.body.style.top = `-${window.scrollY}px`; document.body.style.position = 'fixed'; } </script> <svelte:window on:keydown={(e) => { if (e.key === 'k' && (navigator.platform === 'MacIntel' ? e.metaKey : e.ctrlKey)) { e.preventDefault(); $query = ''; if ($searching) { close(); } else { $searching = true; } } if (e.code === 'Escape') { close(); } }} /> {#if $searching && ready} <div class="modal-background" on:click={close} on:keyup={(e) => e.key === ' ' && close()} /> <div bind:this={modal} class="modal" on:keydown={(e) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const group = focusable_children(e.currentTarget); // when using arrow keys (as opposed to tab), don't focus buttons const selector = 'a, input'; if (e.key === 'ArrowDown') { group.next(selector); } else { group.prev(selector); } } }} use:trap > <div class="search-box"> <!-- svelte-ignore a11y-autofocus --> <input autofocus on:keydown={(e) => { if (e.key === 'Enter') { modal.querySelector('a[data-has-node]')?.click(); } }} on:input={(e) => ($query = e.currentTarget.value)} value={$query} placeholder="Search" aria-describedby="search-description" aria-label="Search" spellcheck="false" /> <button aria-label="Close" on:click={close}> <Icon name="close" /> </button> <span id="search-description" class="visually-hidden">Results will update as you type</span> <div class="results"> {#if search?.query} <div class="results-container" on:click={() => ($searching = false)} on:keydown={(e) => e.key === ' ' && ($searching = false)} > <SearchResults results={search.results} query={search.query} on:select={(e) => { navigate(e.detail.href); }} /> </div> {:else} <h2 class="info" class:empty={recent_searches.length === 0}> {recent_searches.length ? 'Recent searches' : 'No recent searches'} </h2> {#if recent_searches.length} <div class="results-container"> <ul> {#each recent_searches as search, i} <!-- svelte-ignore a11y-mouse-events-have-key-events --> <li class="recent"> <a on:click={() => navigate(search.href)} href={search.href}> <small>{search.breadcrumbs.join('/')}</small> <strong>{search.breadcrumbs.at(-1)}</strong> </a> <button aria-label="Delete" on:click={(e) => { $recent = $recent.filter((href) => href !== search.href); e.stopPropagation(); e.preventDefault(); }} > <Icon name="delete" /> </button> </li> {/each} </ul> </div> {/if} {/if} </div> </div> </div> {/if} <div aria-live="assertive" class="visually-hidden"> {#if $searching && search?.results.length === 0} <p>No results</p> {/if} </div> <style> input { font-family: inherit; font-size: 1.6rem; width: 100%; padding: 1rem 6rem 0.5rem 1rem; height: 5rem; border: none; border-bottom: 1px solid var(--sk-back-3); font-weight: 600; flex-shrink: 0; background: var(--sk-back-2); color: var(--sk-text-1); } input::selection { background-color: var(--sk-back-translucent); } input::placeholder { color: var(--sk-text-3); opacity: 0.3; } input:focus-visible { background: var(--sk-theme-2); color: white; outline: none; } input:focus-visible::placeholder { color: rgba(255, 255, 255, 0.5); } button[aria-label='Close'] { --size: 2rem; position: absolute; top: 0; right: 0; width: 5rem; height: 5rem; background: none; color: var(--sk-text-2); } button[aria-label='Close']:focus-visible { background: var(--sk-theme-2); color: var(--sk-back-1); outline: none; } input:focus-visible + button[aria-label='Close'] { color: var(--sk-back-1); } ul { margin: 0; } .modal-background, .modal { position: fixed; left: 0; top: 0; width: 100%; height: 100%; z-index: 9999; } .modal-background { background: var(--sk-back-1); opacity: 0.7; } .modal { display: flex; justify-content: center; align-items: center; pointer-events: none; } .search-box { position: relative; height: calc(100% - 2rem); width: calc(100vw - 2rem); max-width: 50rem; max-height: 50rem; filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2)); border-radius: var(--sk-border-radius); display: flex; flex-direction: column; overflow: hidden; } .search-box > * { pointer-events: all; } .results { overflow: auto; overscroll-behavior-y: none; } .results-container { background: var(--sk-back-2); border-radius: 0 0 var(--sk-border-radius) var(--sk-border-radius); pointer-events: all; } .info { padding: 1rem; font-size: 1.2rem; font-weight: normal; text-transform: uppercase; background-color: var(--sk-back-2); pointer-events: all; } .info.empty { border-radius: 0 0 var(--sk-border-radius) var(--sk-border-radius); } a { display: block; text-decoration: none; line-height: 1; padding: 1rem; } a:hover { background: rgba(0, 0, 0, 0.05); } a:focus { background: var(--sk-theme-2); color: var(--sk-back-1); outline: none; } a small, a strong { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1; } a small { font-size: 1rem; text-transform: uppercase; font-weight: 600; color: var(--sk-text-3); } a strong { font-size: 1.6rem; color: var(--sk-text-2); margin: 0.4rem 0; } a:focus small { color: white; opacity: 0.6; } a:focus strong { color: white; } a strong :global(mark) { background: var(--sk-theme-2); color: var(--sk-text-3); text-decoration: none; border-radius: 1px; } li { position: relative; } button[aria-label='Delete'] { position: absolute; top: 0; right: 0; width: 5rem; height: 100%; color: var(--sk-text-2); opacity: 0.1; } a:focus + [aria-label='Delete'] { color: var(--sk-back-1); } button[aria-label='Delete']:hover { opacity: 1; outline: none; } button[aria-label='Delete']:focus-visible { background: var(--sk-theme-2); color: var(--sk-text-1); opacity: 1; outline: none; } </style>