AI Suggested Prompts
AI Suggested Prompts é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.
HextaUI
MIT
other
O que é
AI Suggested Prompts é um componente para React e Tailwind CSS, da biblioteca HextaUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Não precisa de pacotes além do que o projeto já tem
Licença
Este componente vem de HextaUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
AI Suggested Prompts
"use client";
import {
Code,
FileText,
Lightbulb,
Search,
Sparkles,
TrendingUp,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Badge } from "@/registry/new-york/ui/badge";
import { Button } from "@/registry/new-york/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york/ui/card";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import { Separator } from "@/registry/new-york/ui/separator";
export interface SuggestedPrompt {
id: string;
title: string;
prompt: string;
category: string;
icon?: React.ComponentType<{ className?: string }>;
description?: string;
isRecent?: boolean;
isPopular?: boolean;
usageCount?: number;
}
export interface AISuggestedPromptsProps {
prompts?: SuggestedPrompt[];
categories?: string[];
onSelect?: (prompt: SuggestedPrompt) => void;
onSearch?: (query: string) => void;
className?: string;
showSearch?: boolean;
showCategories?: boolean;
maxDisplay?: number;
isLoading?: boolean;
}
const defaultCategories = [
"All",
"Writing",
"Code",
"Analysis",
"Creative",
"Research",
];
const categoryIcons: Record<
string,
React.ComponentType<{ className?: string }>
> = {
Writing: FileText,
Code,
Analysis: TrendingUp,
Creative: Sparkles,
Research: Lightbulb,
};
interface ComponentHeaderProps {
title: string;
description: string;
}
function ComponentHeader({ title, description }: ComponentHeaderProps) {
return (
<div className="flex flex-col gap-1">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
);
}
interface PromptSearchProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
resultCount?: number;
}
function PromptSearch({
value,
onChange,
placeholder = "Search prompts…",
resultCount,
}: PromptSearchProps) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
(e.metaKey || e.ctrlKey) &&
e.key === "k" &&
!e.shiftKey &&
document.activeElement !== inputRef.current
) {
e.preventDefault();
inputRef.current?.focus();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
const handleClear = useCallback(() => {
onChange("");
inputRef.current?.focus();
}, [onChange]);
return (
<div className="relative">
<InputGroup>
<InputGroupAddon>
<Search aria-hidden="true" className="size-4" />
</InputGroupAddon>
<InputGroupInput
aria-label="Search prompts"
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
handleClear();
}
}}
placeholder={placeholder}
ref={inputRef}
type="search"
value={value}
/>
</InputGroup>
{resultCount !== undefined && value && (
<div aria-live="polite" className="sr-only" role="status">
{resultCount} result{resultCount !== 1 ? "s" : ""} found
</div>
)}
</div>
);
}
interface CategoryFilterProps {
categories: string[];
selectedCategory: string;
onSelect: (category: string) => void;
}
function CategoryFilter({
categories,
selectedCategory,
onSelect,
}: CategoryFilterProps) {
return (
<div
aria-label="Filter prompts by category"
className="flex flex-wrap gap-2"
role="tablist"
>
{categories.map((category) => {
const Icon = categoryIcons[category];
const isSelected = selectedCategory === category;
return (
<Button
aria-label={`Filter by ${category}`}
aria-selected={isSelected}
className={cn(
"min-h-[32px] gap-2 px-3 py-1.5 sm:h-auto sm:min-h-[10px]",
isSelected
? "border border-border bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80"
)}
key={category}
onClick={() => onSelect(category)}
type="button"
variant="ghost"
>
{Icon && <Icon aria-hidden="true" className="size-3.5" />}
<span className="text-xs">{category}</span>
</Button>
);
})}
</div>
);
}
interface EmptyStateProps {
type: "no-prompts" | "no-results";
searchQuery?: string;
}
function EmptyState({ type, searchQuery }: EmptyStateProps) {
if (type === "no-prompts") {
return (
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Lightbulb
aria-hidden="true"
className="size-6 text-muted-foreground"
/>
</div>
<div className="flex flex-col gap-2">
<p className="font-medium text-sm">No prompts available</p>
<p className="text-muted-foreground text-sm">
Prompts will appear here when they become available
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Search aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex flex-col gap-2">
<p className="font-medium text-sm">No prompts match your search</p>
<p className="text-muted-foreground text-sm">
Try adjusting your search terms or browse different categories
</p>
{searchQuery && (
<p className="text-muted-foreground text-xs">
Searched for: "{searchQuery}"
</p>
)}
</div>
</div>
);
}
interface PromptItemProps {
prompt: SuggestedPrompt;
isFocused: boolean;
index: number;
onSelect: (prompt: SuggestedPrompt) => void;
itemRef: (node: HTMLButtonElement | null) => void;
}
function PromptItem({
prompt,
isFocused,
index,
onSelect,
itemRef,
}: PromptItemProps) {
const Icon = prompt.icon || categoryIcons[prompt.category] || Lightbulb;
const handleSelect = useCallback(() => {
onSelect(prompt);
}, [onSelect, prompt]);
return (
<button
aria-label={`Use prompt: ${prompt.title}`}
className={cn(
"group flex min-h-[44px] flex-col gap-2 rounded-lg border bg-card p-4 text-left transition-colors",
"hover:border-primary hover:bg-primary/5",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isFocused && "border-primary bg-primary/5"
)}
data-prompt-index={index}
onClick={handleSelect}
ref={itemRef}
type="button"
>
<div className="flex items-start gap-3">
<div
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"
>
<Icon className="size-4" />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-medium text-sm">{prompt.title}</h3>
<div className="flex shrink-0 gap-1">
{prompt.isPopular && (
<Badge className="text-xs" variant="default">
Popular
</Badge>
)}
{prompt.isRecent && (
<Badge className="text-xs" variant="secondary">
Recent
</Badge>
)}
</div>
</div>
{prompt.description && (
<p className="line-clamp-2 text-muted-foreground text-xs">
{prompt.description}
</p>
)}
{prompt.usageCount !== undefined && (
<p className="text-muted-foreground text-xs">
Used {prompt.usageCount} time{prompt.usageCount !== 1 ? "s" : ""}
</p>
)}
</div>
</div>
{prompt.prompt && (
<>
<Separator className="my-1" />
<p className="line-clamp-2 text-muted-foreground text-xs italic">
"{prompt.prompt}"
</p>
</>
)}
</button>
);
}
interface PromptListProps {
prompts: SuggestedPrompt[];
focusedIndex: number | null;
onSelect: (prompt: SuggestedPrompt) => void;
setFocusedIndex: (index: number | null) => void;
}
function PromptList({
prompts,
focusedIndex,
onSelect,
setFocusedIndex,
}: PromptListProps) {
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const setItemRef = useCallback(
(index: number) => (node: HTMLButtonElement | null) => {
itemRefs.current[index] = node;
},
[]
);
useEffect(() => {
if (focusedIndex !== null && itemRefs.current[focusedIndex]) {
itemRefs.current[focusedIndex]?.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [focusedIndex]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLButtonElement
) {
if (target.type === "button" && target.closest('[role="tablist"]')) {
return;
}
if (target.type === "search" || target.type === "text") {
return;
}
}
if (prompts.length === 0) return;
switch (e.key) {
case "ArrowDown": {
e.preventDefault();
const nextIndex =
focusedIndex === null
? 0
: focusedIndex < prompts.length - 1
? focusedIndex + 1
: 0;
setFocusedIndex(nextIndex);
itemRefs.current[nextIndex]?.focus();
break;
}
case "ArrowUp": {
e.preventDefault();
const prevIndex =
focusedIndex === null
? prompts.length - 1
: focusedIndex > 0
? focusedIndex - 1
: prompts.length - 1;
setFocusedIndex(prevIndex);
itemRefs.current[prevIndex]?.focus();
break;
}
case "Home": {
e.preventDefault();
setFocusedIndex(0);
itemRefs.current[0]?.focus();
break;
}
case "End": {
e.preventDefault();
const lastIndex = prompts.length - 1;
setFocusedIndex(lastIndex);
itemRefs.current[lastIndex]?.focus();
break;
}
case "Escape": {
if (focusedIndex !== null) {
e.preventDefault();
setFocusedIndex(null);
containerRef.current?.focus();
}
break;
}
case "Enter": {
if (focusedIndex !== null && itemRefs.current[focusedIndex]) {
e.preventDefault();
onSelect(prompts[focusedIndex]);
}
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [prompts, focusedIndex, setFocusedIndex, onSelect]);
return (
<div
aria-label="Suggested prompts list"
className="grid gap-3"
ref={containerRef}
role="list"
tabIndex={-1}
>
{prompts.map((prompt, index) => (
<PromptItem
index={index}
isFocused={focusedIndex === index}
itemRef={setItemRef(index)}
key={prompt.id}
onSelect={onSelect}
prompt={prompt}
/>
))}
</div>
);
}
export default function AISuggestedPrompts({
prompts = [],
categories = defaultCategories,
onSelect,
onSearch,
className,
showSearch = true,
showCategories = true,
maxDisplay,
isLoading = false,
}: AISuggestedPromptsProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("All");
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const filteredPrompts = useMemo(() => {
let filtered = prompts;
if (selectedCategory !== "All") {
filtered = filtered.filter((p) => p.category === selectedCategory);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(
(p) =>
p.title.toLowerCase().includes(query) ||
p.prompt.toLowerCase().includes(query) ||
p.description?.toLowerCase().includes(query)
);
}
const sorted = [...filtered].sort((a, b) => {
if (a.isPopular && !b.isPopular) return -1;
if (!a.isPopular && b.isPopular) return 1;
if (a.isRecent && !b.isRecent) return -1;
if (!a.isRecent && b.isRecent) return 1;
return 0;
});
return maxDisplay ? sorted.slice(0, maxDisplay) : sorted;
}, [prompts, selectedCategory, searchQuery, maxDisplay]);
const handleSelect = useCallback(
(prompt: SuggestedPrompt) => {
onSelect?.(prompt);
setFocusedIndex(null);
},
[onSelect]
);
const handleSearchChange = useCallback(
(value: string) => {
setSearchQuery(value);
onSearch?.(value);
setFocusedIndex(null);
},
[onSearch]
);
const handleCategoryChange = useCallback((category: string) => {
setSelectedCategory(category);
setFocusedIndex(null);
}, []);
if (prompts.length === 0 && !isLoading) {
return (
<Card className={cn("w-full shadow-xs", className)}>
<CardHeader>
<ComponentHeader
description="Get started with these helpful prompts"
title="Suggested Prompts"
/>
</CardHeader>
<CardContent>
<EmptyState type="no-prompts" />
</CardContent>
</Card>
);
}
return (
<Card className={cn("w-full shadow-xs", className)}>
<CardHeader>
<div className="flex flex-col gap-4">
<ComponentHeader
description="Get started with these helpful prompts"
title="Suggested Prompts"
/>
{showSearch && (
<PromptSearch
onChange={handleSearchChange}
placeholder="Search prompts…"
resultCount={filteredPrompts.length}
value={searchQuery}
/>
)}
{showCategories && (
<CategoryFilter
categories={categories}
onSelect={handleCategoryChange}
selectedCategory={selectedCategory}
/>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Lightbulb
aria-hidden="true"
className="size-6 animate-pulse text-muted-foreground"
/>
</div>
<p className="text-muted-foreground text-sm">Loading prompts…</p>
</div>
) : filteredPrompts.length === 0 ? (
<EmptyState searchQuery={searchQuery} type="no-results" />
) : (
<PromptList
focusedIndex={focusedIndex}
onSelect={handleSelect}
prompts={filteredPrompts}
setFocusedIndex={setFocusedIndex}
/>
)}
</CardContent>
</Card>
);
}