import { useRef, type ComponentProps, type KeyboardEvent, type ReactNode } from "react"; import { ListFilterIcon, SearchIcon, XIcon } from "lucide-react"; import { Button } from "./button"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuMeta, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu"; import { cn } from "../lib/cn"; /** * The list toolbar: search on the left, filters and the page's action on the * right, in a row that keeps its shape when tabs or filters change. On narrow * widths (its own container, not the viewport) the search takes the first line * and everything else moves to a second line, still in the same order. */ export function Toolbar({ className, ...props }: ComponentProps<"div">) { return (
); } /** * Groups controls that belong together (a segmented filter and a view toggle, * or the page action). `align="end"` pins the group to the right edge, on * either line when the toolbar wraps. */ export function ToolbarGroup({ align = "start", className, ...props }: ComponentProps<"div"> & { align?: "start" | "end" }) { return (
); } /* ---------------------------------------------------------------------------- Search -------------------------------------------------------------------------- */ export type ToolbarSearchSize = "md" | "lg"; export interface ToolbarSearchProps extends Omit< ComponentProps<"input">, "size" | "value" | "onChange" | "type" > { value: string; onValueChange: (value: string) => void; /** Names what is searchable: "Search connections, skills, and plugins". */ placeholder: string; /** * `md` (36px, radius 10) sits in a row with 36px controls. `lg` (44px, * radius 14) is the page-level search on its own line, as on Capabilities. */ size?: ToolbarSearchSize | undefined; /** Classes for the field wrapper (width, flex basis). */ wrapperClassName?: string | undefined; } export function ToolbarSearch({ value, onValueChange, placeholder, size = "md", wrapperClassName, className, disabled, onKeyDown, "aria-label": ariaLabel, ...props }: ToolbarSearchProps) { const inputRef = useRef(null); const large = size === "lg"; const handleKeyDown = (event: KeyboardEvent) => { onKeyDown?.(event); if (event.defaultPrevented) return; if (event.key === "Escape" && value) { event.preventDefault(); onValueChange(""); } }; return (
); } /* ---------------------------------------------------------------------------- Filter menu: one "Filter" button with checkable items, grouped. -------------------------------------------------------------------------- */ export interface ToolbarFilterOption { id: string; label: string; /** Optional count shown right-aligned in the menu. */ count?: number | undefined; } export interface ToolbarFilterGroup { id: string; /** Sentence case: "Type", "Status". */ label: string; options: ToolbarFilterOption[]; } /** Selected option ids per group id. An empty or missing list means "any". */ export type ToolbarFilterValue = Record; export function countActiveFilters(value: ToolbarFilterValue): number { return Object.values(value).reduce((total, ids) => total + ids.length, 0); } export function toggleFilter( value: ToolbarFilterValue, groupId: string, optionId: string, checked: boolean, ): ToolbarFilterValue { const current = value[groupId] ?? []; const next = checked ? current.includes(optionId) ? current : [...current, optionId] : current.filter((id) => id !== optionId); return { ...value, [groupId]: next }; } // The highlighted item is a surface-2 fill, like the other menus; the global // focus outline would draw a second ring on it. export interface ToolbarFilterMenuProps { groups: ToolbarFilterGroup[]; value: ToolbarFilterValue; onValueChange: (value: ToolbarFilterValue) => void; /** Button label. Default "Filter". */ label?: string | undefined; disabled?: boolean | undefined; align?: "start" | "end" | undefined; className?: string | undefined; } export function ToolbarFilterMenu({ groups, value, onValueChange, label = "Filter", disabled, align = "end", className, }: ToolbarFilterMenuProps) { const active = countActiveFilters(value); return ( {groups.map((group, index) => ( {index > 0 ? : null} {group.label} {group.options.map((option) => { const checked = (value[group.id] ?? []).includes(option.id); return ( event.preventDefault()} onCheckedChange={(next) => onValueChange(toggleFilter(value, group.id, option.id, next === true)) } > {option.label} {option.count === undefined ? null : ( {option.count} )} ); })} ))} {active > 0 ? ( <> onValueChange({})}>Clear filters ) : null} ); } /** * The applied filters as removable chips, shown next to the Filter button so * the active state is never hidden inside a closed menu. */ export function ToolbarFilterChips({ groups, value, onValueChange, className, }: { groups: ToolbarFilterGroup[]; value: ToolbarFilterValue; onValueChange: (value: ToolbarFilterValue) => void; className?: string | undefined; }) { const chips: Array<{ group: ToolbarFilterGroup; option: ToolbarFilterOption }> = []; for (const group of groups) { for (const option of group.options) { if ((value[group.id] ?? []).includes(option.id)) chips.push({ group, option }); } } if (chips.length === 0) return null; return (
    {chips.map(({ group, option }) => (
  • {group.label}: {option.label}
  • ))}
); } /** A quiet result line under the toolbar: "3 of 12 connections". */ export function ToolbarSummary({ className, children, }: { className?: string | undefined; children: ReactNode; }) { return (

{children}

); }