One page, two answers
The page renders HTML for the browser and answers JSON to fetch(): the same PHP filters, sorts and pages the catalogue, and $padExpose names what may leave the server.
$padExposepadFormat=json
useEffectAbortController
Live
- 🖥️ Atlas Monitor 27 - 349
- 🎧 Aurora Headphones - 189
- 🔦 Beam Headlamp - 34
- 🔊 Boom Speaker - 129
- 🫖 Brew Kettle - 49
- 👜 Canvas Tote - 22
How it is built
PAD, on the server React, in the browser
<div data-island="CatalogSearch" data-props="{^start}">
<ul>
{items}<li>{$emoji} {$name} - {$price}</li>{/items}
</ul>
</div>
<script type="text/babel" src="{$padRoot}react/examples/search.js"></script><?php
// One page, two answers. The browser asking ?examples/search gets search.pad with the
// first result in it; the component asks the same page with padFormat=json and gets
// $result as JSON - the templates never run for that request. $padExpose is the list of
// what may leave the server: nothing else of this page does.
$query = trim ( reactText ( 'q' ) );
$category = reactText ( 'category' );
$order = reactText ( 'sort', 'name' );
$perPage = 6;
$products = reactData ( 'products' );
$categories = array_values ( array_unique ( array_column ( $products, 'category' ) ) );
$found = array_values ( array_filter ( $products, fn ( $product ) =>
( $category === '' or $product ['category'] === $category ) and
( $query === '' or stripos ( $product ['name'] . ' ' . $product ['blurb'], $query ) !== FALSE ) ) );
usort ( $found, match ( $order ) {
'cheap' => fn ( $a, $b ) => $a ['price'] <=> $b ['price'],
'dear' => fn ( $a, $b ) => $b ['price'] <=> $a ['price'],
'rating' => fn ( $a, $b ) => $b ['rating'] <=> $a ['rating'],
default => fn ( $a, $b ) => strcasecmp ( $a ['name'], $b ['name'] )
} );
$pages = max ( 1, (int) ceil ( count ( $found ) / $perPage ) );
$pageNo = min ( $pages, max ( 1, (int) padRequest ( 'p', 1 ) ) );
$result = [ 'items' => array_slice ( $found, ( $pageNo - 1 ) * $perPage, $perPage ),
'total' => count ( $found ),
'page' => $pageNo,
'pages' => $pages ];
$start = [ 'result' => $result, 'categories' => $categories ];
$items = $result ['items'];
$padExpose = [ 'result' ];
?>// The catalogue search. The first result came with the page, in the props; every change of
// the query, the category, the order or the page asks the same PAD page again for JSON -
// PadReact.useData aborts the request before it, so a fast typist never sees an old answer
// arrive last. The request log under it shows each round trip.
function CatalogSearch({ result: first, categories }) {
const [query, setQuery] = React.useState('');
const [category, setCategory] = React.useState('');
const [sort, setSort] = React.useState('name');
const [page, setPage] = React.useState(1);
const q = PadReact.useDebounced(query.trim(), 250);
const { data, loading, error } = PadReact.useData('examples/search', { q, category, sort, p: page }, { result: first });
const result = data.result;
// a new search starts on its first page
const change = set => value => { set(value); setPage(1); };
return (
<div>
<div className="toolbar">
<div className="search">
<input className="input" value={query} onChange={event => change(setQuery)(event.target.value)}
placeholder="Search the catalogue - try 'light' or 'usb'" aria-label="Search the catalogue" />
</div>
<select className="select" value={sort} onChange={event => change(setSort)(event.target.value)} aria-label="Order">
<option value="name">Name</option>
<option value="cheap">Price, low first</option>
<option value="dear">Price, high first</option>
<option value="rating">Rating</option>
</select>
{loading && <span className="spinner" aria-label="Asking PAD" />}
</div>
<div className="pills" style={{ marginBottom: 18 }}>
{['', ...categories].map(name => (
<button key={name || 'all'} className={'pill' + (name === category ? ' is-on' : '')} onClick={() => change(setCategory)(name)}>
{name || 'All'}
</button>
))}
</div>
{error && <div className="callout"><p>PAD could not answer: {error.message}</p></div>}
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', opacity: loading ? .55 : 1, transition: 'opacity .2s' }}>
{result.items.map(item => (
<div key={item.id} className="card rise" style={{ padding: 16, display: 'flex', gap: 14, alignItems: 'center' }}>
<span style={{ fontSize: 34 }}>{item.emoji}</span>
<div style={{ minWidth: 0 }}>
<strong>{item.name}</strong>
<div className="muted small">{item.category} · <Stars rating={item.rating} /></div>
<div style={{ fontWeight: 700, marginTop: 2 }}>{money(item.price)}</div>
</div>
</div>
))}
</div>
{result.total === 0 && <div className="empty-state"><span className="big">🔎</span>Nothing in the catalogue matches.</div>}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 18, flexWrap: 'wrap' }}>
<button className="btn-sm btn-ghost" disabled={result.page <= 1} onClick={() => setPage(result.page - 1)}>← Previous</button>
<span className="muted small">page {result.page} of {result.pages} · {result.total} found</span>
<button className="btn-sm btn-ghost" disabled={result.page >= result.pages} onClick={() => setPage(result.page + 1)}>Next →</button>
<a className="small" style={{ marginLeft: 'auto' }} target="_blank" rel="noopener"
href={PadReact.url('examples/search', { q, category, sort, p: page, padFormat: 'json' })}>Open this answer as JSON ↗</a>
</div>
<RequestLog />
</div>
);
}
PadReact.island('CatalogSearch', CatalogSearch);