mirror of https://github.com/sveltejs/svelte
- use search components - use shell componentpull/8453/head
parent
339ea85d55
commit
bad1780d48
@ -1,68 +0,0 @@
|
|||||||
/** @param {HTMLElement} node */
|
|
||||||
export function focusable_children(node) {
|
|
||||||
const nodes = Array.from(
|
|
||||||
node.querySelectorAll(
|
|
||||||
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const index = nodes.indexOf(document.activeElement);
|
|
||||||
|
|
||||||
const update = (d) => {
|
|
||||||
let i = index + d;
|
|
||||||
i += nodes.length;
|
|
||||||
i %= nodes.length;
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
nodes[i].focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
/** @param {string} [selector] */
|
|
||||||
next: (selector) => {
|
|
||||||
const reordered = [...nodes.slice(index + 1), ...nodes.slice(0, index + 1)];
|
|
||||||
|
|
||||||
for (let i = 0; i < reordered.length; i += 1) {
|
|
||||||
if (!selector || reordered[i].matches(selector)) {
|
|
||||||
reordered[i].focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/** @param {string} [selector] */
|
|
||||||
prev: (selector) => {
|
|
||||||
const reordered = [...nodes.slice(index + 1), ...nodes.slice(0, index + 1)];
|
|
||||||
|
|
||||||
for (let i = reordered.length - 2; i >= 0; i -= 1) {
|
|
||||||
if (!selector || reordered[i].matches(selector)) {
|
|
||||||
reordered[i].focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
update
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trap(node) {
|
|
||||||
const handle_keydown = (e) => {
|
|
||||||
if (e.key === 'Tab') {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const group = focusable_children(node);
|
|
||||||
if (e.shiftKey) {
|
|
||||||
group.prev();
|
|
||||||
} else {
|
|
||||||
group.next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
node.addEventListener('keydown', handle_keydown);
|
|
||||||
|
|
||||||
return {
|
|
||||||
destroy: () => {
|
|
||||||
node.removeEventListener('keydown', handle_keydown);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,130 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { searching, query } from './stores.js';
|
|
||||||
|
|
||||||
export let q = '';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<form class="search-container" action="/search">
|
|
||||||
<input
|
|
||||||
value={q}
|
|
||||||
on:input={(e) => {
|
|
||||||
$searching = true;
|
|
||||||
$query = e.currentTarget.value;
|
|
||||||
e.currentTarget.value = '';
|
|
||||||
}}
|
|
||||||
on:mousedown|preventDefault={() => ($searching = true)}
|
|
||||||
on:touchend|preventDefault={() => ($searching = true)}
|
|
||||||
type="search"
|
|
||||||
name="q"
|
|
||||||
placeholder="Search"
|
|
||||||
aria-label="Search"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if browser}
|
|
||||||
<div class="shortcut">
|
|
||||||
<kbd>{navigator.platform === 'MacIntel' ? '⌘' : 'Ctrl'}</kbd> <kbd>K</kbd>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@keyframes fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-container {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
padding: 0.5em 0.5em 0.4em 2em;
|
|
||||||
border: 1px solid var(--sk-back-translucent);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1.4rem;
|
|
||||||
/* text-align: center; */
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
width: 100%;
|
|
||||||
height: 3.2rem;
|
|
||||||
border-radius: var(--sk-border-radius);
|
|
||||||
background: no-repeat 1rem 50% / 1em 1em url(/icons/search.svg);
|
|
||||||
color: var(--sk-text-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus + .shortcut {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut {
|
|
||||||
color: var(--sk-text-3);
|
|
||||||
position: absolute;
|
|
||||||
top: calc(50% - 0.9rem);
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
|
||||||
text-align: right;
|
|
||||||
pointer-events: none;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
animation: fade-in 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
kbd {
|
|
||||||
display: none;
|
|
||||||
background: var(--sk-back-2);
|
|
||||||
border: 1px solid var(--sk-back-translucent);
|
|
||||||
padding: 0.2rem 0.2rem 0rem 0.2rem;
|
|
||||||
color: var(--sk-text-3);
|
|
||||||
font-size: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
|
||||||
.search-container {
|
|
||||||
width: 11rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shortcut {
|
|
||||||
padding: 0 1.6rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
border-radius: 1.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* we're using media query as an imperfect proxy for mobile/desktop */
|
|
||||||
kbd {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 960px) {
|
|
||||||
.search-container {
|
|
||||||
width: 19rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,420 +0,0 @@
|
|||||||
<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>
|
|
@ -1,163 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { createEventDispatcher } from 'svelte';
|
|
||||||
|
|
||||||
/** @type {import('./types').Tree[]} */
|
|
||||||
export let results;
|
|
||||||
|
|
||||||
/** @type {string} */
|
|
||||||
export let query;
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
/** @param {string} text */
|
|
||||||
function escape(text) {
|
|
||||||
return text.replace(/</g, '<').replace(/>/g, '>').replaceAll('`', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} content
|
|
||||||
* @param {string} query
|
|
||||||
*/
|
|
||||||
function excerpt(content, query) {
|
|
||||||
if (content === null) return '';
|
|
||||||
|
|
||||||
const index = content.toLowerCase().indexOf(query.toLowerCase());
|
|
||||||
if (index === -1) {
|
|
||||||
return escape(content.slice(0, 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = index > 20 ? `…${content.slice(index - 15, index)}` : content.slice(0, index);
|
|
||||||
const suffix = content.slice(
|
|
||||||
index + query.length,
|
|
||||||
index + query.length + (80 - (prefix.length + query.length))
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
escape(prefix) +
|
|
||||||
`<mark>${escape(content.slice(index, index + query.length))}</mark>` +
|
|
||||||
escape(suffix)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
{#each results as result (result.href)}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
data-sveltekit-preload-data
|
|
||||||
href={result.href}
|
|
||||||
on:click={() => dispatch('select', { href: result.href })}
|
|
||||||
data-has-node={result.node ? true : undefined}
|
|
||||||
>
|
|
||||||
<strong>{@html excerpt(result.breadcrumbs[result.breadcrumbs.length - 1], query)}</strong>
|
|
||||||
|
|
||||||
{#if result.node?.content}
|
|
||||||
<span>{@html excerpt(result.node.content, query)}</span>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{#if result.children.length > 0}
|
|
||||||
<svelte:self results={result.children} {query} on:select />
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
ul {
|
|
||||||
position: relative;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul :global(ul) {
|
|
||||||
margin-left: 0.8em !important;
|
|
||||||
padding-left: 0em;
|
|
||||||
border-left: 1px solid var(--sk-back-5);
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
list-style: none;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
li:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul ul li {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
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: white;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a strong,
|
|
||||||
a span {
|
|
||||||
display: block;
|
|
||||||
white-space: nowrap;
|
|
||||||
line-height: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
a strong {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
color: var(--sk-text-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
a span {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #737373;
|
|
||||||
margin: 0.4rem 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a :global(mark) {
|
|
||||||
--highlight-color: rgba(255, 255, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
a span :global(mark) {
|
|
||||||
background: none;
|
|
||||||
color: var(--sk-text-1);
|
|
||||||
background: var(--highlight-color);
|
|
||||||
outline: 2px solid var(--highlight-color);
|
|
||||||
border-top: 2px solid var(--highlight-color);
|
|
||||||
/* mix-blend-mode: darken; */
|
|
||||||
}
|
|
||||||
|
|
||||||
a:focus span {
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
a:focus strong {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
a:focus span :global(mark),
|
|
||||||
a:focus strong :global(mark) {
|
|
||||||
--highlight-color: hsl(240, 8%, 54%);
|
|
||||||
mix-blend-mode: lighten;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
a strong :global(mark) {
|
|
||||||
color: var(--sk-text-1);
|
|
||||||
background: var(--highlight-color);
|
|
||||||
outline: 2px solid var(--highlight-color);
|
|
||||||
/* border-top: 2px solid var(--highlight-color); */
|
|
||||||
border-radius: 1px;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,28 +0,0 @@
|
|||||||
<script>
|
|
||||||
import SearchResultList from './SearchResultList.svelte';
|
|
||||||
|
|
||||||
/** @type {import('./types').Tree[]} */
|
|
||||||
export let results;
|
|
||||||
|
|
||||||
/** @type {string} */
|
|
||||||
export let query;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if results.length > 0}
|
|
||||||
<SearchResultList {results} {query} on:select />
|
|
||||||
{:else if query}
|
|
||||||
<p class="info">No results</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.info {
|
|
||||||
padding: 1rem;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: normal;
|
|
||||||
text-transform: uppercase;
|
|
||||||
background-color: var(--sk-back-2);
|
|
||||||
border-radius: 0 0 var(--sk-border-radius) var(--sk-border-radius);
|
|
||||||
pointer-events: all;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -1,107 +0,0 @@
|
|||||||
import flexsearch from 'flexsearch';
|
|
||||||
|
|
||||||
// @ts-expect-error
|
|
||||||
const Index = /** @type {import('flexsearch').Index} */ (flexsearch.Index ?? flexsearch);
|
|
||||||
|
|
||||||
export let inited = false;
|
|
||||||
|
|
||||||
/** @type {import('flexsearch').Index[]} */
|
|
||||||
let indexes;
|
|
||||||
|
|
||||||
/** @type {Map<string, import('./types').Block>} */
|
|
||||||
const map = new Map();
|
|
||||||
|
|
||||||
/** @type {Map<string, string>} */
|
|
||||||
const hrefs = new Map();
|
|
||||||
|
|
||||||
/** @param {import('./types').Block[]} blocks */
|
|
||||||
export function init(blocks) {
|
|
||||||
if (inited) return;
|
|
||||||
|
|
||||||
// we have multiple indexes, so we can rank sections (migration guide comes last)
|
|
||||||
const max_rank = Math.max(...blocks.map((block) => block.rank ?? 0));
|
|
||||||
|
|
||||||
indexes = Array.from({ length: max_rank + 1 }, () => new Index({ tokenize: 'forward' }));
|
|
||||||
|
|
||||||
for (const block of blocks) {
|
|
||||||
const title = block.breadcrumbs.at(-1);
|
|
||||||
map.set(block.href, block);
|
|
||||||
// NOTE: we're not using a number as the ID here, but it is recommended:
|
|
||||||
// https://github.com/nextapps-de/flexsearch#use-numeric-ids
|
|
||||||
// If we were to switch to a number we would need a second map from ID to block
|
|
||||||
// We need to keep the existing one to allow looking up recent searches by URL even if docs change
|
|
||||||
// It's unclear how much browsers do string interning and how this might affect memory
|
|
||||||
// We'd probably want to test both implementations across browsers if memory usage becomes an issue
|
|
||||||
// TODO: fix the type by updating flexsearch after
|
|
||||||
// https://github.com/nextapps-de/flexsearch/pull/364 is merged and released
|
|
||||||
indexes[block.rank ?? 0].add(block.href, `${title} ${block.content}`);
|
|
||||||
|
|
||||||
hrefs.set(block.breadcrumbs.join('::'), block.href);
|
|
||||||
}
|
|
||||||
|
|
||||||
inited = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} query
|
|
||||||
* @returns {import('./types').Block[]}
|
|
||||||
*/
|
|
||||||
export function search(query) {
|
|
||||||
const escaped = query.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
|
|
||||||
const regex = new RegExp(`(^|\\b)${escaped}`, 'i');
|
|
||||||
|
|
||||||
const blocks = indexes
|
|
||||||
.map((index) => index.search(query))
|
|
||||||
.flat()
|
|
||||||
.map(lookup)
|
|
||||||
.map((block, rank) => ({ block, rank }))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const a_title_matches = regex.test(a.block.breadcrumbs.at(-1));
|
|
||||||
const b_title_matches = regex.test(b.block.breadcrumbs.at(-1));
|
|
||||||
|
|
||||||
// massage the order a bit, so that title matches
|
|
||||||
// are given higher priority
|
|
||||||
if (a_title_matches !== b_title_matches) {
|
|
||||||
return a_title_matches ? -1 : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.block.breadcrumbs.length - b.block.breadcrumbs.length || a.rank - b.rank;
|
|
||||||
})
|
|
||||||
.map(({ block }) => block);
|
|
||||||
|
|
||||||
const results = tree([], blocks).children;
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param {string} href */
|
|
||||||
export function lookup(href) {
|
|
||||||
return map.get(href);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string[]} breadcrumbs
|
|
||||||
* @param {import('./types').Block[]} blocks
|
|
||||||
*/
|
|
||||||
function tree(breadcrumbs, blocks) {
|
|
||||||
const depth = breadcrumbs.length;
|
|
||||||
|
|
||||||
const node = blocks.find((block) => {
|
|
||||||
if (block.breadcrumbs.length !== depth) return false;
|
|
||||||
return breadcrumbs.every((part, i) => block.breadcrumbs[i] === part);
|
|
||||||
});
|
|
||||||
|
|
||||||
const descendants = blocks.filter((block) => {
|
|
||||||
if (block.breadcrumbs.length <= depth) return false;
|
|
||||||
return breadcrumbs.every((part, i) => block.breadcrumbs[i] === part);
|
|
||||||
});
|
|
||||||
|
|
||||||
const child_parts = Array.from(new Set(descendants.map((block) => block.breadcrumbs[depth])));
|
|
||||||
|
|
||||||
return {
|
|
||||||
breadcrumbs,
|
|
||||||
href: hrefs.get(breadcrumbs.join('::')),
|
|
||||||
node,
|
|
||||||
children: child_parts.map((part) => tree([...breadcrumbs, part], descendants)),
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
import { persisted } from 'svelte-local-storage-store';
|
|
||||||
|
|
||||||
export const searching = writable(false);
|
|
||||||
export const query = writable('');
|
|
||||||
|
|
||||||
/** @type {import('svelte/store').Writable<any[]>} */
|
|
||||||
export const recent = persisted('recent_searches', []);
|
|
@ -1,13 +0,0 @@
|
|||||||
export interface Block {
|
|
||||||
breadcrumbs: string[];
|
|
||||||
href: string;
|
|
||||||
content: string;
|
|
||||||
rank: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Tree {
|
|
||||||
breadcrumbs: string[];
|
|
||||||
href: string;
|
|
||||||
node: Block;
|
|
||||||
children: Tree[];
|
|
||||||
}
|
|
@ -1,26 +0,0 @@
|
|||||||
import { init, search, lookup } from '../search/search.js';
|
|
||||||
|
|
||||||
addEventListener('message', async (event) => {
|
|
||||||
const { type, payload } = event.data;
|
|
||||||
|
|
||||||
if (type === 'init') {
|
|
||||||
const res = await fetch(`${payload.origin}/content.json`);
|
|
||||||
const { blocks } = await res.json();
|
|
||||||
init(blocks);
|
|
||||||
|
|
||||||
postMessage({ type: 'ready' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'query') {
|
|
||||||
const query = payload;
|
|
||||||
const results = search(query);
|
|
||||||
|
|
||||||
postMessage({ type: 'results', payload: { results, query } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'recents') {
|
|
||||||
const results = payload.map(lookup).filter(Boolean);
|
|
||||||
|
|
||||||
postMessage({ type: 'recents', payload: results });
|
|
||||||
}
|
|
||||||
});
|
|
Loading…
Reference in new issue