mirror of https://github.com/sveltejs/svelte
feat: search and backlink compatability (#8286)
* Copy the right files * Finish search * FIx accessibility issues * Add original site compatibility back in * Remove console.log * Reorganize imports * Minor refactor * Fix undefined heading issue * Replace state on redirect * Don't redirect to docs/introduction from navbar * Cleanup search * Cleanup some more(html entities) * Remove console log * Minor style tweaks * Put search in middlepull/8288/head
parent
1b12546afe
commit
df2bb23af4
@ -0,0 +1,68 @@
|
|||||||
|
/** @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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,130 @@
|
|||||||
|
<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>
|
@ -0,0 +1,420 @@
|
|||||||
|
<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>
|
@ -0,0 +1,163 @@
|
|||||||
|
<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>
|
@ -0,0 +1,28 @@
|
|||||||
|
<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>
|
@ -0,0 +1,133 @@
|
|||||||
|
import { normalizeSlugify, removeMarkdown } from '$lib/server/docs';
|
||||||
|
import { extract_frontmatter, transform } from '$lib/server/markdown';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import glob from 'tiny-glob/sync.js';
|
||||||
|
|
||||||
|
const base = '../../site/content/';
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{
|
||||||
|
slug: 'docs',
|
||||||
|
label: null,
|
||||||
|
/** @param {string[]} parts */
|
||||||
|
href: (parts) =>
|
||||||
|
parts.length > 1 ? `/docs/${parts[0]}#${parts.slice(1).join('-')}` : `/docs/${parts[0]}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'faq',
|
||||||
|
label: 'FAQ',
|
||||||
|
/** @param {string[]} parts */
|
||||||
|
href: (parts) => `/faq#${parts.join('-')}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function content() {
|
||||||
|
/** @type {import('./types').Block[]} */
|
||||||
|
const blocks = [];
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const breadcrumbs = category.label ? [category.label] : [];
|
||||||
|
|
||||||
|
for (const file of glob('**/*.md', { cwd: `${base}/${category.slug}` })) {
|
||||||
|
const basename = path.basename(file);
|
||||||
|
const match = /\d{2}-(.+)\.md/.exec(basename);
|
||||||
|
if (!match) continue;
|
||||||
|
|
||||||
|
const slug = match[1];
|
||||||
|
|
||||||
|
const filepath = `${base}/${category.slug}/${file}`;
|
||||||
|
// const markdown = replace_placeholders(fs.readFileSync(filepath, 'utf-8'));
|
||||||
|
const markdown = fs.readFileSync(filepath, 'utf-8');
|
||||||
|
|
||||||
|
const { body, metadata } = extract_frontmatter(markdown);
|
||||||
|
|
||||||
|
const sections = body.trim().split(/^### /m);
|
||||||
|
const intro = sections.shift().trim();
|
||||||
|
const rank = +metadata.rank || undefined;
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
breadcrumbs: [...breadcrumbs, removeMarkdown(metadata.title ?? '')],
|
||||||
|
href: category.href([slug]),
|
||||||
|
content: plaintext(intro),
|
||||||
|
rank,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
const lines = section.split('\n');
|
||||||
|
const h3 = lines.shift();
|
||||||
|
const content = lines.join('\n');
|
||||||
|
|
||||||
|
const subsections = content.trim().split('### ');
|
||||||
|
|
||||||
|
const intro = subsections.shift().trim();
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
breadcrumbs: [...breadcrumbs, removeMarkdown(metadata.title), removeMarkdown(h3)],
|
||||||
|
href: category.href([slug, normalizeSlugify(h3)]),
|
||||||
|
content: plaintext(intro),
|
||||||
|
rank,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const subsection of subsections) {
|
||||||
|
const lines = subsection.split('\n');
|
||||||
|
const h4 = lines.shift();
|
||||||
|
|
||||||
|
blocks.push({
|
||||||
|
breadcrumbs: [
|
||||||
|
...breadcrumbs,
|
||||||
|
removeMarkdown(metadata.title),
|
||||||
|
removeMarkdown(h3),
|
||||||
|
removeMarkdown(h4),
|
||||||
|
],
|
||||||
|
href: category.href([slug, normalizeSlugify(h3), normalizeSlugify(h4)]),
|
||||||
|
content: plaintext(lines.join('\n').trim()),
|
||||||
|
rank,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} markdown */
|
||||||
|
function plaintext(markdown) {
|
||||||
|
/** @param {unknown} text */
|
||||||
|
const block = (text) => `${text}\n`;
|
||||||
|
|
||||||
|
/** @param {string} text */
|
||||||
|
const inline = (text) => text;
|
||||||
|
|
||||||
|
return transform(markdown, {
|
||||||
|
code: (source) => source.split('// ---cut---\n').pop(),
|
||||||
|
blockquote: block,
|
||||||
|
html: () => '\n',
|
||||||
|
heading: (text) => `${text}\n`,
|
||||||
|
hr: () => '',
|
||||||
|
list: block,
|
||||||
|
listitem: block,
|
||||||
|
checkbox: block,
|
||||||
|
paragraph: (text) => `${text}\n\n`,
|
||||||
|
table: block,
|
||||||
|
tablerow: block,
|
||||||
|
tablecell: (text, opts) => {
|
||||||
|
return text + ' ';
|
||||||
|
},
|
||||||
|
strong: inline,
|
||||||
|
em: inline,
|
||||||
|
codespan: inline,
|
||||||
|
br: () => '',
|
||||||
|
del: inline,
|
||||||
|
link: (href, title, text) => text,
|
||||||
|
image: (href, title, text) => text,
|
||||||
|
text: inline,
|
||||||
|
})
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&#(\d+);/g, (match, code) => {
|
||||||
|
return String.fromCharCode(code);
|
||||||
|
})
|
||||||
|
.trim();
|
||||||
|
}
|
@ -0,0 +1,107 @@
|
|||||||
|
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)),
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
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', []);
|
@ -0,0 +1,13 @@
|
|||||||
|
export interface Block {
|
||||||
|
breadcrumbs: string[];
|
||||||
|
href: string;
|
||||||
|
content: string;
|
||||||
|
rank: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tree {
|
||||||
|
breadcrumbs: string[];
|
||||||
|
href: string;
|
||||||
|
node: Block;
|
||||||
|
children: Tree[];
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
@ -0,0 +1,11 @@
|
|||||||
|
import { content } from '$lib/search/content';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export const prerender = true;
|
||||||
|
|
||||||
|
/** @type {import('./$types').RequestHandler} */
|
||||||
|
export function GET() {
|
||||||
|
return json({
|
||||||
|
blocks: content(),
|
||||||
|
});
|
||||||
|
}
|
@ -1,7 +1 @@
|
|||||||
import { base } from '$app/paths';
|
export const prerender = true;
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
/** @type {import('./$types').PageLoad} */
|
|
||||||
export async function load() {
|
|
||||||
throw redirect(307, `${base}/docs/introduction`);
|
|
||||||
}
|
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
<script>
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
/** @type {import('./$types').PageData} */
|
||||||
|
export let data;
|
||||||
|
|
||||||
|
function getURlToRedirectTo() {
|
||||||
|
const section = data.sections.find((val) =>
|
||||||
|
$page.url.hash.replace('#', '').startsWith(val.path.split('/').at(-1))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!section) return '/docs/introduction';
|
||||||
|
|
||||||
|
// Remove the section name from hash, then redirect to that
|
||||||
|
const hash = $page.url.hash.replace(`#${section.path.split('/').at(-1)}-`, '');
|
||||||
|
|
||||||
|
return `${section.path}#${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
goto(getURlToRedirectTo(), { replaceState: true });
|
||||||
|
});
|
||||||
|
</script>
|
@ -0,0 +1 @@
|
|||||||
|
export const prerender = false;
|
@ -0,0 +1,21 @@
|
|||||||
|
import { init, inited, search } from '$lib/search/search.js';
|
||||||
|
|
||||||
|
/** @type {import('./$types').PageServerLoad} */
|
||||||
|
export async function load({ url, fetch }) {
|
||||||
|
if (!inited) {
|
||||||
|
const res = await fetch('/content.json');
|
||||||
|
if (!res.ok) throw new Error("Couldn't fetch content");
|
||||||
|
|
||||||
|
const blocks = (await res.json()).blocks;
|
||||||
|
init(blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = url.searchParams.get('q');
|
||||||
|
|
||||||
|
const results = query ? search(query) : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
results
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,49 @@
|
|||||||
|
<script>
|
||||||
|
import SearchResults from '$lib/search/SearchResults.svelte';
|
||||||
|
|
||||||
|
/** @type {import('./$types').PageData} */
|
||||||
|
export let data;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Search • SvelteKit</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h1>Search</h1>
|
||||||
|
<form>
|
||||||
|
<input name="q" value={data.query} placeholder="Search" spellcheck="false" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<SearchResults results={data.results} query={data.query} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
max-width: 48rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 8rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
--size: 4rem;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--size);
|
||||||
|
margin: 0 0 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
border: 1px solid var(--sk-back-5);
|
||||||
|
border-radius: var(--sk-border-radius);
|
||||||
|
padding-left: var(--size);
|
||||||
|
border-radius: var(--size);
|
||||||
|
background: no-repeat 1rem 50% / 2rem 2rem url(/icons/search.svg);
|
||||||
|
color: var(--sk-text-1);
|
||||||
|
}
|
||||||
|
</style>
|
Before Width: | Height: | Size: 215 B After Width: | Height: | Size: 393 B |
After Width: | Height: | Size: 356 B |
Loading…
Reference in new issue