feat: add command and combobox components

This commit is contained in:
2026-03-19 18:16:50 +08:00
parent b7d17383bf
commit 71ebb010b9
12 changed files with 1318 additions and 0 deletions
+1
View File
@@ -34,6 +34,7 @@
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"motion": "^12.38.0",
"react-hook-form": "^7.71.2",
"tailwind-merge": "^3.5.0"
@@ -0,0 +1,164 @@
import { useState } from "react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Controller, useForm } from "react-hook-form";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
import { Combobox, type ComboboxItem } from "./combobox";
import {
Form,
FormControl,
FormDescription,
FormItem,
FormLabel,
FormMessage
} from "./form";
const reviewLaneItems: ComboboxItem[] = [
{
value: "editorial",
label: "Editorial review",
group: "Core lanes",
keywords: ["content", "writing"]
},
{
value: "design",
label: "Design review",
group: "Core lanes",
keywords: ["ui", "visual"]
},
{
value: "legal",
label: "Legal review",
description: "Required for policy and compliance changes.",
group: "Specialist lanes",
keywords: ["policy", "compliance"]
}
];
describe("Combobox", () => {
it("renders a selected value, filters options, and updates uncontrolled state", async () => {
const user = userEvent.setup();
render(
<Combobox
aria-label="Review lane"
defaultValue="design"
items={reviewLaneItems}
searchPlaceholder="Search lanes"
/>
);
const trigger = screen.getByRole("combobox", { name: "Review lane" });
expect(trigger).toHaveTextContent("Design review");
expect(trigger).toHaveAttribute("data-slot", "trigger");
await user.click(trigger);
const searchbox = screen.getByRole("searchbox", { name: "Search options" });
await user.type(searchbox, "legal");
const listbox = screen.getByRole("listbox");
const option = within(listbox).getByRole("option", { name: /Legal review/i });
expect(listbox).toHaveAttribute("data-slot", "list");
expect(screen.getByText("Specialist lanes")).toHaveAttribute("data-slot", "label");
await user.click(option);
expect(trigger).toHaveTextContent("Legal review");
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
});
});
it("supports a controlled value and reports updates", async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
function ControlledCombobox() {
const [value, setValue] = useState("editorial");
return (
<Combobox
aria-label="Controlled review lane"
items={reviewLaneItems}
onValueChange={(nextValue) => {
onValueChange(nextValue);
setValue(nextValue);
}}
value={value}
/>
);
}
render(<ControlledCombobox />);
const trigger = screen.getByRole("combobox", { name: "Controlled review lane" });
expect(trigger).toHaveTextContent("Editorial review");
await user.click(trigger);
await user.click(await screen.findByRole("option", { name: /Legal review/i }));
expect(onValueChange).toHaveBeenLastCalledWith("legal");
expect(trigger).toHaveTextContent("Legal review");
});
it("works with FormControl invalid and described-by wiring", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
function ExampleForm() {
const form = useForm<{ lane: string }>({
defaultValues: {
lane: ""
}
});
return (
<Form {...form}>
<form noValidate onSubmit={form.handleSubmit((values) => onSubmit(values))}>
<Controller
control={form.control}
name="lane"
rules={{
required: "Choose a review lane."
}}
render={({ field }) => (
<FormItem name="lane">
<FormLabel>Review lane</FormLabel>
<FormControl>
<Combobox
aria-label="Review lane"
items={reviewLaneItems}
onValueChange={field.onChange}
value={field.value}
/>
</FormControl>
<FormDescription>Select the primary reviewer before publishing.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
}
render(<ExampleForm />);
await user.click(screen.getByRole("button", { name: "Submit" }));
const trigger = screen.getByRole("combobox", { name: "Review lane" });
const message = await screen.findByText("Choose a review lane.");
expect(onSubmit).not.toHaveBeenCalled();
expect(trigger).toHaveAttribute("aria-invalid", "true");
expect(trigger).toHaveAttribute("aria-describedby", expect.stringContaining(message.id));
expect(trigger.closest("[data-slot='root']")).toHaveAttribute("data-invalid", "");
});
});
+426
View File
@@ -0,0 +1,426 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import {
forwardRef,
useEffect,
useId,
useMemo,
useRef,
useState,
type ComponentPropsWithoutRef,
type KeyboardEvent
} from "react";
import {
comboboxContentVariants,
comboboxEmptyVariants,
comboboxGroupVariants,
comboboxItemVariants,
comboboxLabelVariants,
comboboxListVariants,
comboboxSearchVariants,
comboboxTriggerVariants
} from "./combobox.variants";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { useFieldContext } from "./field";
function mergeIds(...ids: Array<string | undefined>) {
const value = ids.filter(Boolean).join(" ").trim();
return value.length > 0 ? value : undefined;
}
function getNextEnabledIndex(
items: ComboboxItem[],
currentIndex: number,
direction: 1 | -1
) {
if (items.length === 0) {
return -1;
}
let nextIndex = currentIndex;
for (let attempt = 0; attempt < items.length; attempt += 1) {
nextIndex = (nextIndex + direction + items.length) % items.length;
if (!items[nextIndex]?.disabled) {
return nextIndex;
}
}
return currentIndex;
}
export type ComboboxItem = {
description?: string;
disabled?: boolean;
group?: string;
keywords?: readonly string[];
label: string;
value: string;
};
export type ComboboxProps = Omit<
ComponentPropsWithoutRef<"button">,
"children" | "defaultValue" | "onChange" | "value"
> & {
defaultOpen?: boolean;
defaultSearchValue?: string;
defaultValue?: string;
emptyMessage?: string;
invalid?: boolean;
items: ComboboxItem[];
onOpenChange?: (open: boolean) => void;
onSearchValueChange?: (value: string) => void;
onValueChange?: (value: string) => void;
open?: boolean;
placeholder?: string;
searchPlaceholder?: string;
searchValue?: string;
value?: string;
};
export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Combobox(
{
className,
defaultOpen = false,
defaultSearchValue = "",
defaultValue,
disabled,
emptyMessage = "No matching results.",
id,
invalid,
items,
onOpenChange,
onSearchValueChange,
onValueChange,
open,
placeholder = "Select an option",
searchPlaceholder = "Search…",
searchValue,
value,
...props
},
ref
) {
const field = useFieldContext();
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
const [uncontrolledSearchValue, setUncontrolledSearchValue] = useState(defaultSearchValue);
const searchRef = useRef<HTMLInputElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [activeIndex, setActiveIndex] = useState(-1);
const resolvedOpen = open ?? uncontrolledOpen;
const resolvedValue = value ?? uncontrolledValue;
const resolvedSearchValue = searchValue ?? uncontrolledSearchValue;
const resolvedDisabled = disabled ?? field?.disabled ?? false;
const resolvedInvalid = invalid ?? field?.invalid ?? false;
const controlId = id ?? props.name ?? field?.inputId ?? `combobox-${useId().replace(/:/g, "")}`;
const describedBy = mergeIds(
typeof props["aria-describedby"] === "string" ? props["aria-describedby"] : undefined,
field?.descriptionId,
resolvedInvalid ? field?.errorId : undefined
);
const selectedItem = useMemo(
() => items.find((item) => item.value === resolvedValue),
[items, resolvedValue]
);
const filteredItems = useMemo(() => {
const query = resolvedSearchValue.trim().toLowerCase();
if (!query) {
return items;
}
return items.filter((item) => {
const haystack = [item.label, item.value, ...(item.keywords ?? [])]
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}, [items, resolvedSearchValue]);
const groupedItems = useMemo(() => {
const groups = new Map<string, ComboboxItem[]>();
filteredItems.forEach((item) => {
const key = item.group ?? "";
const entry = groups.get(key);
if (entry) {
entry.push(item);
return;
}
groups.set(key, [item]);
});
return [...groups.entries()];
}, [filteredItems]);
const setOpenState = (nextOpen: boolean) => {
if (open === undefined) {
setUncontrolledOpen(nextOpen);
}
if (!nextOpen) {
if (searchValue === undefined) {
setUncontrolledSearchValue("");
}
setActiveIndex(-1);
}
onOpenChange?.(nextOpen);
};
const setSearchState = (nextValue: string) => {
if (searchValue === undefined) {
setUncontrolledSearchValue(nextValue);
}
onSearchValueChange?.(nextValue);
};
const setValueState = (nextValue: string) => {
if (value === undefined) {
setUncontrolledValue(nextValue);
}
onValueChange?.(nextValue);
};
useEffect(() => {
if (!resolvedOpen) {
return;
}
requestAnimationFrame(() => {
searchRef.current?.focus();
});
}, [resolvedOpen]);
useEffect(() => {
if (!resolvedOpen || filteredItems.length === 0) {
return;
}
const selectedIndex = filteredItems.findIndex((item) => item.value === resolvedValue && !item.disabled);
const firstEnabledIndex = filteredItems.findIndex((item) => !item.disabled);
const nextIndex = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex;
setActiveIndex(nextIndex);
}, [filteredItems, resolvedOpen, resolvedValue]);
useEffect(() => {
if (activeIndex < 0) {
return;
}
itemRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
const handleSelect = (item: ComboboxItem) => {
if (item.disabled) {
return;
}
setValueState(item.value);
setOpenState(false);
};
const handleTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (resolvedDisabled) {
return;
}
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
setOpenState(true);
}
};
const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((current) => getNextEnabledIndex(filteredItems, current < 0 ? -1 : current, 1));
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((current) => getNextEnabledIndex(filteredItems, current < 0 ? 0 : current, -1));
return;
}
if (event.key === "Enter") {
const activeItem = filteredItems[activeIndex];
if (activeItem) {
event.preventDefault();
handleSelect(activeItem);
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
setOpenState(false);
}
};
itemRefs.current = [];
return (
<div
{...createSlot("root")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
open: resolvedOpen,
value: resolvedValue || undefined
})}
className="grid gap-2"
>
<PopoverPrimitive.Root onOpenChange={setOpenState} open={resolvedOpen}>
<PopoverPrimitive.Trigger asChild>
<button
{...props}
{...createSlot("trigger")}
{...createDataAttributes({
disabled: resolvedDisabled,
invalid: resolvedInvalid,
placeholder: selectedItem ? undefined : true
})}
aria-controls={`${controlId}-listbox`}
aria-describedby={describedBy}
aria-expanded={resolvedOpen}
aria-invalid={resolvedInvalid || undefined}
className={cn(comboboxTriggerVariants(), className)}
data-state={resolvedOpen ? "open" : "closed"}
disabled={resolvedDisabled}
id={controlId}
onKeyDown={handleTriggerKeyDown}
ref={ref}
role="combobox"
type="button"
>
<span {...createSlot("label")} className="truncate">
{selectedItem ? selectedItem.label : placeholder}
</span>
<span
{...createSlot("icon")}
aria-hidden="true"
className="shrink-0 text-[var(--color-muted-foreground)]"
>
</span>
</button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
{...createSlot("content")}
className={comboboxContentVariants()}
sideOffset={8}
>
<div className="border-b border-[var(--color-border)] px-1.5 py-1.5">
<input
{...createSlot("input")}
aria-label="Search options"
className={comboboxSearchVariants()}
onChange={(event) => {
setSearchState(event.target.value);
}}
onKeyDown={handleSearchKeyDown}
placeholder={searchPlaceholder}
ref={searchRef}
role="searchbox"
value={resolvedSearchValue}
/>
</div>
{filteredItems.length === 0 ? (
<div {...createSlot("empty")} className={comboboxEmptyVariants()}>
{emptyMessage}
</div>
) : (
<div
{...createSlot("list")}
className={comboboxListVariants()}
id={`${controlId}-listbox`}
role="listbox"
>
{groupedItems.map(([group, groupItems]) => (
<div
key={group || "default"}
{...createSlot("group")}
className={comboboxGroupVariants()}
>
{group ? (
<div {...createSlot("label")} className={comboboxLabelVariants()}>
{group}
</div>
) : null}
{groupItems.map((item) => {
const itemIndex = filteredItems.findIndex((entry) => entry.value === item.value);
const isSelected = item.value === resolvedValue;
const isActive = itemIndex === activeIndex;
return (
<button
key={item.value}
{...createSlot("item")}
{...createDataAttributes({
active: isActive,
disabled: item.disabled,
selected: isSelected
})}
aria-selected={isSelected}
className={comboboxItemVariants()}
onClick={() => {
handleSelect(item);
}}
onMouseEnter={() => {
setActiveIndex(itemIndex);
}}
ref={(node) => {
itemRefs.current[itemIndex] = node;
}}
role="option"
type="button"
>
<span
{...createSlot("icon")}
aria-hidden="true"
className={cn(
"mt-0.5 inline-flex size-4 shrink-0 items-center justify-center text-xs text-[var(--color-primary)]",
!isSelected && "opacity-0"
)}
>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{item.label}</span>
{item.description ? (
<span className="mt-0.5 block text-xs leading-5 text-[var(--color-muted-foreground)]">
{item.description}
</span>
) : null}
</span>
</button>
);
})}
</div>
))}
</div>
)}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
{props.name ? <input name={props.name} type="hidden" value={resolvedValue} /> : null}
</div>
);
});
@@ -0,0 +1,46 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const comboboxTriggerVariants = cva(
[
"inline-flex h-11 w-full items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--color-input)] bg-[var(--color-card)] px-4 text-left text-sm text-[var(--color-foreground)] shadow-[var(--shadow-xs)] outline-none",
"focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"data-[placeholder]:text-[var(--color-muted-foreground)] data-[disabled]:cursor-not-allowed data-[disabled]:bg-[var(--color-surface)] data-[disabled]:text-[var(--color-muted-foreground)] data-[disabled]:opacity-100",
"aria-[invalid=true]:border-[color-mix(in_oklch,var(--color-destructive)_42%,var(--color-border-strong))]",
"aria-[invalid=true]:shadow-[0_0_0_1px_color-mix(in_oklch,var(--color-destructive)_28%,transparent)]",
getMotionRecipeClassNames("ring")
]
);
export const comboboxContentVariants = cva([
"relative z-50 min-w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-card)] p-0 text-[var(--color-card-foreground)] shadow-[var(--shadow-md)] outline-none",
"data-[state=open]:motion-enter-rise data-[state=closed]:motion-exit-drop"
]);
export const comboboxSearchVariants = cva([
"h-11 w-full border-b border-[var(--color-border)] bg-transparent px-3.5 text-sm text-[var(--color-foreground)] outline-none",
"placeholder:text-[var(--color-muted-foreground)]"
]);
export const comboboxListVariants = cva([
"max-h-[18rem] overflow-y-auto p-1"
]);
export const comboboxGroupVariants = cva([
"grid gap-1"
]);
export const comboboxLabelVariants = cva([
"px-2.5 py-2 text-xs font-medium uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
]);
export const comboboxItemVariants = cva([
"relative flex w-full cursor-default select-none items-start gap-3 rounded-[calc(var(--radius-sm)-4px)] px-3 py-2 text-left text-sm text-[var(--color-foreground)] outline-none",
"transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"data-[active=true]:bg-[var(--color-surface)] data-[selected=true]:bg-[color-mix(in_oklch,var(--color-primary)_10%,var(--color-card))]",
"data-[selected=true]:text-[var(--color-foreground)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45"
]);
export const comboboxEmptyVariants = cva([
"px-3 py-6 text-center text-sm text-[var(--color-muted-foreground)]"
]);
@@ -0,0 +1,99 @@
import { useState } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut
} from "./command";
describe("Command", () => {
it("renders root, input, list, item, and shortcut slots", async () => {
const user = userEvent.setup();
render(
<Command label="Quick actions">
<CommandInput placeholder="Search actions" />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup heading="Actions">
<CommandItem value="launch-release">Launch release</CommandItem>
<CommandItem value="open-legal">
Open legal review
<CommandShortcut>G L</CommandShortcut>
</CommandItem>
</CommandGroup>
<CommandSeparator />
</CommandList>
</Command>
);
const root = document.querySelector('[data-slot="root"]');
const input = screen.getByPlaceholderText("Search actions");
expect(root).toHaveAttribute("data-slot", "root");
expect(input).toHaveAttribute("data-slot", "input");
expect(document.querySelector('[data-slot="list"]')).toBeInTheDocument();
expect(screen.getByText("Open legal review").closest('[data-slot="item"]')).toBeInTheDocument();
expect(screen.getByText("G L")).toHaveAttribute("data-slot", "shortcut");
await user.type(input, "zzz");
expect(await screen.findByText("No results.")).toHaveAttribute("data-slot", "empty");
});
it("calls onSelect when a command item is chosen", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
render(
<Command>
<CommandInput placeholder="Search actions" />
<CommandList>
<CommandItem onSelect={onSelect} value="launch-release">
Launch release
</CommandItem>
</CommandList>
</Command>
);
await user.click(screen.getByText("Launch release"));
expect(onSelect).toHaveBeenCalledWith("launch-release");
});
it("renders inside a dialog and closes through the default close control", async () => {
const user = userEvent.setup();
function CommandDialogExample() {
const [open, setOpen] = useState(true);
return (
<CommandDialog onOpenChange={setOpen} open={open}>
<CommandInput placeholder="Search across workspace" />
<CommandList>
<CommandItem value="open-docs">Open docs</CommandItem>
</CommandList>
</CommandDialog>
);
}
render(<CommandDialogExample />);
expect(screen.getByPlaceholderText("Search across workspace")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Close dialog" }));
await waitFor(() => {
expect(screen.queryByPlaceholderText("Search across workspace")).not.toBeInTheDocument();
});
});
});
+192
View File
@@ -0,0 +1,192 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import {
forwardRef,
type ComponentPropsWithoutRef,
type ElementRef,
type ReactNode
} from "react";
import {
commandDialogContentVariants,
commandEmptyVariants,
commandGroupVariants,
commandInputVariants,
commandInputWrapperVariants,
commandItemVariants,
commandListVariants,
commandSeparatorVariants,
commandShortcutVariants,
commandVariants
} from "./command.variants";
import { DialogContent } from "./dialog";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
function SearchIcon() {
return (
<svg aria-hidden="true" className="size-4 text-[var(--color-muted-foreground)]" viewBox="0 0 16 16">
<path
d="M7.25 12.5a5.25 5.25 0 1 1 0-10.5a5.25 5.25 0 0 1 0 10.5Zm3.75-1.5 3 3"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
);
}
export type CommandProps = ComponentPropsWithoutRef<typeof CommandPrimitive>;
export const Command = forwardRef<ElementRef<typeof CommandPrimitive>, CommandProps>(
function Command({ className, ...props }, ref) {
return (
<CommandPrimitive
{...props}
{...createSlot("root")}
className={cn(commandVariants(), className)}
ref={ref}
/>
);
}
);
export type CommandDialogProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Root> & {
children?: ReactNode;
contentClassName?: string;
commandClassName?: string;
};
export function CommandDialog({
children,
commandClassName,
contentClassName,
...props
}: CommandDialogProps) {
return (
<DialogPrimitive.Root {...props}>
<DialogContent className={cn(commandDialogContentVariants(), contentClassName)}>
<Command className={commandClassName}>{children}</Command>
</DialogContent>
</DialogPrimitive.Root>
);
}
export type CommandInputProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
wrapperClassName?: string;
};
export const CommandInput = forwardRef<
ElementRef<typeof CommandPrimitive.Input>,
CommandInputProps
>(function CommandInput({ className, wrapperClassName, ...props }, ref) {
return (
<div
{...createSlot("control")}
className={cn(commandInputWrapperVariants(), wrapperClassName)}
>
<SearchIcon />
<CommandPrimitive.Input
{...props}
{...createSlot("input")}
className={cn(commandInputVariants(), className)}
ref={ref}
/>
</div>
);
});
export const CommandList = forwardRef<
ElementRef<typeof CommandPrimitive.List>,
ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(function CommandList({ className, ...props }, ref) {
return (
<CommandPrimitive.List
{...props}
{...createSlot("list")}
className={cn(commandListVariants(), className)}
ref={ref}
/>
);
});
export const CommandEmpty = forwardRef<
ElementRef<typeof CommandPrimitive.Empty>,
ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>(function CommandEmpty({ className, ...props }, ref) {
return (
<CommandPrimitive.Empty
{...props}
{...createSlot("empty")}
className={cn(commandEmptyVariants(), className)}
ref={ref}
/>
);
});
export const CommandGroup = forwardRef<
ElementRef<typeof CommandPrimitive.Group>,
ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(function CommandGroup({ className, ...props }, ref) {
return (
<CommandPrimitive.Group
{...props}
{...createSlot("group")}
className={cn(commandGroupVariants(), className)}
ref={ref}
/>
);
});
export type CommandItemProps = ComponentPropsWithoutRef<typeof CommandPrimitive.Item> & {
keywords?: string[];
};
export const CommandItem = forwardRef<
ElementRef<typeof CommandPrimitive.Item>,
CommandItemProps
>(function CommandItem({ className, keywords, ...props }, ref) {
return (
<CommandPrimitive.Item
{...props}
{...createSlot("item")}
{...createDataAttributes({
disabled: props.disabled
})}
className={cn(commandItemVariants(), className)}
keywords={keywords}
ref={ref}
/>
);
});
export const CommandSeparator = forwardRef<
ElementRef<typeof CommandPrimitive.Separator>,
ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(function CommandSeparator({ className, ...props }, ref) {
return (
<CommandPrimitive.Separator
{...props}
{...createSlot("separator")}
className={cn(commandSeparatorVariants(), className)}
ref={ref}
/>
);
});
export type CommandShortcutProps = ComponentPropsWithoutRef<"span">;
export const CommandShortcut = forwardRef<HTMLSpanElement, CommandShortcutProps>(
function CommandShortcut({ className, ...props }, ref) {
return (
<span
{...props}
{...createSlot("shortcut")}
className={cn(commandShortcutVariants(), className)}
ref={ref}
/>
);
}
);
@@ -0,0 +1,53 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const commandVariants = cva([
"flex h-full w-full flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)]",
"bg-[var(--color-card)] text-[var(--color-card-foreground)] shadow-[var(--shadow-sm)]",
getMotionRecipeClassNames("transition", "ring")
]);
export const commandDialogContentVariants = cva([
"gap-0 overflow-hidden p-0 sm:max-w-[40rem]",
"[&>[data-slot=root]]:rounded-none [&>[data-slot=root]]:border-none [&>[data-slot=root]]:shadow-none"
]);
export const commandInputWrapperVariants = cva([
"flex items-center gap-3 border-b border-[var(--color-border)] px-4"
]);
export const commandInputVariants = cva([
"flex h-12 w-full bg-transparent text-sm outline-none",
"placeholder:text-[var(--color-muted-foreground)] disabled:cursor-not-allowed disabled:opacity-50"
]);
export const commandListVariants = cva([
"max-h-[22rem] overflow-y-auto overflow-x-hidden p-2"
]);
export const commandEmptyVariants = cva([
"py-10 text-center text-sm text-[var(--color-muted-foreground)]"
]);
export const commandGroupVariants = cva([
"overflow-hidden p-1 text-[var(--color-foreground)]",
"[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5",
"[&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
"[&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[var(--tracking-caps)]",
"[&_[cmdk-group-heading]]:text-[var(--color-muted-foreground)]"
]);
export const commandItemVariants = cva([
"relative flex cursor-default select-none items-center gap-3 rounded-[calc(var(--radius-sm)-4px)] px-3 py-2 text-sm outline-none",
"text-[var(--color-foreground)] transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)]",
"data-[selected=true]:bg-[var(--color-surface)] data-[selected=true]:text-[var(--color-foreground)]",
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-45"
]);
export const commandSeparatorVariants = cva([
"-mx-1 my-1 h-px bg-[var(--color-border)]"
]);
export const commandShortcutVariants = cva([
"ml-auto text-[0.7rem] uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"
]);
+43
View File
@@ -53,6 +53,49 @@ export {
} from "./components/card.variants";
export { Checkbox, type CheckboxProps } from "./components/checkbox";
export { checkboxVariants } from "./components/checkbox.variants";
export {
Combobox,
type ComboboxItem,
type ComboboxProps
} from "./components/combobox";
export {
comboboxContentVariants,
comboboxEmptyVariants,
comboboxGroupVariants,
comboboxItemVariants,
comboboxLabelVariants,
comboboxListVariants,
comboboxSearchVariants,
comboboxTriggerVariants
} from "./components/combobox.variants";
export {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
type CommandDialogProps,
type CommandInputProps,
type CommandItemProps,
type CommandProps,
type CommandShortcutProps
} from "./components/command";
export {
commandDialogContentVariants,
commandEmptyVariants,
commandGroupVariants,
commandInputVariants,
commandInputWrapperVariants,
commandItemVariants,
commandListVariants,
commandSeparatorVariants,
commandShortcutVariants,
commandVariants
} from "./components/command.variants";
export {
Dialog,
DialogClose,