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 (
onValueChange(event.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
aria-label={ariaLabel ?? placeholder}
disabled={disabled}
autoComplete="off"
spellCheck={false}
className={cn(
"h-full w-full min-w-0 appearance-none border-0 bg-transparent text-sm text-ellipsis text-fg outline-hidden! placeholder:text-fg-subtle disabled:cursor-not-allowed [&::-webkit-search-cancel-button]:appearance-none",
// Room for the clear button only while there is something to clear,
// so long placeholders keep their words on narrow screens.
large ? "pl-11" : "pl-9",
value && !disabled ? (large ? "pr-11" : "pr-9") : large ? "pr-4" : "pr-3",
className,
)}
{...props}
/>
{value && !disabled ? (
) : null}
);
}
/* ----------------------------------------------------------------------------
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 (