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';