import * as React from "react"; import { PlusIcon, SearchIcon, XIcon } from "lucide-react"; import { Collapsible as CollapsiblePrimitive, Slot } from "radix-ui"; import { cn } from "../lib/utils"; import { Button } from "./button"; import { Input } from "./input"; import { Label } from "./label"; type CardStackContextValue = { collapsible: boolean; searchable: boolean; searchQuery: string; setSearchQuery: (query: string) => void; }; const CardStackContext = React.createContext({ collapsible: false, searchable: false, searchQuery: "", setSearchQuery: () => {}, }); type CardStackProps = React.ComponentProps<"div"> & { collapsible?: boolean; defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; /** * When true, renders a compact search input in the header and filters * entries whose `searchText` prop does not match the query. */ searchable?: boolean; searchQuery?: string; defaultSearchQuery?: string; onSearchChange?: (query: string) => void; }; function CardStack({ className, collapsible = false, defaultOpen, open, onOpenChange, searchable = false, searchQuery: searchQueryProp, defaultSearchQuery = "", onSearchChange, ...props }: CardStackProps) { const [uncontrolledQuery, setUncontrolledQuery] = React.useState(defaultSearchQuery); const searchQuery = searchQueryProp ?? uncontrolledQuery; const setSearchQuery = React.useCallback( (query: string) => { if (searchQueryProp === undefined) setUncontrolledQuery(query); onSearchChange?.(query); }, [searchQueryProp, onSearchChange], ); const contextValue = React.useMemo( () => ({ collapsible, searchable, searchQuery, setSearchQuery }), [collapsible, searchable, searchQuery, setSearchQuery], ); const card = (
); if (collapsible) { return ( {card} ); } return {card}; } function CardStackSearchInput() { const { searchQuery, setSearchQuery } = React.useContext(CardStackContext); return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
event.stopPropagation()} > setSearchQuery(event.target.value)} placeholder="Search…" aria-label="Search entries" className="h-5 w-32 rounded-none border-0 bg-transparent p-0 text-xs text-foreground shadow-none outline-none placeholder:text-muted-foreground focus-visible:border-0 focus-visible:ring-0 md:text-xs dark:bg-transparent" /> {searchQuery && ( )}
); } type CardStackHeaderProps = React.HTMLAttributes & { /** * Content rendered on the right side of the header, after the title and * (optional) search input. Useful for action buttons like "Add Header". */ rightSlot?: React.ReactNode; }; function CardStackHeader({ className, children, rightSlot, ...props }: CardStackHeaderProps) { const { collapsible, searchable } = React.useContext(CardStackContext); const title = {children}; if (collapsible) { return ( {title} {searchable && } {rightSlot} ); } return (
{title} {searchable && } {rightSlot}
); } function CardStackHeaderAction({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardStackContent({ className, ...props }: React.ComponentProps<"div">) { const { collapsible } = React.useContext(CardStackContext); if (collapsible) { return (
*+*]:relative [&>*+*]:before:pointer-events-none [&>*+*]:before:absolute [&>*+*]:before:inset-x-0 [&>*+*]:before:top-0 [&>*+*]:before:h-px [&>*+*]:before:bg-border/50", className, )} {...props} /> ); } return (
*+*]:relative [&>*+*]:before:pointer-events-none [&>*+*]:before:absolute [&>*+*]:before:inset-x-0 [&>*+*]:before:top-0 [&>*+*]:before:h-px [&>*+*]:before:bg-border/50", className, )} {...props} /> ); } type CardStackEntryProps = React.ComponentProps<"div"> & { asChild?: boolean; /** * Text used to match against the parent `CardStack`'s search query when * `searchable` is enabled. When omitted, the entry is always shown. */ searchText?: string; }; function CardStackEntry({ className, asChild = false, searchText, ...props }: CardStackEntryProps) { const { searchable, searchQuery } = React.useContext(CardStackContext); if (searchable && searchText !== undefined) { const trimmed = searchQuery.trim().toLowerCase(); if (trimmed.length > 0 && !searchText.toLowerCase().includes(trimmed)) { return null; } } const Comp = asChild ? Slot.Root : "div"; return ( ); } type CardStackEntryFieldProps = React.ComponentProps<"div"> & { label?: React.ReactNode; description?: React.ReactNode; hint?: React.ReactNode; labelAction?: React.ReactNode; }; /** * Form-field variant of `CardStackEntry` — stacks a label, form control, and * optional hint vertically. Use inside a `CardStack` to render consistent * bordered form fields. Consumers pass the form control (Input, Textarea, * etc.) as children. */ function CardStackEntryField({ className, label, description, hint, labelAction, children, ...props }: CardStackEntryFieldProps) { return (
{(label || labelAction) && (
{label && ( )} {labelAction}
)} {children} {hint &&

{hint}

}
); } function CardStackEntryMedia({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardStackEntryContent({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardStackEntryTitle({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardStackEntryDescription({ className, ...props }: React.ComponentProps<"p">) { return (

); } function CardStackEntryActions({ className, ...props }: React.ComponentProps<"div">) { return (

); } function CardStackEmpty({ className, ...props }: React.ComponentProps<"div">) { return (
); } export { CardStack, CardStackHeader, CardStackHeaderAction, CardStackContent, CardStackEntry, CardStackEntryField, CardStackEntryMedia, CardStackEntryContent, CardStackEntryTitle, CardStackEntryDescription, CardStackEntryActions, CardStackEmpty, };