components/we-inc/paginated-table-with-search-filter.tsx
import { useMemo, useState } from "react";
// Stock list for a plant nursery: name, type, pot size, price, stock.
const plants = [
["Japanese maple 'Bloodgood'", "Tree", "10 L", 89, 6], ["Lavender 'Hidcote'", "Shrub", "2 L", 9.5, 48], ["Hellebore 'Ivory Prince'", "Perennial", "3 L", 14, 22],
["Silver birch", "Tree", "15 L", 65, 9], ["Hydrangea 'Annabelle'", "Shrub", "5 L", 24, 15], ["Foxglove 'Pam's Choice'", "Perennial", "2 L", 8, 0],
["Box ball 30 cm", "Topiary", "7 L", 39, 12], ["Rosemary 'Miss Jessopp'", "Herb", "1 L", 6.5, 60], ["Crab apple 'Red Sentinel'", "Tree", "12 L", 75, 4],
["Salvia 'Caradonna'", "Perennial", "2 L", 9, 31], ["Pittosporum 'Tom Thumb'", "Shrub", "3 L", 18, 11], ["Thyme 'Silver Posie'", "Herb", "9 cm", 4, 75],
["Bay tree standard", "Topiary", "20 L", 120, 3], ["Geranium 'Rozanne'", "Perennial", "2 L", 10, 40], ["Olive tree", "Tree", "25 L", 149, 2],
["Mint 'Moroccan'", "Herb", "1 L", 5, 0], ["Viburnum tinus", "Shrub", "5 L", 22, 19], ["Agapanthus 'Navy Blue'", "Perennial", "3 L", 16, 27],
] as const;
const PER_PAGE = 5;
const gbp = new Intl.NumberFormat("en-GB", { style: "currency", currency: "GBP" });
export default function PaginatedTableWithSearchFilter() {
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const found = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? plants.filter((p) => `${p[0]} ${p[1]}`.toLowerCase().includes(q)) : plants;
}, [query]);
const pages = Math.max(1, Math.ceil(found.length / PER_PAGE));
const current = Math.min(page, pages);
const shown = found.slice((current - 1) * PER_PAGE, current * PER_PAGE);
const first = found.length ? (current - 1) * PER_PAGE + 1 : 0;
return (
<section className="bg-sky-50 px-5 py-14 sm:py-20">
<div className="mx-auto max-w-4xl rounded-2xl border border-sky-100 bg-white p-5 shadow-sm sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold text-slate-900">Greenhythe Nursery stock</h2>
<div className="relative sm:w-72">
<label htmlFor="tpg-q" className="sr-only">Search plants</label>
<svg viewBox="0 0 20 20" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><circle cx="9" cy="9" r="6" /><path d="m14 14 4 4" strokeLinecap="round" /></svg>
<input
id="tpg-q"
type="search"
value={query}
onChange={(e) => { setQuery(e.target.value); setPage(1); }}
placeholder="Search by name or type"
className="h-10 w-full rounded-lg border border-slate-300 pl-9 pr-3 text-sm text-slate-900 placeholder:text-slate-500 focus:border-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-600/30"
/>
</div>
</div>
<div className="mt-5 overflow-x-auto">
<table className="w-full min-w-[560px] border-collapse text-left text-sm">
<caption className="sr-only">Plants in stock, page {current} of {pages}</caption>
<thead>
<tr className="border-b border-slate-200 text-slate-600">
<th scope="col" className="py-2.5 pr-3 font-medium">Plant</th>
<th scope="col" className="px-3 py-2.5 font-medium">Type</th>
<th scope="col" className="px-3 py-2.5 font-medium">Pot</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">Price</th>
<th scope="col" className="py-2.5 pl-3 text-right font-medium">In stock</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{shown.map(([name, type, pot, price, stock]) => (
<tr key={name}>
<th scope="row" className="py-3 pr-3 font-medium text-slate-900">{name}</th>
<td className="px-3 py-3 text-slate-600">{type}</td>
<td className="px-3 py-3 text-slate-600">{pot}</td>
<td className="px-3 py-3 text-right tabular-nums text-slate-900">{gbp.format(price)}</td>
<td className={`py-3 pl-3 text-right tabular-nums ${stock === 0 ? "font-medium text-rose-700" : "text-slate-700"}`}>{stock === 0 ? "Sold out" : stock}</td>
</tr>
))}
{shown.length === 0 && <tr><td colSpan={5} className="py-10 text-center text-slate-600">No plants match "{query}".</td></tr>}
</tbody>
</table>
</div>
<nav aria-label="Table pages" className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-4">
<p className="text-sm text-slate-600" aria-live="polite">{first} to {Math.min(current * PER_PAGE, found.length)} of {found.length} plants</p>
<div className="flex items-center gap-1">
<button type="button" onClick={() => setPage(current - 1)} disabled={current === 1} className="h-10 rounded-lg px-3 text-sm font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600">Previous</button>
{Array.from({ length: pages }, (_, i) => i + 1).map((n) => (
<button key={n} type="button" onClick={() => setPage(n)} aria-current={n === current ? "page" : undefined} aria-label={`Page ${n}`} className={`h-10 w-10 rounded-lg text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 ${n === current ? "bg-sky-700 text-white" : "text-slate-700 hover:bg-slate-100"}`}>{n}</button>
))}
<button type="button" onClick={() => setPage(current + 1)} disabled={current === pages} className="h-10 rounded-lg px-3 text-sm font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600">Next</button>
</div>
</nav>
</div>
</section>
);
}