Menu

Change language

GitHub repo

Search Results Page

blocklayout

Full search results page: large pre-filled search input with result count, removable filter chips with clear-all, a left facet sidebar with Type and Team checkbox groups (hidden below lg), six result rows with icon tiles, titles, paths, snippets, and type/updated meta, plus a Previous/Next pagination footer. Chips and facets are interactive; swap the stub rows for your search source.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/search-results-page.json
Manifest:https://uipkge.dev/r/lit/search-results-page.jsonInstalls to:src/uip/blocks/search-results-page/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

SearchResult
interface SearchResult {
  id: string
  icon: IconNode
  iconName: string
  heading: string
  path: string
  snippet: string
  type: string
  updated: string
}
FacetOption
interface FacetOption {
  id: string
  label: string
  count: number
}
FacetGroup
interface FacetGroup {
  id: string
  label: string
  options: FacetOption[]
}
Chip
interface Chip {
  id: string
  label: string
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-search-results-page': UipSearchResultsPage
  }
}

Files installed (1)

  • src/uip/blocks/search-results-page/search-results-page.ts11.7 kB
    import { LitElement, css, html, nothing } from 'lit'
    import { BookOpen, FileText, GitBranch, MessageSquare, Search, Users, Video, type IconNode } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/badge/badge'
    import '../../components/button/button'
    import '../../components/checkbox/checkbox'
    import '../../components/chip/chip'
    import '../../components/input/input'
    import type { UipInput } from '../../components/input/input'
    
    interface SearchResult {
      id: string
      icon: IconNode
      iconName: string
      heading: string
      path: string
      snippet: string
      type: string
      updated: string
    }
    
    interface FacetOption {
      id: string
      label: string
      count: number
    }
    
    interface FacetGroup {
      id: string
      label: string
      options: FacetOption[]
    }
    
    interface Chip {
      id: string
      label: string
    }
    
    const RESULTS: SearchResult[] = [
      {
        id: 'r1',
        icon: BookOpen,
        iconName: 'book-open',
        heading: 'Onboarding flows: design your first-run experience',
        path: 'docs.acme.dev / guides / onboarding-flows',
        snippet:
          'Learn how to compose onboarding flows from checklists, tours, and progressive disclosure so new users reach their first aha moment faster.',
        type: 'Docs',
        updated: '2 days ago',
      },
      {
        id: 'r2',
        icon: FileText,
        iconName: 'file-text',
        heading: 'Checklist API reference for onboarding flows',
        path: 'docs.acme.dev / api / checklist',
        snippet:
          'Full reference for the Checklist primitive used to drive onboarding flows, including step completion events, persistence, and theming.',
        type: 'Reference',
        updated: '5 hours ago',
      },
      {
        id: 'r3',
        icon: Video,
        iconName: 'video',
        heading: 'Video walkthrough: building onboarding flows in 20 minutes',
        path: 'learn.acme.dev / videos / onboarding-flows-walkthrough',
        snippet:
          'A hands-on screencast that builds a complete onboarding flow with branching steps, skippable tours, and analytics instrumentation.',
        type: 'Video',
        updated: '1 week ago',
      },
      {
        id: 'r4',
        icon: MessageSquare,
        iconName: 'message-square',
        heading: 'How do you measure completion of onboarding flows?',
        path: 'community.acme.dev / discussions / 4821',
        snippet:
          'Community thread comparing activation-rate funnels, step-drop-off charts, and qualitative surveys for measuring onboarding flow success.',
        type: 'Discussion',
        updated: '3 days ago',
      },
      {
        id: 'r5',
        icon: GitBranch,
        iconName: 'git-branch',
        heading: 'Example repo: onboarding flows with A/B-tested variants',
        path: 'github.com / acme / examples / onboarding-ab-test',
        snippet:
          'Runnable example that splits new signups into two onboarding flow variants and reports which one converts better to first project creation.',
        type: 'Example',
        updated: '2 weeks ago',
      },
      {
        id: 'r6',
        icon: Users,
        iconName: 'users',
        heading: 'Playbook: personalizing onboarding flows by team role',
        path: 'docs.acme.dev / playbooks / role-based-onboarding',
        snippet:
          'Patterns for tailoring onboarding flows to admins, editors, and viewers, with sample copy, suggested defaults, and rollout checklists.',
        type: 'Guide',
        updated: '4 days ago',
      },
    ]
    
    const FACET_GROUPS: FacetGroup[] = [
      {
        id: 'type',
        label: 'Type',
        options: [
          { id: 'type-docs', label: 'Docs', count: 18 },
          { id: 'type-guides', label: 'Guides', count: 11 },
          { id: 'type-video', label: 'Video', count: 7 },
          { id: 'type-discussion', label: 'Discussion', count: 6 },
        ],
      },
      {
        id: 'team',
        label: 'Team',
        options: [
          { id: 'team-product', label: 'Product', count: 14 },
          { id: 'team-engineering', label: 'Engineering', count: 16 },
          { id: 'team-design', label: 'Design', count: 8 },
          { id: 'team-support', label: 'Support', count: 4 },
        ],
      },
    ]
    
    const TOTAL_PAGES = 3
    
    /**
     * <uip-search-results-page> — the registry SearchResultsPage block as a web
     * component.
     *
     * Class strings are React's SearchResultsPage verbatim. React state (query,
     * chips, facets, page) becomes @state fields. No props, no events: swap the
     * stub rows for your search source.
     *
     * Deviations: result `title` is `heading` in the internal row type only (no
     * public prop, no rename needed). The search field icon moves from React's
     * absolute overlay into <uip-input>'s prefix slot (same visual result; the
     * input's `pl-9` is dropped since the slot provides the gutter).
     */
    export class UipSearchResultsPage extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      static styles = [tailwind, css`:host { display: block; }`]
    
      static properties = {
        query: { state: true },
        chips: { state: true },
        activeFacets: { state: true },
        page: { state: true },
      }
    
      private query = 'onboarding flows'
      private chips: Chip[] = [
        { id: 'type-docs', label: 'Type: Docs' },
        { id: 'team-product', label: 'Team: Product' },
      ]
      private activeFacets: Set<string> = new Set(['type-docs', 'team-product'])
      private page = 1
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'search-results-page')
      }
    
      private removeChip(id: string) {
        this.chips = this.chips.filter((chip) => chip.id !== id)
      }
    
      private toggleFacet(id: string, checked: boolean) {
        const next = new Set(this.activeFacets)
        if (checked) next.add(id)
        else next.delete(id)
        this.activeFacets = next
      }
    
      render() {
        const start = (this.page - 1) * RESULTS.length + 1
        const resultRange = `${start}-${start + RESULTS.length - 1}`
        return html`
          <div data-slot="search-results-page" class=${cn('w-full space-y-6')}>
            <div class="space-y-3">
              <div class="relative max-w-xl">
                <uip-input
                  type="search"
                  class="[&::part(control)]:h-11 text-base"
                  .value=${this.query}
                  placeholder="Search..."
                  @input=${(e: Event) => {
                    this.query = (e.target as UipInput).value
                  }}
                >
                  <span slot="prefix">${icon(Search, 'search', 'size-4')}</span>
                </uip-input>
              </div>
              <p class="text-muted-foreground text-sm">${resultRange} of 42 results for &ldquo;${this.query}&rdquo;</p>
            </div>
    
            ${this.chips.length > 0
              ? html`<div class="flex flex-wrap items-center gap-2">
                  ${this.chips.map(
                    (chip) => html`
                      <uip-chip variant="outline" size="sm" closable @close=${() => this.removeChip(chip.id)}>
                        ${chip.label}
                      </uip-chip>
                    `,
                  )}
                  <uip-button
                    variant="link"
                    size="sm"
                    class="text-muted-foreground h-auto min-h-6 px-2"
                    @click=${() => (this.chips = [])}
                  >
                    Clear all
                  </uip-button>
                </div>`
              : nothing}
    
            <div class="flex gap-8">
              <aside class="hidden w-48 shrink-0 space-y-6 lg:block" aria-label="Filters">
                ${FACET_GROUPS.map(
                  (group) => html`
                    <div>
                      <h3 class="text-muted-foreground mb-3 text-xs font-medium tracking-wider uppercase">
                        ${group.label}
                      </h3>
                      <ul class="space-y-2.5">
                        ${group.options.map(
                          (option) => html`
                            <li>
                              <label
                                for=${`${group.id}-${option.id}`}
                                class="flex cursor-pointer items-center gap-2 text-sm"
                              >
                                <uip-checkbox
                                  id=${`${group.id}-${option.id}`}
                                  .checked=${this.activeFacets.has(option.id)}
                                  @checked-change=${(e: CustomEvent<{ checked: boolean }>) =>
                                    this.toggleFacet(option.id, e.detail.checked)}
                                ></uip-checkbox>
                                <span>${option.label}</span>
                                <span class="text-muted-foreground ml-auto text-xs tabular-nums">${option.count}</span>
                              </label>
                            </li>
                          `,
                        )}
                      </ul>
                    </div>
                  `,
                )}
              </aside>
    
              <div class="min-w-0 flex-1">
                <ol class="divide-border divide-y">
                  ${RESULTS.map(
                    (result) => html`
                      <li>
                        <a
                          href="#"
                          class="hover:bg-muted/50 focus-visible:ring-ring block rounded-lg p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
                        >
                          <div class="flex gap-4">
                            <div
                              class="bg-muted/50 text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-md border"
                            >
                              ${icon(result.icon, result.iconName, 'size-5')}
                            </div>
                            <div class="min-w-0 space-y-1">
                              <p class="truncate font-medium">${result.heading}</p>
                              <p class="text-muted-foreground truncate text-xs">${result.path}</p>
                              <p class="text-muted-foreground line-clamp-2 text-sm">${result.snippet}</p>
                              <div class="flex items-center gap-3 pt-1">
                                <uip-badge variant="secondary">${result.type}</uip-badge>
                                <span class="text-muted-foreground text-xs">Updated ${result.updated}</span>
                              </div>
                            </div>
                          </div>
                        </a>
                      </li>
                    `,
                  )}
                </ol>
    
                <nav class="flex items-center justify-between pt-4" aria-label="Pagination">
                  <uip-button variant="outline" size="sm" ?disabled=${this.page === 1} @click=${() => (this.page -= 1)}>
                    Previous
                  </uip-button>
                  <div class="flex items-center gap-1">
                    ${Array.from({ length: TOTAL_PAGES }, (_, i) => i + 1).map(
                      (n) => html`
                        <uip-button
                          variant=${n === this.page ? 'outline' : 'ghost'}
                          size="icon-sm"
                          aria-current=${n === this.page ? 'page' : nothing}
                          @click=${() => (this.page = n)}
                        >
                          ${n}
                        </uip-button>
                      `,
                    )}
                  </div>
                  <uip-button
                    variant="outline"
                    size="sm"
                    ?disabled=${this.page === TOTAL_PAGES}
                    @click=${() => (this.page += 1)}
                  >
                    Next
                  </uip-button>
                </nav>
              </div>
            </div>
          </div>
        `
      }
    }
    
    customElements.get('uip-search-results-page') || customElements.define('uip-search-results-page', UipSearchResultsPage)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-search-results-page': UipSearchResultsPage
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/search-results-page.json