Template
Initial commit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22Ms5Cxam5UHyxaCrtZzu
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
getPredictiveSearchItemUrl,
|
||||
type PredictiveSearchData,
|
||||
type PredictiveSearchState,
|
||||
} from "@shopify/hydrogen";
|
||||
import {
|
||||
PredictiveSearchProvider,
|
||||
usePredictiveSearch,
|
||||
usePredictiveSearchActions,
|
||||
usePredictiveSearchForm,
|
||||
} from "@shopify/hydrogen/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { content } from "@/lib/content";
|
||||
import { shopifyImageUrl } from "@/lib/image";
|
||||
import { formatPrice } from "@/lib/money";
|
||||
import { routeTemplates } from "@/lib/route-templates";
|
||||
|
||||
const PREDICTIVE_SEARCH_LIMIT = 5;
|
||||
const PREDICTIVE_SEARCH_DIALOG_ID = "search-modal";
|
||||
|
||||
/**
|
||||
* Predictive search modal (`hydrogen-predictive-search` skill). Opened from the
|
||||
* header search trigger. The form is a native `GET /search` fallback so a no-JS
|
||||
* shopper reaches full search results (F4). The modal is a centered `<dialog>`
|
||||
* (`.dialog-center`). Products only, per `notes/predictive-search.md`.
|
||||
*/
|
||||
export function PredictiveSearchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PredictiveSearchProvider limit={PREDICTIVE_SEARCH_LIMIT} types={["PRODUCT"]}>
|
||||
<PredictiveSearchDialogInner isOpen={isOpen} onClose={onClose} />
|
||||
</PredictiveSearchProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PredictiveSearchDialogInner({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const state = usePredictiveSearch();
|
||||
const { clear } = usePredictiveSearchActions();
|
||||
const { formProps, register } = usePredictiveSearchForm();
|
||||
|
||||
// Open/close the native <dialog> and clear predictive state on close so stale
|
||||
// suggestions do not reappear on the next open.
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
if (isOpen && !dialog.open) {
|
||||
dialog.showModal();
|
||||
} else if (!isOpen && dialog.open) {
|
||||
dialog.close();
|
||||
clear();
|
||||
}
|
||||
}, [isOpen, clear]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
id={PREDICTIVE_SEARCH_DIALOG_ID}
|
||||
className="dialog-center"
|
||||
aria-labelledby="search-modal-title"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-border flex shrink-0 items-center gap-2 border-b p-4">
|
||||
<form {...formProps()} role="search" className="flex flex-1 items-center gap-2">
|
||||
<label htmlFor="predictive-search-q" className="sr-only">
|
||||
{content.search.label}
|
||||
</label>
|
||||
<input
|
||||
id="predictive-search-q"
|
||||
type="search"
|
||||
{...register("query")}
|
||||
placeholder={content.search.placeholder}
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="submit" className="sr-only">
|
||||
{content.search.submit}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="button-icon inline-flex h-11 w-11 items-center justify-center rounded"
|
||||
aria-label={content.general.close}
|
||||
>
|
||||
<img
|
||||
src="/icons/icon-x.svg"
|
||||
width="20"
|
||||
height="20"
|
||||
alt=""
|
||||
className="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{state.result.term || state.result.items.products.length > 0 ? (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<h2 id="search-modal-title" className="sr-only">
|
||||
{content.search.title}
|
||||
</h2>
|
||||
<PredictiveBody state={state} onNavigate={onClose} />
|
||||
</div>
|
||||
) : (
|
||||
<h2 id="search-modal-title" className="sr-only">
|
||||
{content.search.title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{state.result.items.products.length > 0 ? (
|
||||
<div className="border-border shrink-0 border-t p-4">
|
||||
<Link
|
||||
href={`/search?q=${encodeURIComponent(state.result.term)}`}
|
||||
onClick={onClose}
|
||||
className="rounded-button button-secondary inline-flex h-11 w-full items-center justify-center px-4 text-sm font-medium no-underline"
|
||||
>
|
||||
View all results
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PredictiveBody({
|
||||
state,
|
||||
onNavigate,
|
||||
}: {
|
||||
state: PredictiveSearchState<PredictiveSearchData>;
|
||||
onNavigate: () => void;
|
||||
}) {
|
||||
const { status, error, result } = state;
|
||||
const products = result.items.products;
|
||||
const hasResults = products.length > 0;
|
||||
const term = result.term;
|
||||
|
||||
if (status === "loading") {
|
||||
return <p className="text-on-surface-secondary text-sm">Searching…</p>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<p role="alert" className="text-sale text-sm">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (status === "success" && !hasResults && term) {
|
||||
return (
|
||||
<div data-testid="search-modal-empty">
|
||||
<p className="text-on-surface text-sm">No results for “{term}”</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!hasResults) return null;
|
||||
|
||||
return (
|
||||
<ul role="listbox" aria-label="Products" className="flex flex-col gap-2">
|
||||
{products.map((product) => {
|
||||
const href = getPredictiveSearchItemUrl(product, { routes: routeTemplates, term });
|
||||
const variant = product.selectedOrFirstAvailableVariant;
|
||||
const image = variant?.image ?? null;
|
||||
const price = variant?.price ?? null;
|
||||
return (
|
||||
<li key={product.id} role="option" aria-selected={false}>
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onNavigate}
|
||||
className="hover:bg-hover flex items-center gap-3 rounded p-2 no-underline"
|
||||
>
|
||||
{image ? (
|
||||
<img
|
||||
src={shopifyImageUrl(image.url, { width: 64 })}
|
||||
alt={image.altText ?? product.title}
|
||||
className="size-12 rounded object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<span className="flex flex-col">
|
||||
<span className="type-body-sm text-on-surface font-medium">{product.title}</span>
|
||||
{price ? (
|
||||
<span className="text-on-surface-secondary text-sm">{formatPrice(price)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user