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 uipkge-ng@latest add search-results-page
Manifest:https://uipkge.dev/r/angular/search-results-page.jsonInstalls to:src/app/components/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: LucideIconData
  title: 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[]
}
FilterChip
interface FilterChip {
  id: string
  label: string
}

Files installed (2)

  • src/app/components/blocks/search-results-page/search-results-page.component.ts10.7 kB
    // Full search results page: pre-filled search input with result count, removable
    // filter chips with clear-all, a left facet sidebar, six result rows, and a
    // Previous/Next pagination footer. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      BookOpen,
      FileText,
      GitBranch,
      LucideAngularModule,
      MessageSquare,
      Search,
      Users,
      Video,
      type LucideIconData,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCheckboxComponent } from '@/ui/checkbox/checkbox.component'
    import { UiChipComponent } from '@/ui/chip/chip.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    
    interface SearchResult {
      id: string
      icon: LucideIconData
      title: 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 FilterChip {
      id: string
      label: string
    }
    
    const RESULTS: SearchResult[] = [
      {
        id: 'r1',
        icon: BookOpen,
        title: '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,
        title: '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,
        title: '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,
        title: '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,
        title: '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,
        title: '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
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-search-results-page, [ui-search-results-page]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCheckboxComponent,
        UiChipComponent,
        UiInputComponent,
      ],
      template: `
        <div data-slot="search-results-page" [class]="rootClass">
          <div class="space-y-3">
            <div class="relative max-w-xl">
              <lucide-icon
                [img]="Search"
                aria-hidden="true"
                class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
              />
              <ui-input
                type="search"
                [value]="query()"
                (valueChange)="query.set($event)"
                placeholder="Search..."
                class="h-11 pl-9 text-base"
              />
            </div>
            <p class="text-muted-foreground text-sm">{{ resultRange() }} of 42 results for &ldquo;{{ query() }}&rdquo;</p>
          </div>
    
          @if (chips().length > 0) {
            <div class="flex flex-wrap items-center gap-2">
              @for (chip of chips(); track chip.id) {
                <span ui-chip variant="outline" size="sm" closable (close)="removeChip(chip.id)">
                  {{ chip.label }}
                </span>
              }
              <button
                ui-button
                variant="link"
                size="sm"
                class="text-muted-foreground h-auto min-h-6 px-2"
                (click)="clearAll()"
              >
                Clear all
              </button>
            </div>
          }
    
          <div class="flex gap-8">
            <aside class="hidden w-48 shrink-0 space-y-6 lg:block" aria-label="Filters">
              @for (group of facetGroups; track group.id) {
                <div>
                  <h3 class="text-muted-foreground mb-3 text-xs font-medium tracking-wider uppercase">
                    {{ group.label }}
                  </h3>
                  <ul class="space-y-2.5">
                    @for (option of group.options; track option.id) {
                      <li>
                        <label [for]="group.id + '-' + option.id" class="flex cursor-pointer items-center gap-2 text-sm">
                          <ui-checkbox
                            [id]="group.id + '-' + option.id"
                            [checked]="activeFacets().has(option.id)"
                            (checkedChange)="toggleFacet(option.id, !!$event)"
                          />
                          <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">
                @for (result of resultsList; track result.id) {
                  <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"
                        >
                          <lucide-icon [img]="result.icon" aria-hidden="true" class="size-5" />
                        </div>
                        <div class="min-w-0 space-y-1">
                          <p class="truncate font-medium">{{ result.title }}</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">
                            <span ui-badge variant="secondary">{{ result.type }}</span>
                            <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">
                <button ui-button variant="outline" size="sm" [disabled]="page() === 1" (click)="prevPage()">
                  Previous
                </button>
                <div class="flex items-center gap-1">
                  @for (n of pageNumbers; track n) {
                    <button
                      ui-button
                      [variant]="n === page() ? 'outline' : 'ghost'"
                      size="icon-sm"
                      [attr.aria-current]="n === page() ? 'page' : null"
                      (click)="page.set(n)"
                    >
                      {{ n }}
                    </button>
                  }
                </div>
                <button ui-button variant="outline" size="sm" [disabled]="page() === totalPages" (click)="nextPage()">
                  Next
                </button>
              </nav>
            </div>
          </div>
        </div>
      `,
    })
    export class UiSearchResultsPageComponent {
      protected readonly Search = Search
    
      protected readonly facetGroups = FACET_GROUPS
      protected readonly resultsList = RESULTS
      protected readonly pageNumbers = Array.from({ length: TOTAL_PAGES }, (_, i) => i + 1)
      protected readonly totalPages = TOTAL_PAGES
    
      @Input() set initialQuery(v: string | undefined) {
        if (v !== undefined) this.query.set(v)
      }
      @Input('class') className?: string
    
      readonly query = signal('onboarding flows')
      readonly chips = signal<FilterChip[]>([
        { id: 'type-docs', label: 'Type: Docs' },
        { id: 'team-product', label: 'Team: Product' },
      ])
      readonly activeFacets = signal<Set<string>>(new Set(['type-docs', 'team-product']))
      readonly page = signal(1)
    
      readonly resultRange = computed(() => {
        const start = (this.page() - 1) * this.resultsList.length + 1
        return `${start}-${start + this.resultsList.length - 1}`
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      removeChip(id: string): void {
        this.chips.update((prev) => prev.filter((chip) => chip.id !== id))
      }
    
      clearAll(): void {
        this.chips.set([])
      }
    
      toggleFacet(id: string, checked: boolean): void {
        this.activeFacets.update((prev) => {
          const next = new Set(prev)
          if (checked) next.add(id)
          else next.delete(id)
          return next
        })
      }
    
      prevPage(): void {
        this.page.update((p) => Math.max(1, p - 1))
      }
    
      nextPage(): void {
        this.page.update((p) => Math.min(TOTAL_PAGES, p + 1))
      }
    }
    
  • src/app/components/blocks/search-results-page/index.ts0.1 kB

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