Setup lists the required files and packages. Usage shows how to add the component.
SearchExpandNavVariation.jsx
"use client";
/**
* Search Expand Nav extended variation, based on Moumen Soliman's original interaction.
* Ported from the finished Search-Expand-Nav-Portfolio-Handoff.md implementation.
* The measured 400ms icon glide and 440ms upward disclosure are preserved.
* Saved restaurants and recent searches stay in memory via an optional sessionRef.
* Google Material Symbols licenses: /vault/search-expand-nav/material-symbols-NOTICE.txt
*/
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { MaterialSymbol } from "./SearchNavMaterialSymbols";
import "./SearchExpandNavVariation.css";
const MORPH_MS = 400;
const GROW_MS = 440;
const SUGGESTIONS = ["Best restaurants in Berlin", "Pizza in Prenzlauer Berg", "Dinner in Neukölln"];
const SCOPE_NOTICE = "Home and Explore aren’t part of this experiment.";
const SEARCH_NAV_PLACES = [
{
"id": "wen-cheng-schoenhauser",
"name": "Wen Cheng Schönhauser",
"cuisine": "Chinese noodles",
"neighborhood": "Prenzlauer Berg",
"address": "Schönhauser Allee 65, 10437 Berlin",
"description": "The Schönhauser location serves hand-pulled Chinese biang biang noodles.",
"website": "https://www.wenchengnoodles.com/locations/berlin/schoenhauser",
"keywords": [
"noodles",
"biang biang",
"Chinese",
"Wen Cheng"
]
},
{
"id": "liu-noodlehouse",
"name": "LIU",
"cuisine": "Sichuan noodles",
"neighborhood": null,
"address": "Kronenstraße 72, 10117 Berlin",
"description": "Chengdu-style noodles and dumplings, including Sichuan beef noodles and Zajiang noodles.",
"website": "https://chengduweidao.de/speisekarte/",
"keywords": [
"noodles",
"Chinese",
"Chengdu",
"dumplings"
]
},
{
"id": "standard-templiner",
"name": "Standard Serious Pizza",
"cuisine": "Neapolitan pizza",
"neighborhood": "Prenzlauer Berg",
"address": "Templiner Straße 7, 10119 Berlin",
"description": "Neapolitan pizza at the Templiner Straße location in Prenzlauer Berg.",
"website": "https://www.standard-berlin.de/",
"keywords": [
"pizza",
"Italian"
]
},
{
"id": "otto",
"name": "otto",
"cuisine": "Seasonal plates",
"neighborhood": "Prenzlauer Berg",
"address": "Oderberger Straße 56, 10435 Berlin",
"description": "A seasonal menu of smaller sharing plates, using produce from Brandenburg.",
"website": "https://www.otto-berlin.net/",
"keywords": [
"seasonal",
"sharing plates",
"regional"
]
},
{
"id": "coda",
"name": "CODA",
"cuisine": "Dessert dining",
"neighborhood": "Neukölln",
"address": "Friedelstraße 47, 12047 Berlin",
"description": "A fine-dining restaurant whose menu draws on pâtisserie techniques and dessert-inspired cooking.",
"website": "https://coda-berlin.com/en/home/",
"keywords": [
"dessert",
"fine dining",
"patisserie"
]
},
{
"id": "nobelhart-schmutzig",
"name": "Nobelhart & Schmutzig",
"cuisine": "Regional cuisine",
"neighborhood": "Kreuzberg",
"address": "Friedrichstraße 218, 10969 Berlin",
"description": "A restaurant whose cooking centers on local producers and ingredients from the Berlin region.",
"website": "https://nobelhartundschmutzig.com/en/",
"keywords": [
"seasonal",
"regional",
"local",
"Nobelhart und Schmutzig"
]
}
];
async function searchDemoPlaces(query, signal) {
await new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("Search cancelled", "AbortError"));
return;
}
const onAbort = () => {
window.clearTimeout(timer);
reject(new DOMException("Search cancelled", "AbortError"));
};
const timer = window.setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, 900);
signal.addEventListener("abort", onAbort, { once: true });
});
return findDemoPlaces(query);
}
function findDemoPlaces(query) {
const terms = normalize(query).split(/\s+/).filter((term) => term && !["in", "near", "around", "restaurant", "restaurants", "best", "dinner"].includes(term));
return SEARCH_NAV_PLACES.filter((place) => {
const haystack = normalize([place.name, place.cuisine, place.neighborhood, place.address, place.description, ...place.keywords].join(" "));
return terms.every((term) => haystack.includes(term));
});
}
function normalize(value) {
return value.trim().toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/ß/g, "ss");
}
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange) {
const query = window.matchMedia(REDUCED_MOTION_QUERY);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}
function getReducedMotionSnapshot() {
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
}
function getServerReducedMotionSnapshot() {
return false;
}
function useReducedMotion() {
return useSyncExternalStore(subscribeReducedMotion, getReducedMotionSnapshot, getServerReducedMotionSnapshot);
}
/**
* @typedef {Object} SearchNavPlace
* @property {string} id Unique, stable restaurant identifier.
* @property {string} name
* @property {string} cuisine
* @property {string|null} neighborhood
* @property {string} address
* @property {string} description
* @property {string} website Trusted HTTP(S) restaurant URL.
* @property {string[]} keywords Search terms used by the bundled demo provider.
*/
/**
* Render inside a container with an explicit height (at least 460px recommended).
* Search/Saved and recent queries are in memory only. The default provider uses
* the supplied restaurant records and simulated loading; no backend is required.
* Keep controls and sessionRef stable if provided. compact is a decorative preview.
* @param {Object} props
* @param {boolean} [props.compact=false]
* @param {{replay?: () => void, reset?: () => void, simulateError?: () => void}} [props.controls] Optional commands populated while mounted.
* @param {(query: string, signal: AbortSignal) => Promise<SearchNavPlace[]>} [props.searchProvider] Return records or reject; honor the signal to cancel network work.
* @param {boolean} [props.reducedMotion=false] Adds to the operating-system preference.
* @param {{current: {recent: string[], saved: SearchNavPlace[]} | null}} [props.sessionRef] Optional memory shared across remounts.
*/
function SearchExpandNavVariation({ compact = false, controls, searchProvider = searchDemoPlaces, reducedMotion = false, sessionRef }) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
const [view, setView] = useState("search");
const [query, setQuery] = useState("");
const [submittedQuery, setSubmittedQuery] = useState("");
const [state, setState] = useState("idle");
const [results, setResults] = useState([]);
const [resultQuery, setResultQuery] = useState("");
const [recent, setRecent] = useState(() => sessionRef?.current?.recent ?? []);
const [saved, setSaved] = useState(() => sessionRef?.current?.saved ?? []);
const [detail, setDetail] = useState(null);
const [announcement, setAnnouncement] = useState("");
const [noticeVisible, setNoticeVisible] = useState(false);
const [travelX, setTravelX] = useState(0);
const [panelHeight, setPanelHeight] = useState(0);
const [maxPanelHeight, setMaxPanelHeight] = useState(320);
const [previewScale, setPreviewScale] = useState(1);
const prefersReducedMotion = useReducedMotion();
const reduced = reducedMotion || prefersReducedMotion;
const panelId = useId();
const inputId = useId();
const scopeDescriptionId = useId();
const rootRef = useRef(null);
const panelInnerRef = useRef(null);
const listRef = useRef(null);
const inputRef = useRef(null);
const searchButtonRef = useRef(null);
const firstButtonRef = useRef(null);
const detailBackRef = useRef(null);
const triggerRef = useRef(null);
const noticeTimerRef = useRef(void 0);
const requestRef = useRef(null);
const requestVersionRef = useRef(0);
const sequenceTimersRef = useRef([]);
const playbackTimersRef = useRef([]);
const playingRef = useRef(false);
const compositionRef = useRef(false);
const focusOnOpenRef = useRef(false);
const focusOnCollectionRef = useRef(false);
const lastPlaceRef = useRef(null);
const commandsRef = useRef({ replay: () => 0, reset: () => {
}, simulateError: () => {
} });
const interactive = expanded && !compact;
const collection = saved;
const history = [...recent, ...SUGGESTIONS.filter((item) => !recent.some((entry) => normalize(entry) === normalize(item)))].slice(0, 3);
const isSaved = detail ? saved.some((place) => place.id === detail.id) : false;
function cancelRequest() {
requestVersionRef.current += 1;
requestRef.current?.abort();
requestRef.current = null;
}
function clearSequence() {
sequenceTimersRef.current.forEach(window.clearTimeout);
sequenceTimersRef.current = [];
}
function stopPlayback() {
playbackTimersRef.current.forEach(window.clearTimeout);
playbackTimersRef.current = [];
playingRef.current = false;
}
function schedule(fn, ms, playback = false) {
const timer = window.setTimeout(fn, ms);
(playback ? playbackTimersRef : sequenceTimersRef).current.push(timer);
}
function dismissNotice() {
window.clearTimeout(noticeTimerRef.current);
setNoticeVisible(false);
}
function showScopeNotice() {
stopPlayback();
window.clearTimeout(noticeTimerRef.current);
setNoticeVisible(true);
setAnnouncement(SCOPE_NOTICE);
noticeTimerRef.current = window.setTimeout(() => setNoticeVisible(false), 4200);
}
function closePanel(restoreFocus = false) {
dismissNotice();
stopPlayback();
clearSequence();
cancelRequest();
focusOnOpenRef.current = false;
focusOnCollectionRef.current = false;
setExpanded(false);
if (state === "loading") setState("idle");
const finish = () => {
setOpen(false);
if (restoreFocus && !compact) (triggerRef.current ?? searchButtonRef.current)?.focus({ preventScroll: true });
};
if (reduced) finish();
else schedule(finish, GROW_MS);
}
function openSearch(shouldFocus = true) {
dismissNotice();
clearSequence();
cancelRequest();
setState("idle");
setDetail(null);
triggerRef.current = searchButtonRef.current;
focusOnOpenRef.current = shouldFocus && !compact;
focusOnCollectionRef.current = false;
const morph = () => {
setView("search");
setOpen(true);
if (reduced) setExpanded(true);
else schedule(() => setExpanded(true), MORPH_MS);
};
if (expanded && !open) {
setExpanded(false);
if (reduced) morph();
else schedule(morph, GROW_MS);
} else {
morph();
}
}
function openSaved(trigger) {
dismissNotice();
stopPlayback();
if (expanded && view === "saved" && !detail) {
closePanel(true);
return;
}
clearSequence();
cancelRequest();
setView("saved");
setDetail(null);
setOpen(false);
setExpanded(true);
triggerRef.current = trigger;
focusOnOpenRef.current = false;
focusOnCollectionRef.current = !compact;
}
function changeQuery(value) {
stopPlayback();
cancelRequest();
setQuery(value);
setState("idle");
setDetail(null);
setAnnouncement("");
}
function clearQuery() {
changeQuery("");
inputRef.current?.focus({ preventScroll: true });
}
async function submitSearch(value = query, forcedFailure = false, autoplay = false) {
if (compositionRef.current) return;
const clean = value.trim();
if (!clean) {
if (!compact && !autoplay) inputRef.current?.focus({ preventScroll: true });
return;
}
if (!autoplay) stopPlayback();
if (!compact && !autoplay) inputRef.current?.focus({ preventScroll: true });
cancelRequest();
const request = new AbortController();
const version = requestVersionRef.current;
requestRef.current = request;
setQuery(value);
setSubmittedQuery(clean);
setDetail(null);
setState("loading");
setAnnouncement(`Searching for ${clean}.`);
try {
let places;
if (forcedFailure) {
await searchDemoPlaces(clean, request.signal);
throw new Error("Preview request failure");
} else {
places = await searchProvider(clean, request.signal);
}
if (request.signal.aborted || version !== requestVersionRef.current) return;
setResults(places);
setResultQuery(clean);
setState("success");
setRecent((previous) => [clean, ...previous.filter((item) => normalize(item) !== normalize(clean))].slice(0, 3));
setAnnouncement(places.length ? `${places.length} ${places.length === 1 ? "result" : "results"} for ${clean}.` : `No results for ${clean}. Try a different search.`);
} catch {
if (request.signal.aborted || version !== requestVersionRef.current) return;
setState("error");
setAnnouncement("Search could not finish. Your query is still here. Try again.");
} finally {
if (version === requestVersionRef.current) requestRef.current = null;
}
}
function openPlace(place) {
stopPlayback();
lastPlaceRef.current = place.id;
setDetail(place);
setAnnouncement(`${place.name}. Restaurant details.`);
}
function backToCollection() {
const placeId = lastPlaceRef.current;
setDetail(null);
if (!compact) schedule(() => {
const buttons = listRef.current?.querySelectorAll("[data-place-id]");
const original = Array.from(buttons ?? []).find((button) => button.dataset.placeId === placeId);
const firstChoice = listRef.current?.querySelector("[data-list-choice]");
const fallback = view === "search" ? inputRef.current : triggerRef.current;
(original ?? firstChoice ?? fallback)?.focus({ preventScroll: true });
}, 0);
}
function toggleSaved() {
if (!detail) return;
setSaved((previous) => isSaved ? previous.filter((place) => place.id !== detail.id) : [...previous, detail]);
setAnnouncement(isSaved ? `${detail.name} removed from Saved.` : `${detail.name} added to Saved.`);
}
function reset() {
dismissNotice();
stopPlayback();
clearSequence();
cancelRequest();
focusOnOpenRef.current = false;
focusOnCollectionRef.current = false;
setOpen(false);
setExpanded(false);
setView("search");
setQuery("");
setSubmittedQuery("");
setResults([]);
setResultQuery("");
setRecent([]);
setSaved([]);
setDetail(null);
setState("idle");
setAnnouncement("");
}
function replay() {
reset();
if (reduced) {
setView("search");
setOpen(true);
setExpanded(true);
setQuery("noodles");
setSubmittedQuery("noodles");
setResultQuery("noodles");
setResults(findDemoPlaces("noodles"));
setState("success");
return 0;
}
playingRef.current = true;
schedule(() => openSearch(false), 350, true);
const demoQuery = "noodles";
const typingStart = 350 + MORPH_MS + GROW_MS + 200;
Array.from(demoQuery).forEach((_, index) => schedule(() => setQuery(demoQuery.slice(0, index + 1)), typingStart + index * 90, true));
schedule(() => {
void submitSearch(demoQuery, false, true);
}, typingStart + demoQuery.length * 90 + 250, true);
schedule(() => {
cancelRequest();
setState((previous) => previous === "loading" ? "idle" : previous);
setExpanded(false);
schedule(() => {
setOpen(false);
playingRef.current = false;
}, GROW_MS, true);
}, typingStart + demoQuery.length * 90 + 250 + 900 + 1900, true);
return typingStart + demoQuery.length * 90 + 250 + 900 + 1900 + GROW_MS + MORPH_MS;
}
function simulateError() {
stopPlayback();
clearSequence();
const value = query.trim() || "noodles";
if (open) {
setExpanded(true);
void submitSearch(value, true);
return;
}
const wait = reduced ? 0 : (expanded ? GROW_MS : 0) + MORPH_MS + GROW_MS;
openSearch(false);
schedule(() => {
void submitSearch(value, true);
}, wait, true);
}
commandsRef.current = { replay, reset, simulateError };
useLayoutEffect(() => {
if (sessionRef && !compact) sessionRef.current = { recent, saved };
}, [recent, saved, sessionRef, compact]);
useLayoutEffect(() => {
const measure = () => {
const root = rootRef.current;
const first = firstButtonRef.current;
const search = searchButtonRef.current;
if (!root || !first || !search) return;
setTravelX(first.offsetLeft - search.offsetLeft);
const scale = compact && root.clientHeight <= 300 ? 0.68 : 1;
setPreviewScale(scale);
const bottom = compact ? 0 : parseFloat(getComputedStyle(root).paddingBottom);
const barHeight = root.querySelector(".snv-bar")?.offsetHeight ?? 48;
setMaxPanelHeight(Math.max(64, Math.floor((root.clientHeight - bottom - 16) / scale - barHeight - 2)));
};
measure();
const observer = new ResizeObserver(measure);
if (rootRef.current) observer.observe(rootRef.current);
return () => observer.disconnect();
}, [compact]);
useLayoutEffect(() => {
const inner = panelInnerRef.current;
if (!inner) return;
const measure = () => setPanelHeight(Math.min(inner.scrollHeight, maxPanelHeight));
measure();
const observer = new ResizeObserver(measure);
observer.observe(inner);
return () => observer.disconnect();
}, [maxPanelHeight]);
useEffect(() => {
if (open && expanded && focusOnOpenRef.current && !compact) {
inputRef.current?.focus({ preventScroll: true });
focusOnOpenRef.current = false;
}
}, [open, expanded, compact]);
useEffect(() => {
if (detail && !compact && !playingRef.current) detailBackRef.current?.focus({ preventScroll: true });
}, [detail, compact]);
useEffect(() => {
if (expanded && !open && !detail && !compact && focusOnCollectionRef.current) {
listRef.current?.querySelector("[data-list-choice]")?.focus({ preventScroll: true });
focusOnCollectionRef.current = false;
}
}, [expanded, open, detail, view, compact]);
useEffect(() => {
if (!open && !expanded && !noticeVisible || compact) return;
const pointer = (event) => {
if (!rootRef.current?.querySelector(".snv-box")?.contains(event.target)) closePanel(false);
};
document.addEventListener("pointerdown", pointer);
return () => {
document.removeEventListener("pointerdown", pointer);
};
});
useEffect(() => {
if (!controls) return;
controls.replay = () => {
commandsRef.current.replay();
};
controls.reset = () => commandsRef.current.reset();
controls.simulateError = () => commandsRef.current.simulateError();
return () => {
delete controls.replay;
delete controls.reset;
delete controls.simulateError;
};
}, [controls]);
useEffect(() => {
if (!compact) return;
if (reduced) {
commandsRef.current.replay();
return;
}
let visible = false;
let loopTimer = 0;
const cycle = () => {
if (!visible) return;
const duration = commandsRef.current.replay();
loopTimer = window.setTimeout(cycle, duration + 1400);
};
const observer = new IntersectionObserver(([entry]) => {
const nowVisible = Boolean(entry?.isIntersecting && entry.intersectionRatio >= 0.35);
if (nowVisible && !visible) {
visible = true;
cycle();
} else if (!nowVisible && visible) {
visible = false;
window.clearTimeout(loopTimer);
commandsRef.current.reset();
}
}, { threshold: [0, 0.35, 1] });
if (rootRef.current) observer.observe(rootRef.current);
return () => {
visible = false;
observer.disconnect();
window.clearTimeout(loopTimer);
commandsRef.current.reset();
};
}, [compact, reduced]);
useEffect(() => () => {
window.clearTimeout(noticeTimerRef.current);
sequenceTimersRef.current.forEach(window.clearTimeout);
playbackTimersRef.current.forEach(window.clearTimeout);
requestVersionRef.current += 1;
requestRef.current?.abort();
}, []);
function handleEscape(event) {
if (!open && !expanded && !noticeVisible || compact) return;
if (event.key !== "Escape" || event.nativeEvent.isComposing || compositionRef.current) return;
if (!rootRef.current?.contains(document.activeElement)) return;
event.preventDefault();
event.stopPropagation();
if (noticeVisible) dismissNotice();
else if (open && query && (event.target === inputRef.current || event.target.closest?.(".snv-clear"))) clearQuery();
else closePanel(true);
}
function navigateList(event) {
if (event.nativeEvent.isComposing || compositionRef.current || !expanded || compact) return;
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
const choices = Array.from(listRef.current?.querySelectorAll("[data-list-choice]:not(:disabled)") ?? []);
if (!choices.length) return;
event.preventDefault();
const current = choices.indexOf(document.activeElement);
const direction = event.key === "ArrowDown" ? 1 : -1;
const next = current < 0 ? direction > 0 ? 0 : choices.length - 1 : (current + direction + choices.length) % choices.length;
choices[next].focus({ preventScroll: true });
choices[next].scrollIntoView({ block: "nearest", behavior: "instant" });
}
function placeRows(places) {
return <ul className="snv-list">{places.map((place, index) => <li key={place.id} className="snv-item" style={{ "--snv-index": index }}>
<button type="button" className="snv-result" data-list-choice data-place-id={place.id} tabIndex={interactive ? 0 : -1} onClick={() => openPlace(place)}>
<span className="snv-row-icon"><PlaceIcon /></span>
<span className="snv-result-copy"><span className="snv-result-title">{place.name}</span><span className="snv-result-meta">{place.cuisine} <span aria-hidden="true">·</span> {place.neighborhood ?? "Berlin"}</span></span>
<span className="snv-trailing">{saved.some((item) => item.id === place.id) ? <BookmarkIcon size={14} filled /> : <ArrowIcon />}</span>
</button>
</li>)}</ul>;
}
let heading = view === "saved" ? "Saved" : query.trim() ? "Ready to search" : recent.length ? "Recent" : "Suggested searches";
let meta = view === "saved" ? `${saved.length} ${saved.length === 1 ? "restaurant" : "restaurants"}` : "";
if (view === "search") {
if (state === "loading") heading = "Searching";
if (state === "success") {
heading = results.length ? "Results" : "No results";
meta = results.length ? `${results.length} ${results.length === 1 ? "match" : "matches"}` : "";
}
if (state === "error") heading = "Search interrupted";
}
if (detail) {
heading = detail.cuisine;
meta = detail.neighborhood ?? "Berlin";
}
return <div ref={rootRef} className={`snv-demo${compact ? " snv-demo--compact" : ""}`} data-open={open} data-expanded={expanded} data-reduced-motion={reduced} onKeyDown={handleEscape} data-view={view} data-state={state} data-detail={detail?.id ?? ""} aria-hidden={compact || void 0} style={{ "--snv-panel-max": `${maxPanelHeight}px`, "--snv-scale": previewScale }}>
<div className="snv-box">
<div className="snv-scope-notice" data-visible={noticeVisible} aria-hidden="true">{SCOPE_NOTICE}</div>
<span className="snv-elevation" aria-hidden="true" />
<section id={panelId} className="snv-panel" aria-label={detail ? `${detail.name} preview` : `${heading} panel`} aria-hidden={!expanded} inert={!expanded || compact} style={{ height: expanded ? panelHeight : 0 }}>
<div ref={panelInnerRef} className="snv-panel-inner">
<div className="snv-panel-heading"><h2>{heading}</h2><span>{meta}</span></div>
<div ref={listRef} className="snv-panel-body" onKeyDown={navigateList}>
{detail ? <div className="snv-detail snv-state-content" key={`detail-${detail.id}`}>
<button ref={detailBackRef} type="button" className="snv-text-button snv-back" tabIndex={interactive ? 0 : -1} onClick={backToCollection}><BackIcon /> Back to {view === "search" ? "results" : "Saved"}</button>
<h3>{detail.name}</h3>
<div className="snv-place-actions">
<button type="button" className="snv-action snv-save" aria-label={isSaved ? "Unsave restaurant" : "Save restaurant"} aria-pressed={isSaved} tabIndex={interactive ? 0 : -1} onClick={toggleSaved}><BookmarkIcon size={14} filled={isSaved} />{isSaved ? "Saved" : "Save"}{isSaved && <CheckIcon />}</button>
<a className="snv-action snv-map-link" aria-label="Open in Google Maps" href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${detail.name}, ${detail.address}`)}`} target="_blank" rel="noopener noreferrer" tabIndex={interactive ? 0 : -1}><PlaceIcon /> Open map</a>
</div>
<p>{detail.description}</p>
<p className="snv-address">{detail.address}</p>
<a className="snv-text-button snv-website" href={detail.website} target="_blank" rel="noopener noreferrer" tabIndex={interactive ? 0 : -1}>Restaurant website <ArrowIcon /></a>
</div> : view !== "search" ? <div className="snv-state-content" key={view}>
{collection.length ? placeRows(collection) : <div className="snv-empty"><p>Your saved restaurants go here.</p><span>Save a restaurant to find it here again.</span><button type="button" className="snv-action" data-list-choice tabIndex={interactive ? 0 : -1} onClick={() => {
setQuery("");
openSearch();
}}>Find restaurants <ArrowIcon /></button></div>}
</div> : <div className="snv-state-content" key={state === "idle" ? query.trim() ? "ready" : "suggestions" : state}>
{state === "idle" && (query.trim() ? <div className="snv-ready">
<button type="button" className="snv-suggestion" data-list-choice tabIndex={interactive ? 0 : -1} onClick={() => {
void submitSearch();
}}><span className="snv-row-icon"><SearchIcon size={14} /></span><span className="snv-suggestion-label">Search for “{query.trim()}”</span><span className="snv-keycap" aria-hidden="true"><MaterialSymbol name="keyboard_return" size={14} /></span></button>
<p>Search names, cuisines, and neighborhoods.</p>
</div> : <ul className="snv-list">{history.map((value, index) => <li key={value} className="snv-item" style={{ "--snv-index": index }}><button type="button" className="snv-suggestion" data-list-choice tabIndex={interactive ? 0 : -1} onClick={() => {
void submitSearch(value);
}}><span className="snv-row-icon">{recent.includes(value) ? <ClockIcon /> : <SearchIcon size={14} />}</span><span className="snv-suggestion-label">{value}</span><span className="snv-trailing"><ArrowUpLeftIcon /></span></button></li>)}</ul>)}
{state === "loading" && <>
<div className="snv-loading-copy"><MaterialSymbol name="progress_activity" size={14} className="snv-spinner" /><span>Searching for “{submittedQuery}”</span></div>
{results.length ? <><p className="snv-previous-label">Previous results · {resultQuery}</p>{placeRows(results)}</> : <div className="snv-skeleton-list" aria-hidden="true">{[0, 1, 2].map((index) => <div key={index} className="snv-skeleton-row" style={{ "--snv-index": index }}><span className="snv-skeleton-icon" /><span className="snv-skeleton-copy"><span /><span /></span></div>)}</div>}
</>}
{state === "success" && (results.length ? placeRows(results) : <div className="snv-empty"><p>No matches for “{submittedQuery}”.</p><span>Try noodles, pizza, or Neukölln.</span><button type="button" className="snv-action" data-list-choice tabIndex={interactive ? 0 : -1} onClick={() => {
void submitSearch("noodles");
}}>Try “noodles” <ArrowIcon /></button></div>)}
{state === "error" && <><div className="snv-empty"><p>Search couldn't finish.</p><span>Your query is still here. Give it another try.</span><button type="button" className="snv-action" data-list-choice tabIndex={interactive ? 0 : -1} onClick={() => {
void submitSearch(submittedQuery);
}}><RetryIcon /> Retry search</button></div>{results.length > 0 && <><p className="snv-previous-label">Previous results · {resultQuery}</p>{placeRows(results)}</>}</>}
</div>}
</div>
</div>
</section>
<form className="snv-bar" role="search" aria-label="Berlin restaurants" onSubmit={(event) => {
event.preventDefault();
if (open && !compositionRef.current) void submitSearch();
}}>
<label className="snv-visually-hidden" htmlFor={inputId}>Search restaurants</label>
<input ref={inputRef} id={inputId} type="search" className="snv-input" placeholder="Search…" aria-hidden={!open} aria-controls={panelId} autoComplete="off" spellCheck={false} tabIndex={open && !compact ? 0 : -1} value={query} onChange={(event) => changeQuery(event.target.value)} onCompositionStart={() => {
compositionRef.current = true;
}} onCompositionEnd={() => {
compositionRef.current = false;
}} onKeyDown={(event) => {
if (event.key === "Enter" && (event.nativeEvent.isComposing || compositionRef.current || event.keyCode === 229)) {
event.preventDefault();
return;
}
navigateList(event);
}} />
<div className="snv-row" onKeyDown={navigateList}>
<button ref={firstButtonRef} type="button" className="snv-icon-button snv-nav" aria-label="Home" title="Home" aria-describedby={scopeDescriptionId} aria-hidden={open || void 0} tabIndex={!open && !compact ? 0 : -1} onClick={showScopeNotice}><HomeIcon /></button>
<button type="button" className="snv-icon-button snv-nav" aria-label="Explore" title="Explore" aria-describedby={scopeDescriptionId} aria-hidden={open || void 0} tabIndex={!open && !compact ? 0 : -1} onClick={showScopeNotice}><CompassIcon /></button>
<button type="button" className="snv-icon-button snv-nav" aria-label="Saved" title="Saved" aria-expanded={expanded && view === "saved"} aria-controls={panelId} aria-hidden={open || void 0} tabIndex={!open && !compact ? 0 : -1} onClick={(event) => openSaved(event.currentTarget)}><BookmarkIcon />{saved.length > 0 && <span className="snv-saved-dot" aria-hidden="true" />}</button>
<button ref={searchButtonRef} type={open ? "submit" : "button"} className="snv-icon-button snv-search" aria-label={open ? "Submit search" : "Search"} title={open ? "Submit search" : "Search"} aria-expanded={open} aria-controls={panelId} aria-disabled={open && (!query.trim() || state === "loading")} tabIndex={compact ? -1 : 0} style={{ transform: open ? `translateX(${travelX}px)` : "translateX(0)" }} onClick={(event) => {
if (!open) {
event.preventDefault();
stopPlayback();
openSearch();
} else if (!query.trim() || state === "loading") event.preventDefault();
}}>{state === "loading" && open ? <MaterialSymbol name="progress_activity" size={16} className="snv-spinner" /> : <SearchIcon />}</button>
</div>
<button type="button" className="snv-icon-button snv-clear" aria-label="Clear query" aria-keyshortcuts="Escape" title="Clear query (Esc)" aria-hidden={!open || !query} tabIndex={open && Boolean(query) && !compact ? 0 : -1} data-visible={open && Boolean(query)} onClick={clearQuery}><span className="snv-clear-label" aria-hidden="true">esc</span></button>
<button type="button" className="snv-icon-button snv-close" aria-label={open ? "Close search" : "Close navigation"} title="Close search" aria-hidden={!open} tabIndex={open && !compact ? 0 : -1} onClick={() => closePanel(true)}><CloseIcon /></button>
</form>
</div>
<span id={scopeDescriptionId} className="snv-visually-hidden">{SCOPE_NOTICE}</span>
<span className="snv-visually-hidden" role="status" aria-live={compact ? "off" : "polite"} aria-atomic="true">{announcement}</span>
</div>;
}
function HomeIcon() {
return <MaterialSymbol name="home" size={16} />;
}
function CompassIcon() {
return <MaterialSymbol name="explore" size={16} />;
}
function BookmarkIcon({ size = 16, filled = false }) {
return <MaterialSymbol name="bookmark" size={size} filled={filled} />;
}
function SearchIcon({ size = 16 }) {
return <MaterialSymbol name="search" size={size} />;
}
function CloseIcon({ size = 16 }) {
return <MaterialSymbol name="close" size={size} />;
}
function ClockIcon() {
return <MaterialSymbol name="history" size={14} />;
}
function ArrowUpLeftIcon() {
return <MaterialSymbol name="north_west" size={14} />;
}
function ArrowIcon() {
return <MaterialSymbol name="chevron_right" size={14} />;
}
function BackIcon() {
return <MaterialSymbol name="arrow_back" size={14} />;
}
function PlaceIcon() {
return <MaterialSymbol name="location_on" size={14} />;
}
function RetryIcon() {
return <MaterialSymbol name="refresh" size={14} />;
}
function CheckIcon() {
return <MaterialSymbol name="check" size={14} />;
}
export {
SEARCH_NAV_PLACES,
SearchExpandNavVariation,
searchDemoPlaces
};