[web] make the search entry page usable #191

Merged
eleith merged 1 commits from fix/deferred-search-and-search-page into main 2026-11-04 15:58:57 +00:00
8 changed files with 705 additions and 375 deletions

No files matched your search

@@ -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.
@@ -1,62 +1,25 @@
<script lang="ts">
import SearchIcon from 'virtual:icons/ph/magnifying-glass'
import CloseIcon from 'virtual:icons/ph/x'
import CaretUpDownIcon from 'virtual:icons/ph/caret-up-down'
import { fade } from 'svelte/transition'
import { Dialog, Select } from 'bits-ui'
import DatePicker from '#lib/components/ui/DatePicker.svelte'
import { page } from '$app/state'
import { afterNavigate, goto } from '$app/navigation'
import { Dialog } from 'bits-ui'
import SearchForm from './SearchForm.svelte'
import { afterNavigate } from '$app/navigation'
import { resolve } from '$app/paths'
import { page } from '$app/state'
const pieceTypes = $derived(page.data.pieceTypes ?? [])
let query = $state('')
let type = $state('')
let after = $state('')
let before = $state('')
let query = $derived(page.url.searchParams.get('query') || '')
let type = $derived(page.url.searchParams.get('type') || '')
let after = $derived(page.url.searchParams.get('after') || '')
let before = $derived(page.url.searchParams.get('before') || '')
let isOpen = $state(false)
let navigationError = $state(false)
$effect(() => {
query = page.url.searchParams.get('query') || ''
type = page.url.searchParams.get('type') || ''
after = page.url.searchParams.get('after') || ''
before = page.url.searchParams.get('before') || ''
})
function clearFilters() {
query = ''
type = ''
after = ''
before = ''
}
afterNavigate(({ shallow, type }) => {
if (shallow && type === 'goto') return
isOpen = false
navigationError = false
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
navigationError = false
const filters: Record<string, string> = {}
if (query) filters.query = query
if (type) filters.type = type
if (after) filters.after = after
if (before) filters.before = before
const searchString = new URLSearchParams(filters).toString()
try {
const destination = searchString ? resolve(`search?${searchString}`) : resolve('search')
await goto(destination)
} catch {
navigationError = true
}
}
function responsiveFly(node: HTMLElement, { duration = 300 }) {
return {
duration,
@@ -102,19 +65,13 @@
{#snippet child({ props, open })}
{#if open}
<div class="search-modal" {...props} transition:responsiveFly={{ duration: 300 }}>
<form method="GET" action="/search" onsubmit={handleSubmit} class="search-form">
<!-- svelte-ignore a11y_autofocus -->
<div class="search-input-wrapper">
<SearchIcon class="search-input-icon" />
<input
type="search"
placeholder="Search by keyword, title, metadata..."
name="query"
bind:value={query}
class="search-input"
autocomplete="off"
autofocus
/>
<Dialog.Title class="visually-hidden">Search</Dialog.Title>
<Dialog.Description class="visually-hidden">
Find pieces by keyword, type or date.
</Dialog.Description>
<!-- BitsUI focuses the first tabbable control: the query input. -->
<SearchForm bind:query bind:type bind:after bind:before>
{#snippet closeButton()}
<Dialog.Close>
{#snippet child({ props })}
<button {...props} type="button" class="close-btn" aria-label="Close search">
@@ -122,88 +79,8 @@
</button>
{/snippet}
</Dialog.Close>
</div>
<input type="hidden" name="after" value={after} />
<input type="hidden" name="before" value={before} />
<div class="modal-body">
<div class="filters-container">
<span class="filters-header">Refine results</span>
<div class="filter-field">
<label for="type-select-trigger" class="filter-label">Piece Type</label>
<Select.Root type="single" name="type" bind:value={type}>
<Select.Trigger>
{#snippet child({ props })}
<button {...props} id="type-select-trigger" class="select-trigger">
<span>{type || 'All Types'}</span>
<CaretUpDownIcon
style="width: 1em; height: 1em; color: var(--color-on-surface-variant);"
/>
</button>
{/snippet}
</Select.Trigger>
<Select.Portal>
<Select.Content sideOffset={4}>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} class="select-content">
<Select.Viewport class="select-viewport">
<Select.Item value="" class="select-item">
{#snippet children({ selected })}
<span class="select-item-text" class:selected
>All Types</span
>
{/snippet}
</Select.Item>
{#each pieceTypes as pt (pt)}
<Select.Item value={pt} class="select-item">
{#snippet children({ selected })}
<span class="select-item-text" class:selected>{pt}</span>
{/snippet}
</Select.Item>
{/each}
</Select.Viewport>
</div>
</div>
{/if}
{/snippet}
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
<div class="date-filters-group">
<DatePicker label="After" bind:value={after} />
<DatePicker label="Before" bind:value={before} />
</div>
</div>
{#if navigationError}
<p role="alert">
Search navigation did not complete. Your filters are still here.
</p>
{/if}
<div class="actions-container">
{#if query || type || after || before}
<button
type="button"
onclick={clearFilters}
class="button clear-btn"
data-variant="link"
>
Clear Filters
</button>
{/if}
<button type="submit" class="button submit-btn" data-variant="primary">
Search
</button>
</div>
</div>
</form>
{/snippet}
</SearchForm>
</div>
{/if}
{/snippet}
@@ -249,10 +126,6 @@
.close-btn {
display: none;
}
.search-input {
padding-right: calc(var(--space-3) + 12px);
}
}
@media (min-width: 768px) {
@@ -270,33 +143,6 @@
}
}
.search-form {
display: flex;
flex-direction: column;
gap: 0;
border-radius: inherit;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
border-bottom: 2px solid var(--color-outline);
background: var(--color-surface-container-highest);
height: 60px;
width: 100%;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.search-input-wrapper :global(.search-input-icon) {
position: absolute;
left: var(--space-3);
color: var(--color-on-surface-variant);
font-size: 1.4em;
pointer-events: none;
}
.close-btn {
position: absolute;
right: var(--space-3);
@@ -326,143 +172,4 @@
background-color: var(--color-surface-container-low);
color: var(--color-on-surface);
}
.search-input {
width: 100%;
height: 100%;
border: none;
background: transparent;
color: var(--color-on-surface);
font-size: 18px;
padding: 0 calc(var(--space-3) + 52px) 0 calc(var(--space-3) + 2.5em);
outline: none;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.search-input::placeholder {
color: var(--color-on-surface-variant);
opacity: 0.6;
}
.modal-body {
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.filters-container {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.filters-header {
font-size: var(--font-size-xs);
color: var(--color-on-surface-variant);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-1);
}
.filter-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.filter-label {
font-size: var(--font-size-xs);
color: var(--color-on-surface-variant);
text-transform: uppercase;
font-weight: var(--font-weight-medium);
}
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
border: 3px solid var(--color-surface-inverse);
background: var(--color-surface-container-highest);
height: 50px;
color: var(--color-on-surface);
font-size: 16px;
padding: 0 12px;
outline: none;
cursor: pointer;
width: 100%;
}
.select-trigger:focus {
background: var(--color-surface-inverse);
color: var(--color-on-surface-inverse);
}
.select-content {
background-color: var(--color-surface-container-highest);
border: 1px solid var(--color-outline);
border-radius: var(--radius-small);
width: var(--bits-select-anchor-width);
box-sizing: border-box;
box-shadow: var(--shadow-raised);
z-index: 2001;
padding: var(--space-1);
}
.select-content :global(.select-viewport) {
max-height: 250px;
overflow-y: auto;
}
.select-content :global(.select-item) {
display: flex;
align-items: center;
padding: var(--space-3);
cursor: pointer;
color: var(--color-on-surface);
border-radius: var(--radius-small);
width: 100%;
text-align: left;
user-select: none;
outline: none;
}
.select-content :global(.select-item:hover),
.select-content :global(.select-item[data-highlighted]) {
background-color: var(--color-surface-container-low);
}
.select-content :global(.select-item-text.selected) {
font-weight: var(--font-weight-bold);
color: var(--color-primary);
}
.date-filters-group {
display: flex;
gap: var(--space-4);
}
@media (max-width: 639px) {
.date-filters-group {
flex-direction: column;
}
}
.actions-container {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-2);
}
.clear-btn {
font-size: 14px;
}
.submit-btn {
min-width: 100px;
}
</style>
@@ -0,0 +1,331 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import SearchIcon from 'virtual:icons/ph/magnifying-glass'
import CaretUpDownIcon from 'virtual:icons/ph/caret-up-down'
import { Select } from 'bits-ui'
import DatePicker from '#lib/components/ui/DatePicker.svelte'
import { page } from '$app/state'
import { afterNavigate, goto } from '$app/navigation'
import { resolve } from '$app/paths'
type Props = {
query?: string
type?: string
after?: string
before?: string
closeButton?: Snippet
}
let {
query = $bindable(''),
type = $bindable(''),
after = $bindable(''),
before = $bindable(''),
closeButton
}: Props = $props()
const id = $props.id()
const pieceTypes = $derived(page.data.pieceTypes ?? [])
let navigationError = $state(false)
function clearFilters() {
query = ''
type = ''
after = ''
before = ''
}
afterNavigate(({ shallow, type }) => {
if (shallow && type === 'goto') return
navigationError = false
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
navigationError = false
const filters: Record<string, string> = {}
if (query) filters.query = query
if (type) filters.type = type
if (after) filters.after = after
if (before) filters.before = before
// Start from the filters, not the current URL, so pagination is cleared.
const searchString = new URLSearchParams(filters).toString()
try {
const destination = searchString ? resolve(`search?${searchString}`) : resolve('search')
await goto(destination)
} catch {
navigationError = true
}
}
</script>
<form method="GET" action={resolve('search')} onsubmit={handleSubmit} class="search-form">
<div class="search-input-wrapper" class:has-close-button={!!closeButton}>
<SearchIcon class="search-input-icon" />
<input
id={`${id}-query`}
aria-label="Search query"
type="search"
placeholder="Search by keyword, title, metadata..."
name="query"
bind:value={query}
class="search-input"
autocomplete="off"
/>
{@render closeButton?.()}
</div>
<input type="hidden" name="after" value={after} />
<input type="hidden" name="before" value={before} />
<div class="form-body">
<div class="filters-container">
<span class="filters-header">Refine results</span>
<div class="filter-field">
<label for={`${id}-type`} class="filter-label">Piece Type</label>
<Select.Root type="single" name="type" bind:value={type}>
<Select.Trigger id={`${id}-type`}>
{#snippet child({ props })}
<button {...props} class="select-trigger">
<span>{type || 'All Types'}</span>
<CaretUpDownIcon
style="width: 1em; height: 1em; color: var(--color-on-surface-variant);"
/>
</button>
{/snippet}
</Select.Trigger>
<Select.Portal>
<Select.Content sideOffset={4}>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...props} class="select-content">
<Select.Viewport class="select-viewport">
<Select.Item value="" class="select-item">
{#snippet children({ selected })}
<span class="select-item-text" class:selected>All Types</span>
{/snippet}
</Select.Item>
{#each pieceTypes as pt (pt)}
<Select.Item value={pt} class="select-item">
{#snippet children({ selected })}
<span class="select-item-text" class:selected>{pt}</span>
{/snippet}
</Select.Item>
{/each}
</Select.Viewport>
</div>
</div>
{/if}
{/snippet}
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
<div class="date-filters-group">
<DatePicker label="After" bind:value={after} />
<DatePicker label="Before" bind:value={before} />
</div>
</div>
{#if navigationError}
<p role="alert">Search navigation did not complete. Your filters are still here.</p>
{/if}
<div class="actions-container">
{#if query || type || after || before}
<button type="button" onclick={clearFilters} class="button clear-btn" data-variant="link">
Clear Filters
</button>
{/if}
<button type="submit" class="button submit-btn" data-variant="primary"> Search </button>
</div>
</div>
</form>
<style>
.search-form {
display: flex;
flex-direction: column;
gap: 0;
border-radius: inherit;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
border-bottom: 2px solid var(--color-outline);
background: var(--color-surface-container-highest);
height: 60px;
width: 100%;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.search-input-wrapper :global(.search-input-icon) {
position: absolute;
left: var(--space-3);
color: var(--color-on-surface-variant);
font-size: 1.4em;
pointer-events: none;
}
.search-input {
width: 100%;
height: 100%;
border: none;
background: transparent;
color: var(--color-on-surface);
font-size: 18px;
padding: 0 calc(var(--space-3) + 12px) 0 calc(var(--space-3) + 2.5em);
outline: none;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
}
.search-input:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -3px;
}
.has-close-button .search-input {
padding-right: calc(var(--space-3) + 52px);
}
.search-input::placeholder {
color: var(--color-on-surface-variant);
opacity: 0.6;
}
.form-body {
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.filters-container {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.filters-header {
font-size: var(--font-size-xs);
color: var(--color-on-surface-variant);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-1);
}
.filter-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.filter-label {
font-size: var(--font-size-xs);
color: var(--color-on-surface-variant);
text-transform: uppercase;
font-weight: var(--font-weight-medium);
}
.select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
border: 3px solid var(--color-surface-inverse);
background: var(--color-surface-container-highest);
height: 50px;
color: var(--color-on-surface);
font-size: 16px;
padding: 0 12px;
outline: none;
cursor: pointer;
width: 100%;
}
.select-trigger:focus {
background: var(--color-surface-inverse);
color: var(--color-on-surface-inverse);
}
.select-content {
background-color: var(--color-surface-container-highest);
border: 1px solid var(--color-outline);
border-radius: var(--radius-small);
width: var(--bits-select-anchor-width);
box-sizing: border-box;
box-shadow: var(--shadow-raised);
z-index: 2001;
padding: var(--space-1);
}
.select-content :global(.select-viewport) {
max-height: 250px;
overflow-y: auto;
}
.select-content :global(.select-item) {
display: flex;
align-items: center;
padding: var(--space-3);
cursor: pointer;
color: var(--color-on-surface);
border-radius: var(--radius-small);
width: 100%;
text-align: left;
user-select: none;
outline: none;
}
.select-content :global(.select-item:hover),
.select-content :global(.select-item[data-highlighted]) {
background-color: var(--color-surface-container-low);
}
.select-content :global(.select-item-text.selected) {
font-weight: var(--font-weight-bold);
color: var(--color-primary);
}
.date-filters-group {
display: flex;
gap: var(--space-4);
}
@media (max-width: 639px) {
.date-filters-group {
flex-direction: column;
}
}
.actions-container {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-2);
}
.clear-btn {
font-size: 14px;
}
.submit-btn {
min-width: 100px;
}
@media (max-width: 767px) {
.has-close-button .search-input {
padding-right: calc(var(--space-3) + 12px);
}
}
</style>
@@ -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<App.PageData>
},
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(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/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(/<input\b(?:[^>"']|"[^"]*"|'[^']*')*>/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(/<form\b[^>]*>/)?.[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(/<button\b[^>]*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" <title> #?% / 雪',
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.
@@ -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) {
+88 -64
View File
@@ -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);
@@ -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()
}
)
+95 -1
View File
@@ -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('&amp;', '&'), 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('&amp;', '&'))
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(