diff --git a/src/components/SearchForm.astro b/src/components/SearchForm.astro new file mode 100644 index 0000000..e29410d --- /dev/null +++ b/src/components/SearchForm.astro @@ -0,0 +1,43 @@ +--- +/** The form of the search, its status and its results, which the search page and the dialog of the bar share. */ +import { TEXT } from '../content/site' +import Icon from './Icon.astro' + +interface Props { + /** The id of the field, unique on the page. */ + id: string + /** Whether the field takes the focus when the form appears. */ + autofocus?: boolean +} +const { id, autofocus = false } = Astro.props +const labels = TEXT.search +--- + + +
    diff --git a/src/components/SiteBar.astro b/src/components/SiteBar.astro index c71ceef..16d51e7 100644 --- a/src/components/SiteBar.astro +++ b/src/components/SiteBar.astro @@ -1,11 +1,13 @@ --- /** - * The bar on top of every page: the brand that leads home, the way to search, the organization on GitHub and - * on Packagist, and the theme control. + * The bar on top of every page: the brand that leads home, the search, which opens as a dialog over the page + * and is a link to the search page where no script runs, the organization on GitHub and on Packagist, and + * the theme control. */ import { SITE, TEXT } from '../content/site' import BrandMark from './BrandMark.astro' import Icon from './Icon.astro' +import SearchForm from './SearchForm.astro' import ThemeToggle from './ThemeToggle.astro' --- @@ -16,8 +18,17 @@ import ThemeToggle from './ThemeToggle.astro'
    - + + +
    + + +
    +

    {TEXT.search.title}

    +
    + +
    +
    + +
    diff --git a/src/content/site.ts b/src/content/site.ts index 8ef8f8a..f9c3ecc 100644 --- a/src/content/site.ts +++ b/src/content/site.ts @@ -74,6 +74,9 @@ export const TEXT = { lede: 'Look up a class, a method, a pattern or a problem to solve.', label: 'Search term', placeholder: 'E.g. Collection, Instant, correlation id', + close: 'Close the search', + /** The keys that open the search from any page, shown beside the control of the bar. */ + shortcut: 'Ctrl K', idle: 'Type at least two letters.', loading: 'Searching...', empty: 'Nothing found. Try another word.', diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 7ff94f9..0267dc4 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -96,6 +96,7 @@ const jsonLd = structuredData diff --git a/src/pages/search.astro b/src/pages/search.astro index 61fe550..a390ec3 100644 --- a/src/pages/search.astro +++ b/src/pages/search.astro @@ -1,12 +1,13 @@ --- /** - * The search over the documentation of the libraries. + * The search over the documentation of the libraries, for a reader with no script to open the dialog of + * the bar and for a search shared by its address. * * The index is built by Pagefind after the site, from the library pages only. The page ships the form and * an empty list, and the script fetches the index the first time the reader types, so no other page pays * for it. */ -import Icon from '../components/Icon.astro' +import SearchForm from '../components/SearchForm.astro' import { MAIN_ID, TEXT } from '../content/site' import BaseLayout from '../layouts/BaseLayout.astro' @@ -28,36 +29,8 @@ const labels = TEXT.search
    - -
      +
      - diff --git a/src/scripts/search-dialog.ts b/src/scripts/search-dialog.ts new file mode 100644 index 0000000..1c41343 --- /dev/null +++ b/src/scripts/search-dialog.ts @@ -0,0 +1,30 @@ +/** + * Progressive enhancement for the search. The link of the bar to the search page opens the search as a + * dialog over the page instead, and so does Ctrl K or ⌘ K. A press outside the dialog closes it, as its + * `closedby` asks. The search itself, and the index behind it, load the first time they are needed, so a + * page that is never searched pays only for this. On the search page the form of the page is bound too, + * following the term in the address. + */ +const control = document.querySelector('[data-search-open]') +const dialog = document.querySelector('dialog[data-search-dialog]') +const page = document.querySelector('main [data-search]') + +if (page) { + void import('./search.ts').then((search) => search.bind(page.parentElement ?? document, true)) +} + +if (control && dialog) { + const open = (event: Event): void => { + event.preventDefault() + dialog.showModal() + void import('./search.ts').then((search) => search.bind(dialog, false)) + } + control.addEventListener('click', open) + document.addEventListener('keydown', (event) => { + if (event.key === 'k' && (event.ctrlKey || event.metaKey) && !dialog.open) { + open(event) + } + }) +} + +export {} diff --git a/src/scripts/search.ts b/src/scripts/search.ts index 657133d..daaf4ee 100644 --- a/src/scripts/search.ts +++ b/src/scripts/search.ts @@ -1,10 +1,12 @@ /** - * The search box of the notes. It loads the Pagefind index on the first search, so the page itself + * The search over the documentation of the libraries, bound to a form wherever it sits: the search page, + * or the dialog the bar opens on every page. It loads the Pagefind index on the first search, so a page * ships only the form, and it writes the results as plain links to the section that matched. * - * Pressing enter submits the form the native way, to this page with the term in the address, which is - * also what makes a search shareable. The index exists only in the site the build produces. Under the development server the import fails, - * and the box says so instead of failing silently. + * On the search page the term follows into the address, which is what makes a search shareable, and + * pressing enter submits the form the native way. In the dialog the results come as the reader types. The + * index exists only in the site the build produces. Under the development server the import fails, and + * the box says so instead of failing silently. */ interface PagefindResult { readonly url: string @@ -47,21 +49,25 @@ function resultItem(result: PagefindResult): string { return `
    1. ${result.meta.title ?? ''}
    2. ` } -function init(): void { - const form = document.querySelector('[data-search]') +/** Binds the search to the form inside a root, once, writing the term to the address when asked to. */ +export function bind(root: ParentNode, followAddress: boolean): void { + const form = root.querySelector('[data-search]') const input = form?.querySelector('input') - const status = document.querySelector('[data-search-status]') - const results = document.querySelector('[data-search-results]') - if (!form || !input || !status || !results) { + const status = root.querySelector('[data-search-status]') + const results = root.querySelector('[data-search-results]') + if (!form || !input || !status || !results || form.dataset.bound) { return } + form.dataset.bound = 'on' const say = (key: string, count = 0): void => { status.textContent = (form.dataset[key] ?? '').replace('{count}', String(count)) } const run = async (): Promise => { const term = input.value.trim() - history.replaceState(null, '', term === '' ? '?' : `?q=${encodeURIComponent(term)}`) + if (followAddress) { + history.replaceState(null, '', term === '' ? '?' : `?q=${encodeURIComponent(term)}`) + } if (term.length < 2) { results.innerHTML = '' say('idle') @@ -81,12 +87,12 @@ function init(): void { } input.addEventListener('input', () => void run()) + if (!followAddress) { + form.addEventListener('submit', (event) => event.preventDefault()) + return + } input.value = new URLSearchParams(window.location.search).get('q') ?? '' if (input.value !== '') { void run() } } - -init() - -export {} diff --git a/src/styles/theme.css b/src/styles/theme.css index abf2453..2361ec7 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -567,6 +567,67 @@ code { background: var(--color-surface-strong); color: var(--color-ink); } +.tb-bar__tool--search span, +.tb-bar__tool--search kbd { + display: none; +} +@media (min-width: 64rem) { + .tb-bar__tool--search { + width: auto; + gap: 0.5rem; + display: inline-flex; + align-items: center; + padding-inline: 0.875rem 1rem; + border: 1px solid var(--color-hair); + font-size: 0.875rem; + font-weight: var(--font-weight-demibold); + } + .tb-bar__tool--search span { + display: inline; + } + .tb-bar__tool--search kbd { + display: inline; + padding: 0.0625rem 0.375rem; + border-radius: var(--radius-sm); + border: 1px solid var(--color-hair); + font-family: var(--font-mono); + font-size: 0.6875rem; + color: var(--color-ink-muted); + } +} + +/* Search dialog: the search over the page the bar opens, with Ctrl K or the control of the bar. */ +.tb-dialog { + width: min(44rem, calc(100% - 2rem)); + max-height: min(40rem, calc(100% - 4rem)); + margin: 4rem auto auto; + padding: 1rem 1.25rem 1.25rem; + overflow-y: auto; + border-radius: var(--radius-lg); + border: 1px solid var(--color-hair); + background: var(--color-page); + color: var(--color-ink); + box-shadow: var(--shadow-raised); +} +.tb-dialog::backdrop { + background: rgb(0 0 0 / 0.5); + backdrop-filter: blur(2px); +} +.tb-dialog__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-block-end: 0.5rem; +} +.tb-dialog__title { + font-family: var(--font-display); + font-weight: var(--font-weight-bold); + font-size: 1.125rem; +} +.tb-dialog .tb-results { + margin-block-start: 1rem; +} /* ------------------------------------ search ------------------------------------ */ .tb-search {