Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Search

A searchable list screen composed from touchcn components.

A complete search screen: a live-filtering searchbar, a row of tappable recent searches, a grouped list of results (avatar, name and handle), pull-to-refresh, infinite scroll, loading skeletons and a no-results empty state. The sample data and timers stand in for a real API — replace them and emit itemTap into your navigation.

Installation

npx touchcn add search

This also adds the components the block composes:

avatar · button · chip · infinite-scroll · list · navbar · page · pull-to-refresh · searchbar · skeleton

Usage

<tcn-search-block (itemTap)="openResult($event)" />
import { TcnSearchBlock } from '@/components/blocks/search';

<TcnSearchBlock onItemTap={(item) => openResult(item)} />
<script setup lang="ts">
import { TcnSearchBlock } from '@/components/blocks/search';
</script>

<template>
  <TcnSearchBlock @item-tap="(item) => openResult(item)" />
</template>

itemTap fires with the tapped result. The block simulates fetching, refreshing and paging with local timers so it works standalone — swap the makeItems generator and the setTimeout delays for your data source, and drive loading and the exhausted flag from your own request state.

Set showBack to render a back control in the navbar’s leading slot; it emits back (Angular) / onBack (React) when tapped — wire it to your router.

Source · Angular
export * from './tcn-search-block';
import { booleanAttribute, Component, computed, DestroyRef, inject, input, output, signal } from '@angular/core';
import { TcnAvatar } from '@/components/ui/avatar';
import { TcnButton } from '@/components/ui/button';
import { TcnChip } from '@/components/ui/chip';
import { TcnInfiniteScroll } from '@/components/ui/infinite-scroll';
import { TcnList, TcnListItem } from '@/components/ui/list';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnPullToRefresh } from '@/components/ui/pull-to-refresh';
import { TcnSearchbar } from '@/components/ui/searchbar';
import { TcnSkeleton } from '@/components/ui/skeleton';

export interface SearchItem {
  id: number;
  name: string;
  handle: string;
  initials: string;
}

const FIRST = [
  'Ava', 'Liam', 'Noah', 'Emma', 'Olivia', 'Sophia', 'Mateo', 'Mia', 'Lucas', 'Amelia',
  'Ethan', 'Isabella', 'Aria', 'James', 'Layla', 'Kai', 'Zoe', 'Leo', 'Nora', 'Ivan',
  'Priya', 'Omar', 'Chen', 'Sara',
];
const LAST = [
  'Bennett', 'Carter', 'Nguyen', 'Patel', 'Silva', 'Reyes', 'Kim', 'Okafor', 'Rossi', 'Haddad',
  'Novak', 'Andersson', 'Costa', 'Flores', 'Ibrahim', 'Larsson', 'Moreau', 'Petrov', 'Sato', 'Vargas',
];

const PAGE_SIZE = 8;
const INITIAL_SIZE = 12;
const MAX_ITEMS = 48;
const MAX_RECENTS = 6;

/** Deterministic sample person for a given index — stand-in for a real API row. */
function makeItem(id: number): SearchItem {
  const first = FIRST[id % FIRST.length];
  const last = LAST[(id * 7) % LAST.length];
  return {
    id,
    name: `${first} ${last}`,
    handle: `@${first.toLowerCase()}.${last.toLowerCase()}`,
    initials: `${first[0]}${last[0]}`,
  };
}

function makeItems(start: number, count: number): SearchItem[] {
  return Array.from({ length: count }, (_, index) => makeItem(start + index));
}

/**
 * Searchable master list — a full app screen: a live-filtering searchbar, a row
 * of tappable recent searches, a grouped list of results (avatar + name + handle),
 * pull-to-refresh, infinite scroll, loading skeletons and an empty state. The
 * simulated data and timers stand in for a real API — replace them and emit
 * `itemTap` into your navigation.
 */
@Component({
  selector: 'tcn-search-block',
  imports: [
    TcnPage,
    TcnNavbar,
    TcnPullToRefresh,
    TcnSearchbar,
    TcnChip,
    TcnList,
    TcnListItem,
    TcnAvatar,
    TcnSkeleton,
    TcnInfiniteScroll,
    TcnButton,
  ],
  template: `
    <tcn-page>
      <tcn-navbar title="Search">
        @if (showBack()) {
          <button navbar-leading type="button" class="tcn-navbar-back" aria-label="Back" (click)="back.emit()">
            <svg class="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
              <path d="M10 2 2 10l8 8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
            </svg>
            <svg class="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
            </svg>
          </button>
        }
      </tcn-navbar>
      <tcn-pull-to-refresh #ptr="tcnPullToRefresh" (refresh)="onRefresh(ptr)">
        <div class="mx-auto max-w-[480px] px-4 pt-16 pb-12">
          <div class="py-3">
            <tcn-searchbar
              placeholder="Search people"
              [(value)]="query"
              (search)="commitRecent($event)"
              (cancelled)="query.set('')"
            />
          </div>

          @if (!query() && recents().length) {
            <div class="mb-4">
              <div class="mb-2 flex items-center justify-between px-1">
                <span class="text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
                  Recent
                </span>
                <button tcnButton variant="ghost" size="sm" type="button" (click)="clearRecents()">Clear</button>
              </div>
              <div class="flex flex-wrap gap-2">
                @for (term of recents(); track term) {
                  <button type="button" class="appearance-none" (click)="query.set(term)">
                    <tcn-chip>{{ term }}</tcn-chip>
                  </button>
                }
              </div>
            </div>
          }

          @if (loading()) {
            <tcn-list>
              @for (row of skeletonRows; track $index) {
                <tcn-list-item>
                  <tcn-skeleton item-leading variant="circle" class="size-10" />
                  <tcn-skeleton variant="text" class="my-1.5 w-32" />
                  <tcn-skeleton item-subtitle variant="text" class="mt-1 w-20" />
                </tcn-list-item>
              }
            </tcn-list>
          } @else if (!filtered().length) {
            <div class="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
              <span class="text-[var(--color-on-surface-variant)]">
                <svg width="44" height="44" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.75" />
                  <path d="m20 20-3-3" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
                  <path d="m8 8 6 6m0-6-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5" />
                </svg>
              </span>
              <div>
                <p class="font-medium">No results found</p>
                <p class="mt-1 text-sm text-[var(--color-on-surface-variant)]">
                  Nothing matched “{{ query() }}”. Try a different search.
                </p>
              </div>
              <button tcnButton variant="secondary" type="button" (click)="query.set('')">Clear search</button>
            </div>
          } @else {
            <tcn-list>
              @for (item of filtered(); track item.id) {
                <tcn-list-item chevron class="cursor-pointer" (click)="itemTap.emit(item)">
                  <tcn-avatar item-leading size="md" [initials]="item.initials" [alt]="item.name" />
                  {{ item.name }}
                  <span item-subtitle>{{ item.handle }}</span>
                </tcn-list-item>
              }
            </tcn-list>

            <tcn-infinite-scroll
              #infinite="tcnInfiniteScroll"
              [disabled]="infiniteDisabled()"
              (loadMore)="onLoadMore(infinite)"
            >
              <span end class="text-sm text-[var(--color-on-surface-variant)]">You've reached the end</span>
            </tcn-infinite-scroll>
          }
        </div>
      </tcn-pull-to-refresh>
    </tcn-page>
  `,
})
export class TcnSearchBlock {
  /** Renders a back control in the navbar's leading slot. */
  readonly showBack = input(false, { transform: booleanAttribute });
  /** Fires with the row the user taps. */
  readonly itemTap = output<SearchItem>();
  readonly back = output<void>();

  protected readonly query = signal('');
  protected readonly items = signal<SearchItem[]>([]);
  protected readonly loading = signal(true);
  protected readonly exhausted = signal(false);
  protected readonly recents = signal<string[]>(['Amelia', 'Design team', 'Kai']);

  protected readonly skeletonRows = Array.from({ length: 6 });

  protected readonly filtered = computed(() => {
    const query = this.query().trim().toLowerCase();
    if (!query) {
      return this.items();
    }
    return this.items().filter(
      (item) => item.name.toLowerCase().includes(query) || item.handle.toLowerCase().includes(query),
    );
  });

  protected readonly infiniteDisabled = computed(() => this.loading() || this.exhausted() || !!this.query().trim());

  private timers = new Set<ReturnType<typeof setTimeout>>();

  constructor() {
    // Simulate the initial fetch: skeletons, then the first page.
    this.defer(() => {
      this.items.set(makeItems(0, INITIAL_SIZE));
      this.loading.set(false);
    }, 700);
    inject(DestroyRef).onDestroy(() => {
      this.timers.forEach((id) => clearTimeout(id));
      this.timers.clear();
    });
  }

  protected commitRecent(term: string): void {
    const value = term.trim();
    if (!value) {
      return;
    }
    this.recents.update((list) => [value, ...list.filter((item) => item !== value)].slice(0, MAX_RECENTS));
  }

  protected clearRecents(): void {
    this.recents.set([]);
  }

  protected onRefresh(ptr: TcnPullToRefresh): void {
    this.defer(() => {
      this.items.set(makeItems(0, INITIAL_SIZE));
      this.exhausted.set(false);
      ptr.complete();
    }, 1000);
  }

  protected onLoadMore(infinite: TcnInfiniteScroll): void {
    this.defer(() => {
      const next = Math.min(this.items().length + PAGE_SIZE, MAX_ITEMS);
      this.items.set(makeItems(0, next));
      if (next >= MAX_ITEMS) {
        this.exhausted.set(true);
      }
      infinite.complete();
    }, 800);
  }

  private defer(work: () => void, ms: number): void {
    const id = setTimeout(() => {
      this.timers.delete(id);
      work();
    }, ms);
    this.timers.add(id);
  }
}
Source · React
export * from './tcn-search-block';
import { useEffect, useMemo, useState } from 'react';
import { TcnAvatar } from '@/components/ui/avatar';
import { TcnButton } from '@/components/ui/button';
import { TcnChip } from '@/components/ui/chip';
import { TcnInfiniteScroll } from '@/components/ui/infinite-scroll';
import { TcnList, TcnListItem } from '@/components/ui/list';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnPullToRefresh } from '@/components/ui/pull-to-refresh';
import { TcnSearchbar } from '@/components/ui/searchbar';
import { TcnSkeleton } from '@/components/ui/skeleton';

export interface SearchItem {
  id: number;
  name: string;
  handle: string;
  initials: string;
}

export interface TcnSearchBlockProps {
  /** Renders a back control in the navbar's leading slot. */
  showBack?: boolean;
  /** Fires with the row the user taps. */
  onItemTap?(item: SearchItem): void;
  /** Fires when the navbar back control is activated. */
  onBack?(): void;
}

const FIRST = [
  'Ava', 'Liam', 'Noah', 'Emma', 'Olivia', 'Sophia', 'Mateo', 'Mia', 'Lucas', 'Amelia',
  'Ethan', 'Isabella', 'Aria', 'James', 'Layla', 'Kai', 'Zoe', 'Leo', 'Nora', 'Ivan',
  'Priya', 'Omar', 'Chen', 'Sara',
];
const LAST = [
  'Bennett', 'Carter', 'Nguyen', 'Patel', 'Silva', 'Reyes', 'Kim', 'Okafor', 'Rossi', 'Haddad',
  'Novak', 'Andersson', 'Costa', 'Flores', 'Ibrahim', 'Larsson', 'Moreau', 'Petrov', 'Sato', 'Vargas',
];

const PAGE_SIZE = 8;
const INITIAL_SIZE = 12;
const MAX_ITEMS = 48;
const MAX_RECENTS = 6;

/** Deterministic sample person for a given index — stand-in for a real API row. */
function makeItem(id: number): SearchItem {
  const first = FIRST[id % FIRST.length];
  const last = LAST[(id * 7) % LAST.length];
  return {
    id,
    name: `${first} ${last}`,
    handle: `@${first.toLowerCase()}.${last.toLowerCase()}`,
    initials: `${first[0]}${last[0]}`,
  };
}

function makeItems(start: number, count: number): SearchItem[] {
  return Array.from({ length: count }, (_, index) => makeItem(start + index));
}

const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

/**
 * Searchable master list — a full app screen: a live-filtering searchbar, a row
 * of tappable recent searches, a grouped list of results (avatar + name + handle),
 * pull-to-refresh, infinite scroll, loading skeletons and an empty state. The
 * simulated data and timers stand in for a real API — replace them and emit
 * `onItemTap` into your navigation.
 */
export function TcnSearchBlock({ showBack = false, onItemTap, onBack }: TcnSearchBlockProps) {
  const [query, setQuery] = useState('');
  const [items, setItems] = useState<SearchItem[]>([]);
  const [loading, setLoading] = useState(true);
  const [exhausted, setExhausted] = useState(false);
  const [recents, setRecents] = useState<string[]>(['Amelia', 'Design team', 'Kai']);

  useEffect(() => {
    let active = true;
    delay(700).then(() => {
      if (!active) {
        return;
      }
      setItems(makeItems(0, INITIAL_SIZE));
      setLoading(false);
    });
    return () => {
      active = false;
    };
  }, []);

  const trimmed = query.trim().toLowerCase();
  const filtered = useMemo(() => {
    if (!trimmed) {
      return items;
    }
    return items.filter(
      (item) => item.name.toLowerCase().includes(trimmed) || item.handle.toLowerCase().includes(trimmed),
    );
  }, [items, trimmed]);

  const infiniteDisabled = loading || exhausted || !!query.trim();

  const commitRecent = (term: string) => {
    const value = term.trim();
    if (!value) {
      return;
    }
    setRecents((list) => [value, ...list.filter((item) => item !== value)].slice(0, MAX_RECENTS));
  };

  const onRefresh = async () => {
    await delay(1000);
    setItems(makeItems(0, INITIAL_SIZE));
    setExhausted(false);
  };

  const onLoadMore = async () => {
    await delay(800);
    setItems((list) => {
      const next = Math.min(list.length + PAGE_SIZE, MAX_ITEMS);
      if (next >= MAX_ITEMS) {
        setExhausted(true);
      }
      return makeItems(0, next);
    });
  };

  return (
    <TcnPage>
      <TcnNavbar
        title="Search"
        leading={
          showBack ? (
            <button type="button" className="tcn-navbar-back" aria-label="Back" onClick={() => onBack?.()}>
              <svg className="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
                <path d="M10 2 2 10l8 8" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
              <svg className="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>
          ) : undefined
        }
      />
      <TcnPullToRefresh onRefresh={onRefresh}>
        <div className="mx-auto max-w-[480px] px-4 pt-16 pb-12">
          <div className="py-3">
            <TcnSearchbar
              placeholder="Search people"
              value={query}
              onValueChange={setQuery}
              onSearch={commitRecent}
              onCancel={() => setQuery('')}
            />
          </div>

          {!query && recents.length > 0 && (
            <div className="mb-4">
              <div className="mb-2 flex items-center justify-between px-1">
                <span className="text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
                  Recent
                </span>
                <TcnButton variant="ghost" size="sm" onClick={() => setRecents([])}>
                  Clear
                </TcnButton>
              </div>
              <div className="flex flex-wrap gap-2">
                {recents.map((term) => (
                  <button key={term} type="button" className="appearance-none" onClick={() => setQuery(term)}>
                    <TcnChip>{term}</TcnChip>
                  </button>
                ))}
              </div>
            </div>
          )}

          {loading ? (
            <TcnList>
              {Array.from({ length: 6 }).map((_, index) => (
                <TcnListItem
                  key={index}
                  leading={<TcnSkeleton variant="circle" className="size-10" />}
                  subtitle={<TcnSkeleton variant="text" className="mt-1 w-20" />}
                >
                  <TcnSkeleton variant="text" className="my-1.5 w-32" />
                </TcnListItem>
              ))}
            </TcnList>
          ) : filtered.length === 0 ? (
            <div className="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
              <span className="text-[var(--color-on-surface-variant)]">
                <svg width="44" height="44" viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="1.75" />
                  <path d="m20 20-3-3" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
                  <path d="m8 8 6 6m0-6-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.5" />
                </svg>
              </span>
              <div>
                <p className="font-medium">No results found</p>
                <p className="mt-1 text-sm text-[var(--color-on-surface-variant)]">
                  Nothing matched “{query}”. Try a different search.
                </p>
              </div>
              <TcnButton variant="secondary" onClick={() => setQuery('')}>
                Clear search
              </TcnButton>
            </div>
          ) : (
            <>
              <TcnList>
                {filtered.map((item) => (
                  <TcnListItem
                    key={item.id}
                    chevron
                    className="cursor-pointer"
                    onClick={() => onItemTap?.(item)}
                    leading={<TcnAvatar size="md" initials={item.initials} alt={item.name} />}
                    subtitle={item.handle}
                  >
                    {item.name}
                  </TcnListItem>
                ))}
              </TcnList>

              <TcnInfiniteScroll
                onLoadMore={onLoadMore}
                disabled={infiniteDisabled}
                end={<span className="text-sm text-[var(--color-on-surface-variant)]">You've reached the end</span>}
              />
            </>
          )}
        </div>
      </TcnPullToRefresh>
    </TcnPage>
  );
}
Source · Vue
<script lang="ts">
export interface SearchItem {
  id: number;
  name: string;
  handle: string;
  initials: string;
}
</script>

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { TcnAvatar } from '@/components/ui/avatar';
import { TcnButton } from '@/components/ui/button';
import { TcnChip } from '@/components/ui/chip';
import { TcnInfiniteScroll } from '@/components/ui/infinite-scroll';
import { TcnList, TcnListItem } from '@/components/ui/list';
import { TcnNavbar } from '@/components/ui/navbar';
import { TcnPage } from '@/components/ui/page';
import { TcnPullToRefresh } from '@/components/ui/pull-to-refresh';
import { TcnSearchbar } from '@/components/ui/searchbar';
import { TcnSkeleton } from '@/components/ui/skeleton';

/**
 * Searchable master list — a full app screen: a live-filtering searchbar, a row
 * of tappable recent searches, a grouped list of results (avatar + name +
 * handle), pull-to-refresh, infinite scroll, loading skeletons and an empty
 * state. The simulated data and timers stand in for a real API — replace them
 * and emit `itemTap` into your navigation.
 */
withDefaults(defineProps<{
  /** Renders a back control in the navbar's leading slot. */
  showBack?: boolean;
}>(), { showBack: false });

const emit = defineEmits<{
  /** Fires with the row the user taps. */
  itemTap: [item: SearchItem];
  /** Fires when the navbar back control is activated. */
  back: [];
}>();

const FIRST = [
  'Ava', 'Liam', 'Noah', 'Emma', 'Olivia', 'Sophia', 'Mateo', 'Mia', 'Lucas', 'Amelia',
  'Ethan', 'Isabella', 'Aria', 'James', 'Layla', 'Kai', 'Zoe', 'Leo', 'Nora', 'Ivan',
  'Priya', 'Omar', 'Chen', 'Sara',
];
const LAST = [
  'Bennett', 'Carter', 'Nguyen', 'Patel', 'Silva', 'Reyes', 'Kim', 'Okafor', 'Rossi', 'Haddad',
  'Novak', 'Andersson', 'Costa', 'Flores', 'Ibrahim', 'Larsson', 'Moreau', 'Petrov', 'Sato', 'Vargas',
];

const PAGE_SIZE = 8;
const INITIAL_SIZE = 12;
const MAX_ITEMS = 48;
const MAX_RECENTS = 6;

/** Deterministic sample person for a given index — stand-in for a real API row. */
function makeItem(id: number): SearchItem {
  const first = FIRST[id % FIRST.length];
  const last = LAST[(id * 7) % LAST.length];
  return {
    id,
    name: `${first} ${last}`,
    handle: `@${first.toLowerCase()}.${last.toLowerCase()}`,
    initials: `${first[0]}${last[0]}`,
  };
}

function makeItems(start: number, count: number): SearchItem[] {
  return Array.from({ length: count }, (_, index) => makeItem(start + index));
}

const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

const query = ref('');
const items = ref<SearchItem[]>([]);
const loading = ref(true);
const exhausted = ref(false);
const recents = ref<string[]>(['Amelia', 'Design team', 'Kai']);

let alive = true;

onMounted(() => {
  delay(700).then(() => {
    if (!alive) return;
    items.value = makeItems(0, INITIAL_SIZE);
    loading.value = false;
  });
});
onBeforeUnmount(() => {
  alive = false;
});

const trimmed = computed(() => query.value.trim().toLowerCase());
const filtered = computed(() => {
  if (!trimmed.value) return items.value;
  return items.value.filter(
    (item) => item.name.toLowerCase().includes(trimmed.value) || item.handle.toLowerCase().includes(trimmed.value),
  );
});

const infiniteDisabled = computed(() => loading.value || exhausted.value || !!query.value.trim());

const commitRecent = (term: string): void => {
  const value = term.trim();
  if (!value) return;
  recents.value = [value, ...recents.value.filter((item) => item !== value)].slice(0, MAX_RECENTS);
};

const onRefresh = async (): Promise<void> => {
  await delay(1000);
  items.value = makeItems(0, INITIAL_SIZE);
  exhausted.value = false;
};

const onLoadMore = async (): Promise<void> => {
  await delay(800);
  const next = Math.min(items.value.length + PAGE_SIZE, MAX_ITEMS);
  if (next >= MAX_ITEMS) exhausted.value = true;
  items.value = makeItems(0, next);
};
</script>

<template>
  <TcnPage>
    <TcnNavbar title="Search">
      <template v-if="showBack" #leading>
        <button type="button" class="tcn-navbar-back" aria-label="Back" @click="emit('back')">
          <svg class="if-ios" width="11" height="18" viewBox="0 0 12 20" fill="none" aria-hidden="true">
            <path d="M10 2 2 10l8 8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
          <svg class="if-md" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
            <path d="M20 12H4m0 0 6-6m-6 6 6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
        </button>
      </template>
    </TcnNavbar>
    <TcnPullToRefresh :on-refresh="onRefresh">
      <div class="mx-auto max-w-[480px] px-4 pt-16 pb-12">
        <div class="py-3">
          <TcnSearchbar
            v-model="query"
            placeholder="Search people"
            @search="commitRecent"
            @cancel="query = ''"
          />
        </div>

        <div v-if="!query && recents.length > 0" class="mb-4">
          <div class="mb-2 flex items-center justify-between px-1">
            <span class="text-xs font-semibold uppercase tracking-wide text-[var(--color-on-surface-variant)]">
              Recent
            </span>
            <TcnButton variant="ghost" size="sm" @click="recents = []">Clear</TcnButton>
          </div>
          <div class="flex flex-wrap gap-2">
            <button v-for="term in recents" :key="term" type="button" class="appearance-none" @click="query = term">
              <TcnChip>{{ term }}</TcnChip>
            </button>
          </div>
        </div>

        <TcnList v-if="loading">
          <TcnListItem v-for="index in 6" :key="index">
            <template #leading><TcnSkeleton variant="circle" class="size-10" /></template>
            <template #subtitle><TcnSkeleton variant="text" class="mt-1 w-20" /></template>
            <TcnSkeleton variant="text" class="my-1.5 w-32" />
          </TcnListItem>
        </TcnList>

        <div v-else-if="filtered.length === 0" class="flex flex-col items-center justify-center gap-3 px-6 py-16 text-center">
          <span class="text-[var(--color-on-surface-variant)]">
            <svg width="44" height="44" viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.75" />
              <path d="m20 20-3-3" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" />
              <path d="m8 8 6 6m0-6-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" opacity="0.5" />
            </svg>
          </span>
          <div>
            <p class="font-medium">No results found</p>
            <p class="mt-1 text-sm text-[var(--color-on-surface-variant)]">
              Nothing matched “{{ query }}”. Try a different search.
            </p>
          </div>
          <TcnButton variant="secondary" @click="query = ''">Clear search</TcnButton>
        </div>

        <template v-else>
          <TcnList>
            <TcnListItem
              v-for="item in filtered"
              :key="item.id"
              chevron
              class="cursor-pointer"
              @click="emit('itemTap', item)"
            >
              <template #leading><TcnAvatar size="md" :initials="item.initials" :alt="item.name" /></template>
              <template #subtitle>{{ item.handle }}</template>
              {{ item.name }}
            </TcnListItem>
          </TcnList>

          <TcnInfiniteScroll :on-load-more="onLoadMore" :disabled="infiniteDisabled">
            <template #end>
              <span class="text-sm text-[var(--color-on-surface-variant)]">You've reached the end</span>
            </template>
          </TcnInfiniteScroll>
        </template>
      </div>
    </TcnPullToRefresh>
  </TcnPage>
</template>
export { default as TcnSearchBlock } from './TcnSearchBlock.vue';
export type { SearchItem } from './TcnSearchBlock.vue';

Last updated on July 24, 2026

Was this page helpful?