mirror of https://github.com/sveltejs/svelte
421 lines
8.1 KiB
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>
|