From e730e6f9be777be8a755bd5a861db2759c1c92ad Mon Sep 17 00:00:00 2001 From: eleith Date: Wed, 4 Nov 2026 07:52:05 -0800 Subject: [PATCH] [web] make the search entry page usable --- .changeset/deferred-search-and-search-page.md | 5 + .../layout/simple/SearchDialog.svelte | 327 +---------------- .../layout/simple/SearchForm.svelte | 331 ++++++++++++++++++ .../layout/simple/SearchForm.test.ts | 136 +++++++ apps/web/src/routes/search/+page.server.ts | 4 + apps/web/src/routes/search/+page.svelte | 152 ++++---- .../web/src/routes/search/page.server.test.ts | 29 ++ apps/web/test/templates/routes.test.ts | 96 ++++- 8 files changed, 705 insertions(+), 375 deletions(-) create mode 100644 .changeset/deferred-search-and-search-page.md create mode 100644 apps/web/src/lib/components/layout/simple/SearchForm.svelte create mode 100644 apps/web/src/lib/components/layout/simple/SearchForm.test.ts create mode 100644 apps/web/src/routes/search/page.server.test.ts diff --git a/.changeset/deferred-search-and-search-page.md b/.changeset/deferred-search-and-search-page.md new file mode 100644 index 00000000..cb985c8d --- /dev/null +++ b/.changeset/deferred-search-and-search-page.md @@ -0,0 +1,5 @@ +--- +"@luzzle/web": patch +--- + +Show a usable search form on unfiltered `/search` visits, sharing controls with the navbar dialog. Keep filtered pages results-only, preserve dialog drafts between openings, and skip querying and hydrating pieces until filters are provided. diff --git a/apps/web/src/lib/components/layout/simple/SearchDialog.svelte b/apps/web/src/lib/components/layout/simple/SearchDialog.svelte index 38c616a2..9f7072c8 100644 --- a/apps/web/src/lib/components/layout/simple/SearchDialog.svelte +++ b/apps/web/src/lib/components/layout/simple/SearchDialog.svelte @@ -1,62 +1,25 @@ + +
+
+ + + {@render closeButton?.()} +
+ + + + +
+
+ Refine results + +
+ + + + {#snippet child({ props })} + + {/snippet} + + + + {#snippet child({ wrapperProps, props, open })} + {#if open} +
+
+ + + {#snippet children({ selected })} + All Types + {/snippet} + + {#each pieceTypes as pt (pt)} + + {#snippet children({ selected })} + {pt} + {/snippet} + + {/each} + +
+
+ {/if} + {/snippet} +
+
+
+
+ +
+ + +
+
+ + {#if navigationError} +

Search navigation did not complete. Your filters are still here.

+ {/if} + +
+ {#if query || type || after || before} + + {/if} + +
+
+
+ + diff --git a/apps/web/src/lib/components/layout/simple/SearchForm.test.ts b/apps/web/src/lib/components/layout/simple/SearchForm.test.ts new file mode 100644 index 00000000..ae42e51b --- /dev/null +++ b/apps/web/src/lib/components/layout/simple/SearchForm.test.ts @@ -0,0 +1,136 @@ +import { render } from 'svelte/server' +import { beforeEach, expect, test, vi } from 'vitest' +import SearchForm from './SearchForm.svelte' + +const { page, mount, resolve } = vi.hoisted(() => { + const mount = { prefix: '' } + return { + mount, + page: { + url: new URL('https://garden.test/search'), + data: {} as Partial + }, + resolve: vi.fn((path: string) => `${mount.prefix}/${path}`) + } +}) +vi.mock('$app/state', () => ({ page })) +vi.mock('$app/paths', () => ({ resolve })) +vi.mock('$app/navigation', () => ({ afterNavigate: vi.fn(), goto: vi.fn() })) + +function decode(value: string) { + return value + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') +} + +function attribute(tag: string, name: string) { + const value = tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1] + return value === undefined ? undefined : decode(value) +} + +function inputs(html: string) { + return html.match(/"']|"[^"]*"|'[^']*')*>/g) ?? [] +} + +function field(html: string, name: string) { + const matches = inputs(html).filter((tag) => attribute(tag, 'name') === name) + expect(matches).toHaveLength(1) + return matches[0] +} + +beforeEach(() => { + page.url = new URL('https://garden.test/search') + page.data = { pieceTypes: ['books', 'notes'] } + mount.prefix = '' + vi.clearAllMocks() +}) + +test('renders a real GET form, accessible query, and one named field per filter before JS', () => { + const html = render(SearchForm).body + const form = html.match(/]*>/)?.[0] ?? '' + expect(attribute(form, 'method')).toBe('GET') + expect(attribute(form, 'action')).toBe('/search') + expect(attribute(field(html, 'query'), 'type')).toBe('search') + expect(attribute(field(html, 'query'), 'aria-label')).toBe('Search query') + for (const name of ['query', 'type', 'after', 'before']) { + expect(attribute(field(html, name), 'value')).toBe('') + } + expect(html).toMatch(/]*type="submit"/) + expect(html).not.toContain('Clear Filters') + expect(html).not.toMatch(/\sautofocus(?:\s|=|>)/) + expect(html).not.toContain('Close search') + expect(html).not.toContain('has-close-button') + expect(html).not.toContain('role="alert"') +}) + +test('filter props, including special characters, are in the actual SSR form fields', () => { + const filters = { + query: 'C++ & "tea" #?% / 雪', + type: 'books & "notes"', + after: '2025-01-02', + before: '2026-03-04' + } + page.url.search = '?query=not-the-prop&type=unrelated' + page.data.pieceTypes = [filters.type] + const html = render(SearchForm, { props: filters }).body + for (const [name, value] of Object.entries(filters)) { + expect(attribute(field(html, name), 'value')).toBe(value) + } + expect(html).toContain('Clear Filters') + // The real BitsUI Select owns its single named input; no parallel type input. + expect(attribute(field(html, 'type'), 'aria-hidden')).toBe('true') + for (const name of ['after', 'before']) { + expect(attribute(field(html, name), 'type')).toBe('hidden') + } +}) + +test('native GET targets the resolved search path without carrying pagination or unrelated params', () => { + mount.prefix = '/garden' + page.url = new URL('https://garden.test/garden/search?query=old&page=9&unrelated=old') + const html = render(SearchForm, { props: { query: 'tea' } }).body + const form = html.match(/<form\b[^>]*>/)?.[0] ?? '' + expect(attribute(form, 'action')).toBe('/garden/search') + expect(resolve).toHaveBeenCalledWith('search') + expect(attribute(field(html, 'query'), 'value')).toBe('tea') + const names = inputs(html) + .map((tag) => attribute(tag, 'name')) + .filter(Boolean) + expect(names.sort()).toEqual(['after', 'before', 'query', 'type']) +}) + +test('omitted filter props remain empty regardless of the current URL', () => { + page.url.search = '?query=old&type=books&after=2025-01-02&before=2026-03-04' + const html = render(SearchForm).body + for (const name of ['query', 'type', 'after', 'before']) { + expect(attribute(field(html, name), 'value')).toBe('') + } + expect(html).not.toContain('Clear Filters') +}) + +test('query and type IDs are scoped and the type label targets the real BitsUI trigger', () => { + // Separate SSR roots use explicit prefixes, as required by Svelte. Within each + // root, $props.id() supplies component-local IDs rather than fixed document IDs. + const scopes = ['search-page', 'search-dialog'].map((idPrefix) => { + const html = render(SearchForm, { idPrefix }).body + const queryId = attribute(field(html, 'query'), 'id') + const label = html.match(/<label\b[^>]*class="filter-label[^>]*>/)?.[0] ?? '' + const typeId = attribute(label, 'for') + expect(queryId).toContain(idPrefix) + expect(typeId).toContain(idPrefix) + expect(typeId).not.toBe(queryId) + const trigger = (html.match(/<button\b[^>]*>/g) ?? []).find( + (tag) => attribute(tag, 'id') === typeId + ) + expect(trigger).toBeDefined() + const ids = [...html.matchAll(/\sid="([^"]*)"/g)].map((match) => match[1]) + expect(new Set(ids).size).toBe(ids.length) + return ids + }) + expect(scopes[0].some((id) => scopes[1].includes(id))).toBe(false) +}) + +// SSR assertions cover initial HTML/native field structure only, not enhanced +// navigation, clear-button events, date-picker interaction, or dialog focus. diff --git a/apps/web/src/routes/search/+page.server.ts b/apps/web/src/routes/search/+page.server.ts index 8533f0cf..c16c4937 100644 --- a/apps/web/src/routes/search/+page.server.ts +++ b/apps/web/src/routes/search/+page.server.ts @@ -17,6 +17,10 @@ export const load: PageServerLoad = async ({ url }) => { redirect(302, url.pathname) } + if (!query && !type && !after && !before) { + return { pieces: [], query, type, after, before, nextPage: null } + } + let piecesQuery = db.selectFrom('web_pieces_fts5').selectAll() if (query) { diff --git a/apps/web/src/routes/search/+page.svelte b/apps/web/src/routes/search/+page.svelte index 66549a72..6881cae6 100644 --- a/apps/web/src/routes/search/+page.svelte +++ b/apps/web/src/routes/search/+page.svelte @@ -2,9 +2,15 @@ import { afterNavigate } from '$app/navigation' import { resolve } from '$app/paths' import PieceIcon from '#lib/pieces/components/icon.svelte' + import SearchForm from '#lib/components/layout/simple/SearchForm.svelte' + import type { PageProps } from './$types' let activePieceId = $state<string | null>(null) - let { data } = $props() + let { data }: PageProps = $props() + let query = $derived(data.query) + let type = $derived(data.type) + let after = $derived(data.after) + let before = $derived(data.before) let hasSearchTerms = $derived(!!(data.query || data.type || data.after || data.before)) afterNavigate(({ type, shallow }) => { @@ -16,19 +22,20 @@ }) </script> -<section> - <div class="container"> - <div class="search-header"> - <h1> - {#if !hasSearchTerms} - All pieces - {:else if data.pieces.length === 0} - No results found - {:else} - Search results - {/if} - </h1> - {#if hasSearchTerms} +{#if !hasSearchTerms} + <section class="search-panel" aria-labelledby="search-title"> + <h1 id="search-title">Search</h1> + <div class="search-form-frame"> + <SearchForm bind:query bind:type bind:after bind:before /> + </div> + </section> +{:else} + <section aria-labelledby="search-results-title"> + <div class="container"> + <div class="search-header"> + <h1 id="search-results-title"> + {data.pieces.length === 0 ? 'No results found' : 'Search results'} + </h1> <div class="search-summary"> {#if data.query} for <span class="summary-highlight">"{data.query}"</span> @@ -48,65 +55,82 @@ {/if} {/if} </div> - {/if} - </div> + </div> - {#if data.pieces.length > 0} - {#each data.pieces as piece (piece.id)} - <a - href={resolve(`pieces/${piece.type}/${piece.slug}`)} - onpointerenter={() => { - activePieceId = piece.id - }} - onpointerleave={() => { - activePieceId = null - }} - onfocus={(e) => { - if (e.currentTarget.matches(':focus-visible')) { + {#if data.pieces.length > 0} + {#each data.pieces as piece (piece.id)} + <a + href={resolve(`pieces/${piece.type}/${piece.slug}`)} + onpointerenter={() => { activePieceId = piece.id - } - }} - onblur={() => { - activePieceId = null - }} - > - <div class="piece-card"> - <div class="piece-icon"> - <div style="display: flex;"> - <div style="align-self: baseline;"> - <PieceIcon {piece} size={{ width: 125 }} active={activePieceId === piece.id} /> + }} + onpointerleave={() => { + activePieceId = null + }} + onfocus={(e) => { + if (e.currentTarget.matches(':focus-visible')) { + activePieceId = piece.id + } + }} + onblur={() => { + activePieceId = null + }} + > + <div class="piece-card"> + <div class="piece-icon"> + <div style="display: flex;"> + <div style="align-self: baseline;"> + <PieceIcon {piece} size={{ width: 125 }} active={activePieceId === piece.id} /> + </div> </div> </div> + <div class="piece-text"> + {piece.title} + </div> </div> - <div class="piece-text"> - {piece.title} - </div> - </div> - </a> - {/each} - {/if} - </div> -</section> - -{#if data.nextPage} - <section class="action"> - <!-- eslint-disable svelte/no-navigation-without-resolve -- Query links retain the browser-resolved pathname. --> - <a - href="?{new URLSearchParams({ - ...(data.query && { query: data.query }), - ...(data.type && { type: data.type }), - ...(data.after && { after: data.after }), - ...(data.before && { before: data.before }), - page: data.nextPage.toString() - }).toString()}" - > - more - </a> - <!-- eslint-enable svelte/no-navigation-without-resolve --> + </a> + {/each} + {/if} + </div> </section> + + {#if data.nextPage} + <section class="action"> + <!-- eslint-disable svelte/no-navigation-without-resolve -- Query links retain the browser-resolved pathname. --> + <a + href="?{new URLSearchParams({ + ...(data.query && { query: data.query }), + ...(data.type && { type: data.type }), + ...(data.after && { after: data.after }), + ...(data.before && { before: data.before }), + page: data.nextPage.toString() + }).toString()}" + > + more + </a> + <!-- eslint-enable svelte/no-navigation-without-resolve --> + </section> + {/if} {/if} <style> + .search-panel { + max-width: 800px; + margin: 0 auto var(--space-5); + padding: var(--space-5); + } + + .search-panel h1 { + margin-bottom: var(--space-3); + font-size: var(--font-size-xl); + } + + .search-form-frame { + background: var(--color-surface-container-high); + border: 2px solid var(--color-outline); + border-radius: var(--radius-medium); + } + .search-header { grid-column: 1 / -1; padding-top: var(--space-1); diff --git a/apps/web/src/routes/search/page.server.test.ts b/apps/web/src/routes/search/page.server.test.ts new file mode 100644 index 00000000..7c9d88a0 --- /dev/null +++ b/apps/web/src/routes/search/page.server.test.ts @@ -0,0 +1,29 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import { db } from '#lib/server/database/index.js' +import { hydrateWithAssets } from '#lib/pieces/assets.server.js' +import { load } from './+page.server.js' + +vi.mock('#lib/server/database/index.js', () => ({ + db: { selectFrom: vi.fn() }, + sql: vi.fn() +})) +vi.mock('#lib/pieces/assets.server.js', () => ({ hydrateWithAssets: vi.fn() })) + +beforeEach(() => vi.clearAllMocks()) + +test.each(['/search', '/search?query=&type=&after=&before=&page=2'])( + '%s shows the entry form without querying or hydrating pieces', + async (path) => { + const event = { url: new URL(path, 'https://garden.test') } as Parameters<typeof load>[0] + expect(await load(event)).toEqual({ + pieces: [], + query: '', + type: '', + after: '', + before: '', + nextPage: null + }) + expect(db.selectFrom).not.toHaveBeenCalled() + expect(hydrateWithAssets).not.toHaveBeenCalled() + } +) diff --git a/apps/web/test/templates/routes.test.ts b/apps/web/test/templates/routes.test.ts index 99070275..9ac8fabb 100644 --- a/apps/web/test/templates/routes.test.ts +++ b/apps/web/test/templates/routes.test.ts @@ -86,7 +86,7 @@ try { const row = { id: 'cutover-id', key: 'cutover-key', slug: 'real-piece', type: 'cutover-kind', title: 'Real Piece', file_path: 'real-piece.cutover-kind.md', date_added: 1700000000000, - note: 'Raw indexed note', summary: 'Published route description', + date_consumed: 1700000000000, note: 'Raw indexed note', summary: 'Published route description', json_metadata: JSON.stringify({ title: 'Real Piece', cover: 'cover-key', subtitle: 'Published metadata', artwork: 'cover-key', released: '2001-09-09', minutes: 123, genre: 'Native catalog', filmmaker: 'Ada Example', website: 'https://example.com/real-piece', isbn: '9780000000001', creators: [{ name: 'Ada Example' }] }) @@ -696,6 +696,100 @@ describe('stock pieces and configured site sections through production HTTP', () expect(html).toContain('pieceTypes:["cutover-kind"]') }) + test('keeps a native search link on the initial public piece', async () => { + const html = await pageHtml() + const searchLinks = html.match(/<a\b(?=[^>]*\baria-label="search")[^>]*>[\s\S]*?<\/a>/g) ?? [] + expect(searchLinks).toHaveLength(1) + expect(linkTargets(searchLinks[0]!, piecePath)).toEqual([new URL('/search', origin).href]) + expect(searchLinks[0]).not.toMatch(/\b(?:role="button"|onclick=)/) + }) + + test('shows the native search form only without filters and keeps filtered pages results-only', async () => { + async function searchHtml(pathname: string) { + // Ignore Svelte's SSR branch markers when asserting visible heading text. + return (await pageHtml(pathname)).replace(/<!--[\s\S]*?-->/g, '') + } + function searchForm(html: string, pathname: string) { + expect(html.match(/<main\b/g)).toHaveLength(1) + expect(html.match(/<h1\b[^>]*>Search<\/h1>/g)).toHaveLength(1) + const forms = [...html.matchAll(/<form\b([^>]*)>([\s\S]*?)<\/form>/g)] + expect(forms).toHaveLength(1) + const [, attributes, body] = forms[0] + expect(attributes).toMatch(/\bmethod="get"/i) + const action = attributes.match(/\baction="([^"]+)"/)?.[1] + expect(action).toBeDefined() + const url = new URL(action!.replaceAll('&', '&'), new URL(pathname, origin)) + expect(url.href).toBe(new URL('/search', origin).href) + const controls = [...body.matchAll(/<input\b[^>]*\bname="([^"]+)"[^>]*>/g)] + expect(controls.map(([, name]) => name).sort()).toEqual(['after', 'before', 'query', 'type']) + const params = new URLSearchParams() + for (const [tag, name] of controls) { + expect(tag).not.toMatch(/\sdisabled(?:\s|=|>)/) + const value = tag.match(/\bvalue="([^"]*)"/)?.[1] + expect(value, name).toBeDefined() + params.set(name, value!.replaceAll('&', '&')) + if (name === 'after' || name === 'before') expect(tag).toContain('type="hidden"') + } + expect(body).toMatch(/<input\b(?=[^>]*\bname="query")(?=[^>]*\btype="search")[^>]*>/) + expect(body).toMatch(/<button\b[^>]*\btype="submit"/) + expect(html.indexOf('<h1')).toBeLessThan(html.indexOf('<form')) + expect(html).not.toContain('id="search-results-title"') + expect(html).not.toContain('Real Piece') + expect(linkTargets(html, pathname)).not.toContain(new URL(piecePath, origin).href) + return { url, params } + } + + function expectResultsOnly(html: string, pathname: string) { + expect(html.match(/<main\b/g)).toHaveLength(1) + expect(html.match(/<h1\b/g)).toHaveLength(1) + expect(html).not.toMatch(/<form\b/) + expect(html).not.toContain('class="search-panel') + expect(linkTargets(html, pathname)).toContain(new URL('/search', origin).href) + } + + const empty = await searchHtml('/search') + const { url, params } = searchForm(empty, '/search') + expect(Object.fromEntries(params)).toEqual({ + query: '', + type: '', + after: '', + before: '' + }) + expect(await frameworkCss(empty)).toContain('.search-input-wrapper') + searchForm(await searchHtml('/search?query=&page=1'), '/search?query=&page=1') + + const filters = { + query: 'native-missing-query', + type: 'cutover-kind', + after: '2020-01-02', + before: '2030-03-04' + } + const pathname = `/search?${new URLSearchParams(filters)}&page=1&unrelated=old` + const filtered = await searchHtml(pathname) + expectResultsOnly(filtered, pathname) + expect(filtered).toMatch(/<h1\b[^>]*>\s*No results found\s*<\/h1>/) + expect(filtered).toContain(filters.query) + expect(linkTargets(filtered, pathname)).not.toContain(new URL(piecePath, origin).href) + for (const filter of ['type=cutover-kind', 'after=2020-01-02', 'before=2030-03-04']) { + const filteredPath = `/search?${filter}` + const filteredHtml = await searchHtml(filteredPath) + expectResultsOnly(filteredHtml, filteredPath) + expect(linkTargets(filteredHtml, filteredPath)).toContain(new URL(piecePath, origin).href) + } + + // Construct the GET a native form would send after editing its controls; + // this does not execute browser submission or the enhanced goto handler. + params.set('query', 'Real Piece') + params.set('after', '') + params.set('before', '') + expect(params.has('page')).toBe(false) + url.search = params.toString() + const results = await searchHtml(`${url.pathname}${url.search}`) + expectResultsOnly(results, `${url.pathname}${url.search}`) + expect(results).toMatch(/<h1\b[^>]*>\s*Search results\s*<\/h1>/) + expect(linkTargets(results, url.pathname)).toContain(new URL(piecePath, origin).href) + }) + test('serves the bundled editor schema publicly without exposing instance configuration', async () => { const original = await readFile(configFilename) const editorSchema = JSON.parse( -- 2.54.0