Pick a suggestion
import * as React from "react";
import { LightBulbIcon, XMarkIcon } from "@heroicons-animated/react";
import {
Suggestion,
SuggestionList,
SuggestionPanel,
SuggestionPanelClose,
SuggestionPanelContent,
SuggestionPanelHeader,
SuggestionPanelTitle,
Suggestions,
} from "@/components/pandacoderz-ui/suggestions";
const prompts = [
"Summarize this document",
"Draft a launch email",
"Explain a regex",
"Write unit tests",
];
export default function SuggestionsDemo() {
const [selected, setSelected] = React.useState<string | null>(null);
const [open, setOpen] = React.useState(true);
return (
<div className="flex w-full max-w-xl flex-col items-center gap-6">
<Suggestions onSelect={setSelected}>
<SuggestionList>
{prompts.map((p) => (
<Suggestion key={p} highlight={["document", "email"]}>
{p}
</Suggestion>
))}
</SuggestionList>
</Suggestions>
<div className="relative w-full pt-8">
<SuggestionPanel open={open} onOpenChange={setOpen} className="static w-full">
<SuggestionPanelHeader>
<SuggestionPanelTitle className="text-sm text-muted-foreground">
<LightBulbIcon size={14} className="flex" />
Try asking
</SuggestionPanelTitle>
<SuggestionPanelClose>
<XMarkIcon size={16} className="flex" />
</SuggestionPanelClose>
</SuggestionPanelHeader>
<SuggestionPanelContent>
<Suggestions onSelect={setSelected}>
<SuggestionList orientation="vertical" className="px-1">
<Suggestion variant="ghost">What can you help me with?</Suggestion>
<Suggestion variant="ghost">Compare React and Astro</Suggestion>
</SuggestionList>
</Suggestions>
</SuggestionPanelContent>
</SuggestionPanel>
{!open ? (
<button
type="button"
className="text-sm text-muted-foreground underline underline-offset-2"
onClick={() => setOpen(true)}
>
Reopen panel
</button>
) : null}
</div>
<p className="text-sm text-muted-foreground">
{selected ? `Selected: ${selected}` : "Pick a suggestion"}
</p>
</div>
);
}Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/suggestions.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/suggestions.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/suggestions.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/suggestions.jsonInstall the dependencies:
npm install radix-ui @radix-ui/react-presence class-variance-authorityAdd the shadcn primitives it builds on:
npx shadcn@latest add buttonCopy the source into your project:
"use client";
import * as React from "react";
import { Presence } from "@radix-ui/react-presence";
import { Slot } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
const suggestionVariants = cva(
"h-8 gap-1.5 rounded-full px-4 text-sm font-normal shadow-none outline-0 transition-all duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.99]",
{
variants: {
variant: {
filled: "border-none bg-muted text-foreground hover:bg-border",
outline:
"border border-input bg-transparent text-foreground hover:bg-muted",
ghost:
"border-none bg-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
},
},
defaultVariants: {
variant: "filled",
},
},
);
type SuggestionsContextValue = {
onSelect?: (value: string) => void;
};
const SuggestionsContext = React.createContext<SuggestionsContextValue>({});
type SuggestionsProps = Omit<
React.HTMLAttributes<HTMLDivElement>,
"onSelect"
> & {
onSelect?: (value: string) => void;
};
function Suggestions({ className, onSelect, ...props }: SuggestionsProps) {
return (
<SuggestionsContext.Provider value={{ onSelect }}>
<div
data-slot="suggestions"
role="group"
aria-label="Suggestions"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
</SuggestionsContext.Provider>
);
}
type SuggestionListProps = React.HTMLAttributes<HTMLDivElement> & {
orientation?: "horizontal" | "vertical";
};
function SuggestionList({
className,
orientation = "horizontal",
...props
}: SuggestionListProps) {
return (
<div
data-slot="suggestion-list"
role="group"
aria-label="Suggestions"
className={cn(
"flex gap-2 duration-150",
orientation === "horizontal"
? "flex-row flex-wrap items-center justify-center"
: "flex-col items-start",
className,
)}
{...props}
/>
);
}
type SuggestionProps = Omit<React.ComponentProps<typeof Button>, "variant"> &
VariantProps<typeof suggestionVariants> & {
value?: string;
highlight?: string | string[];
};
function highlightText(
text: string,
terms: string | string[],
): React.ReactNode {
const termList = Array.isArray(terms) ? terms : [terms];
const escaped = termList.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const pattern = new RegExp(`(${escaped.join("|")})`, "gi");
const parts = text.split(pattern);
return (
<span>
{parts.map((part, i) =>
escaped.some((e) => new RegExp(`^${e}$`, "i").test(part)) ? (
<span key={i} className="text-muted-foreground">
{part}
</span>
) : (
<span key={i} className="text-secondary-foreground">
{part}
</span>
),
)}
</span>
);
}
function Suggestion({
className,
value,
variant = "filled",
highlight,
onClick,
children,
...props
}: SuggestionProps) {
const { onSelect } = React.useContext(SuggestionsContext);
const textToHighlight =
typeof children === "string" ? children : (value ?? "");
const nonStringChildren = React.Children.toArray(children).filter(
(c) => typeof c !== "string",
);
const rendered =
highlight && textToHighlight ? (
<>
{highlightText(textToHighlight, highlight)}
{nonStringChildren}
</>
) : (
children
);
return (
<Button
data-slot="suggestion"
className={cn(suggestionVariants({ variant }), className)}
onClick={(e) => {
onClick?.(e);
const text = value ?? (typeof children === "string" ? children : "");
if (text && onSelect) onSelect(text);
}}
{...props}
>
{rendered}
</Button>
);
}
const FOCUSABLE =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
function getFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE));
}
const SuggestionPanelContext = React.createContext<{
onOpenChange: (open: boolean) => void;
} | null>(null);
type SuggestionPanelProps = React.ComponentProps<"div"> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
onClose?: () => void;
};
function SuggestionPanel({
className,
open = true,
onOpenChange,
onClose,
ref,
children,
...props
}: SuggestionPanelProps) {
const panelRef = React.useRef<HTMLDivElement>(null);
const mergedRef = React.useMemo(
() => (node: HTMLDivElement | null) => {
(panelRef as React.RefObject<HTMLDivElement | null>).current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.RefObject<HTMLDivElement | null>).current = node;
},
[ref],
);
const handleOpenChange = React.useCallback(
(next: boolean) => {
onOpenChange?.(next);
},
[onOpenChange],
);
const handleAnimationEnd = React.useCallback(
(e: React.AnimationEvent) => {
if (e.animationName === "exit" && !open) onClose?.();
},
[open, onClose],
);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") handleOpenChange(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleOpenChange]);
React.useEffect(() => {
if (!open) return;
const panel = panelRef.current;
if (!panel) return;
const focusable = getFocusableElements(panel);
if (focusable.length > 0) focusable[0]?.focus();
}, [open]);
React.useEffect(() => {
const panel = panelRef.current;
if (!panel) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusable = getFocusableElements(panel);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey) {
if (active === first) {
e.preventDefault();
last?.focus();
}
} else if (active === last) {
e.preventDefault();
first?.focus();
}
};
panel.addEventListener("keydown", handleKeyDown);
return () => panel.removeEventListener("keydown", handleKeyDown);
}, []);
const ctx = React.useMemo(
() => ({ onOpenChange: handleOpenChange }),
[handleOpenChange],
);
return (
<Presence present={open}>
<div
ref={mergedRef}
data-slot="suggestion-panel"
role="dialog"
aria-modal="true"
aria-label="Suggestions panel"
data-state={open ? "open" : "closed"}
onAnimationEnd={handleAnimationEnd}
className={cn(
"rounded-t-0 absolute inset-x-0 -top-7.5 z-0 mx-auto flex w-[calc(100%-16px)] flex-col items-center justify-center gap-3 rounded-b-2xl bg-muted px-2 py-3 duration-200 data-[state=closed]:animate-out data-[state=closed]:duration-0 data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-2",
className,
)}
{...props}
>
<SuggestionPanelContext.Provider value={ctx}>
{children}
</SuggestionPanelContext.Provider>
</div>
</Presence>
);
}
function SuggestionPanelHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="suggestion-panel-header"
className={cn("flex w-full items-center justify-between px-3", className)}
{...props}
/>
);
}
function SuggestionPanelTitle({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="suggestion-panel-title"
className={cn("flex items-center gap-1.5", className)}
{...props}
/>
);
}
type SuggestionPanelCloseProps =
React.ButtonHTMLAttributes<HTMLButtonElement> & {
asChild?: boolean;
};
function SuggestionPanelClose({
asChild = false,
className,
onClick,
"aria-label": _ariaLabel,
...props
}: SuggestionPanelCloseProps) {
const ctx = React.useContext(SuggestionPanelContext);
const Comp = asChild ? Slot.Root : "button";
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
ctx?.onOpenChange(false);
onClick?.(e);
};
return (
<Comp
type={asChild ? undefined : "button"}
data-slot="suggestion-panel-close"
aria-label="Close suggestions panel"
className={cn(
"flex cursor-pointer items-center justify-center text-muted-foreground hover:text-foreground",
className,
)}
onClick={handleClick}
{...props}
/>
);
}
type SuggestionPanelContentProps = React.HTMLAttributes<HTMLDivElement> & {
asChild?: boolean;
};
function SuggestionPanelContent({
asChild = false,
className,
...props
}: SuggestionPanelContentProps) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
data-slot="suggestion-panel-content"
className={cn("w-full", className)}
{...props}
/>
);
}
export {
Suggestions,
SuggestionList,
Suggestion,
SuggestionPanel,
SuggestionPanelHeader,
SuggestionPanelTitle,
SuggestionPanelClose,
SuggestionPanelContent,
};Usage
import {
Suggestion,
SuggestionList,
Suggestions,
} from "@/components/pandacoderz-ui/suggestions";<Suggestions onSelect={(text) => send(text)}>
<SuggestionList>
<Suggestion>Summarize this document</Suggestion>
<Suggestion highlight="email">Draft a launch email</Suggestion>
</SuggestionList>
</Suggestions>Use SuggestionPanel for a panel that sits above the prompt input and can be dismissed with Escape or its close button.
API Reference
Suggestions
| Prop | Type | Description |
|---|---|---|
onSelect |
(value: string) => void |
Called with the suggestion’s value or text. |
Suggestion
| Prop | Type | Description |
|---|---|---|
variant |
"filled" | "outline" | "ghost" |
Visual style. Default filled. |
value |
string |
Value passed to onSelect. Defaults to the text content. |
highlight |
string | string[] |
Terms to render de-emphasized inside the label. |
SuggestionPanel
| Prop | Type | Description |
|---|---|---|
open |
boolean |
Controlled open state. |
onOpenChange |
(open: boolean) => void |
Called on close requests. |
onClose |
() => void |
Called after the exit animation finishes. |