Blocks

Search Results

A four-state search results page — query echo, keyword highlighting, faceted filters whose counts are the real post-click result count, and pagination that snaps back to page 1 on every filter change.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, ListFilter, SearchX } from "lucide-react"
import { cn } from "@/lib/utils"
import { SearchHighlight } from "@/registry/ui/search-highlight"
import type { SearchResultsData, SearchResultsItem } from "./search-results.contract"

const DAY_MS = 86_400_000

/**
 * 时间分面。区间是**嵌套**的(24h ⊂ 一周 ⊂ 一月 ⊂ 一年),对嵌套区间做多选没有意义,
 * 所以这一维是单选;"Any time" 是它的显式取值,而不是「没选」的隐含状态。
 */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/search-results.json

Prompt

Build a React + TypeScript + Tailwind "SearchResults" page block
(lucide-react glyphs, zod, and a SearchHighlight text component that marks
matches structurally).

Contract
- One zod schema is the single source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    query: string;                  // echoed and used as the highlight terms
    queriedAt: ISO datetime;        // the anchor for the recency facet
    items: { id, title, href, snippet, breadcrumb: string[],
             type: string, tags: string[], updatedAt: ISO datetime }[];
    suggestions?: string[] }        // "did you mean" alternates
- `snippet` is PLAIN TEXT. It is rendered as text nodes and highlighted with
  real <mark> elements, never with dangerouslySetInnerHTML, so a snippet
  containing "<img src=x onerror=…>" is shown as characters and cannot become
  a node. Strip server-side <em> markup before it reaches this contract.
- `queriedAt` is data, not Date.now(): the recency buckets must be the same on
  the server, in the client and in tomorrow's screenshot.
- `href` is refined at parse time to reject dead anchors ("#" and friends).
- Three whole-payload refines, each with its own existence guard because zod
  runs every refine even after an earlier one failed: ready implies at least
  one item, empty implies zero items, ids are unique. A refine that
  dereferences items[0] without guarding turns safeParse into a thrown
  TypeError on a ragged payload.
- Component props = z.infer of the schema plus pageSize? (default 5, clamped
  to >= 1), locale? (default "en-US"), onRetry?, onSuggest?, className.
  Facet selection and the current page are internal state — the host ships an
  ordered result set, the block owns the narrowing.

Behavior
- Four first-class branches: loading (sidebar + result skeletons mirroring the
  real anatomy), error (message + Try again only when onRetry exists), empty,
  ready. `ready` with zero items is rendered as the empty state, so a
  contract-breaking payload degrades instead of showing an empty page.
- Facet semantics, stated once and obeyed everywhere: OR inside a dimension,
  AND across dimensions. Type and tags are multi-select checkboxes, recency is
  single-select radios (its ranges are nested, so multi-select is meaningless)
  with "Any time" as an explicit value. Options are DERIVED from the items in
  first-appearance order, so the host orders the data to order the facets.
- HONEST COUNTS — the whole point: the number beside an option is
  |results after that click|, computed by applying the toggle to the current
  facet state and running the same predicate the list runs. Not a static
  number, not a count that ignores the other dimensions. A zero therefore
  means "clicking me leaves nothing", which is worth showing rather than
  hiding: the click is never a dead end because the no-match panel offers the
  way back. Screen readers get the meaning too, with wording that follows the
  control: "Guide, 4 results if selected" / "… if removed" for the checkboxes,
  "Any time, 14 results shown now" for the radio that is already on (clicking
  it changes nothing, so "if removed" would be a lie).
- Every filter change resets to page 1. A filtered set is a different result
  set; keeping page 4 is how users land on a blank page. The page number is
  also clamped during render, so a shrinking result set can never point past
  the end.
- Stale-selection guard: options that no longer exist in a new payload drop
  out of the active selection during render, otherwise the block filters on a
  value the user can neither see nor unselect.
- Highlighting: query terms are split on whitespace and matched
  case-insensitively in the title and the snippet. Overlapping and adjacent
  hits merge into one <mark> (two touching marks are read out twice by a
  screen reader). The query is escaped before it reaches a RegExp, so "a.b"
  cannot match "axb" and a lone "(" cannot throw.
- Two zero states with different copy and different exits:
    * status === "empty" -> "No results for <query>", spelling/synonym advice
      and, when suggestions and onSuggest are both present, "did you mean"
      chips. No Clear-filters button, because no filter is hiding anything.
    * ready + facets narrow to 0 -> "No results match these filters",
      restating the total that is still there, plus Clear all filters.
- Counts are announced, not spammed: one always-mounted role=status region is
  written imperatively 600ms after the last change, so a host that re-renders
  on every keystroke does not narrate every keystroke. Mount itself never
  announces.
- Pagination: results/page slice with a sliding page window (first, last, a
  five-page run around the current page, "…" only when it hides more than one
  page). Prev/Next at the edges use aria-disabled, not the native disabled
  attribute, which would blur the button the moment you reach the last page.
- Nothing is silently capped: every item is reachable through the pages, every
  facet option is listed, long titles and breadcrumbs wrap instead of clipping.
- Time rendering degrades instead of lying: each row shows "Updated 2 days ago"
  measured from queriedAt; if queriedAt is unparsable the recency facet is not
  rendered at all (a control that cannot filter honestly) and rows fall back to
  an absolute UTC date; if the row's own updatedAt is unparsable it says so and
  stays out of every time window instead of pretending to be recent.
- Numeric props are clamped: pageSize 0 / NaN / negative becomes 1, so a bad
  prop cannot produce Infinity pages or an empty list.

Rendering & styling
- Semantic tokens only: bg-card panels, border / border-dashed for the two
  zero states, text-muted-foreground for meta, bg-primary for the current page
  and the active-filter badge, accent-primary on the native checkboxes and
  radios. No hex, no rgb().
- Results are an <ol> (relevance order is meaningful) with start={offset + 1},
  so page 2 counts from 6. Each facet dimension is a <fieldset> with a
  <legend>; the count sits inside the <label> so it is part of the option's
  accessible name.
- Layout is a single-column stack that becomes a
  [minmax(0,15rem) minmax(0,1fr)] grid at lg. Below lg the facets live behind
  an aria-expanded toggle and are display:none when closed — a visually hidden
  panel would still be reachable by Tab.
- Overflow discipline: min-w-0 on every flex/grid child that holds text plus
  wrap-anywhere on titles, breadcrumbs and facet labels. A 60-character URL
  segment wraps instead of pushing the page sideways; fieldsets get min-w-0
  because their default min-inline-size is min-content.
- Motion: only the skeleton pulses, and it stops under motion-reduce; every
  transition is transition-colors and is disabled under motion-reduce without
  losing any function. focus-visible:ring-2 ring-ring on every control.

Customization levers
- Add a fourth facet (author, product area, language) by copying the
  derive-options -> toggle -> countAfter trio; the counts, the reset-to-page-1
  and both zero states pick it up with no further changes.
- Facet semantics: make a dimension single-select by replacing toggleValue
  with a replace, or make recency multi-select if your ranges stop nesting —
  the counts stay honest either way because they are computed from the same
  predicate.
- Server-side paging: replace `filtered.slice()` with a fetch keyed by
  (facets, page) and keep the same UI; the contract already carries the full
  ordered set for the client-side default.
- Highlighting: pass an explicit term array (stems, synonyms) instead of the
  raw query, or switch the mode to "substring" for phrase search.
- Density: pageSize is a prop; drop the breadcrumb line or the tag chips for a
  tighter row; move the type pill next to the title for a card-like result.
- Copy: the advice list, the two zero-state paragraphs and the "Each number is
  the result count after that click" hint are single literals meant to be
  swapped for your voice or an i18n lookup.
- Announcement timing: ANNOUNCE_DELAY_MS trades narration latency for calm; a
  block that never re-renders per keystroke can drop it to 0.

Concepts

  • Honest facet counts — the number next to an option is produced by applying that click to the current filter state and running the same predicate the result list runs, so it is a promise about the next click rather than a decorative badge. A count that ignores the other dimensions (or, worse, a hardcoded one) is the most common way a search page lies to its user.
  • OR inside, AND across — two selected types widen the set, a selected tag on top of them narrows it. Stating the rule once and computing counts from that same rule is what keeps the numbers and the list from drifting apart.
  • Structural highlighting — matches become real <mark> elements around text nodes, never an HTML string, so a snippet containing markup is displayed rather than parsed; overlapping and touching hits merge into one mark so a screen reader does not hear the same run twice.
  • Filtering resets pagination — a new filter combination is a new result set; keeping the old page number is how a user ends up staring at an empty page 4. The page index is additionally clamped during render, so a shrinking set can never point past the end.
  • Two zero states — "this query found nothing" and "your filters hid everything" look identical if you reuse one panel. They get different copy and different exits: rewrite advice on one side, a Clear all filters button on the other.
  • Recency anchored to data — the time buckets are measured from queriedAt in the payload instead of Date.now(), so the same response renders the same buckets on the server, in the browser, and in a screenshot taken a week later.
  • Calm live region — the result count is announced from one always-mounted role="status" element written 600ms after the last change, so a host that re-renders on every keystroke narrates the outcome once instead of narrating the typing.

On This Page