[web] make the search entry page usable #191
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(/"/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(/<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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user