svelte/sites/svelte.dev/src/lib/search/SearchBox.svelte

421 lines
8.1 KiB

<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>