Template
- Header: search button opening a command-palette dialog with debounced product suggestions, a term chip, and View All - Command: shadcn-shaped primitives built on the project's own Dialog so no cmdk/radix dependency is introduced - /search: results grid with item count, sort (relevance, price asc/desc), and cursor-based load more - Filters: driven by the API's productFilters facets — colour swatches, labelled lists with counts, and a price range — round-tripped through each facet's raw input string so no filter shape is hardcoded Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
197 lines
4.7 KiB
TypeScript
197 lines
4.7 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
|
import { RiSearchLine, RiCloseLine } from '@remixicon/react';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
// A command palette in the shadcn shape, implemented on this project's own
|
|
// Dialog rather than cmdk so it stays dependency-free. Filtering is left to the
|
|
// caller, which suits async sources like the Storefront API.
|
|
|
|
function Command({ className, ...props }: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="command"
|
|
className={cn(
|
|
'flex h-full w-full flex-col overflow-hidden rounded-lg bg-popover text-popover-foreground',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface CommandDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
function CommandDialog({
|
|
open,
|
|
onOpenChange,
|
|
children,
|
|
className,
|
|
}: CommandDialogProps) {
|
|
// Dialog portals straight into document.body, so hold off until mounted or
|
|
// prerendering this component's page throws "document is not defined".
|
|
const [mounted, setMounted] = React.useState(false);
|
|
React.useEffect(() => setMounted(true), []);
|
|
|
|
if (!mounted) return null;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
// DialogContent merges with clsx, so its own `p-6`/`gap-4` need the
|
|
// important flag to lose to these.
|
|
className={cn(
|
|
'top-24 max-w-xl translate-y-0 overflow-hidden p-0! gap-0!',
|
|
className
|
|
)}
|
|
>
|
|
<Command>{children}</Command>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
interface CommandInputProps
|
|
extends Omit<React.ComponentProps<'input'>, 'onChange'> {
|
|
onValueChange?: (value: string) => void;
|
|
onClear?: () => void;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
function CommandInput({
|
|
className,
|
|
value,
|
|
onValueChange,
|
|
onClear,
|
|
onClose,
|
|
...props
|
|
}: CommandInputProps) {
|
|
const hasValue = Boolean(String(value ?? '').length);
|
|
|
|
return (
|
|
<div
|
|
data-slot="command-input-wrapper"
|
|
className="flex h-12 items-center gap-2 px-3"
|
|
>
|
|
<RiSearchLine size={18} className="shrink-0 text-muted-foreground" />
|
|
<input
|
|
data-slot="command-input"
|
|
value={value}
|
|
onChange={(event) => onValueChange?.(event.target.value)}
|
|
className={cn(
|
|
'flex h-10 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
{hasValue && onClear && (
|
|
<Button
|
|
type="button"
|
|
onClick={onClear}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="font-normal text-muted-foreground hover:text-foreground"
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
{onClose && (
|
|
<Button
|
|
type="button"
|
|
onClick={onClose}
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Close search"
|
|
>
|
|
<RiCloseLine size={18} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CommandList({ className, ...props }: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="command-list"
|
|
className={cn('max-h-80 overflow-y-auto overflow-x-hidden', className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function CommandEmpty({ className, ...props }: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="command-empty"
|
|
className={cn('px-4 py-8 text-center text-sm text-muted-foreground', className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface CommandGroupProps extends React.ComponentProps<'div'> {
|
|
heading?: string;
|
|
}
|
|
|
|
function CommandGroup({ className, heading, children, ...props }: CommandGroupProps) {
|
|
return (
|
|
<div
|
|
data-slot="command-group"
|
|
className={cn('px-2 py-2', className)}
|
|
{...props}
|
|
>
|
|
{heading && (
|
|
<div className="px-2 pb-2 text-xs uppercase tracking-wide text-muted-foreground">
|
|
{heading}
|
|
</div>
|
|
)}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CommandItem({ className, ...props }: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="command-item"
|
|
role="option"
|
|
className={cn(
|
|
'flex cursor-pointer select-none items-center gap-3 rounded-md px-2 py-2 text-sm outline-none hover:bg-accent',
|
|
className
|
|
)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function CommandSeparator({ className, ...props }: React.ComponentProps<'div'>) {
|
|
return (
|
|
<div
|
|
data-slot="command-separator"
|
|
className={cn('h-px bg-border', className)}
|
|
{...props}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export {
|
|
Command,
|
|
CommandDialog,
|
|
CommandInput,
|
|
CommandList,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandItem,
|
|
CommandSeparator,
|
|
};
|