diff --git a/.gitignore b/.gitignore
index 7ccce7f..0509f57 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,5 @@ coverage
.turbo
.pnpm-store
/.storybook
+.home
.DS_Store
diff --git a/apps/docs/src/components/combobox.stories.tsx b/apps/docs/src/components/combobox.stories.tsx
new file mode 100644
index 0000000..37fbbec
--- /dev/null
+++ b/apps/docs/src/components/combobox.stories.tsx
@@ -0,0 +1,163 @@
+import {
+ Button,
+ Combobox,
+ Form,
+ FormControl,
+ FormDescription,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from "@ai-ui/ui";
+import type { Meta, StoryObj } from "@storybook/react";
+import { Controller, useForm } from "react-hook-form";
+import { useState } from "react";
+
+const teamItems = [
+ {
+ value: "design",
+ label: "Design",
+ group: "Primary teams",
+ description: "Owns interface quality and review workflows.",
+ keywords: ["ux", "ui", "visual"]
+ },
+ {
+ value: "engineering",
+ label: "Engineering",
+ group: "Primary teams",
+ description: "Implements and verifies rollout mechanics.",
+ keywords: ["dev", "build", "api"]
+ },
+ {
+ value: "legal",
+ label: "Legal",
+ group: "Specialist teams",
+ description: "Checks policy, compliance, and contractual risk.",
+ keywords: ["policy", "compliance"]
+ },
+ {
+ value: "ops",
+ label: "Operations",
+ group: "Specialist teams",
+ description: "Coordinates timing, communications, and monitoring.",
+ keywords: ["launch", "support"]
+ }
+] as const;
+
+function ControlledDemo() {
+ const [value, setValue] = useState("design");
+
+ return (
+
+
+
+ Current value: {value}
+
+
+ );
+}
+
+function LaunchRoutingForm() {
+ const [submitted, setSubmitted] = useState | null>(null);
+ const form = useForm<{ team: string }>({
+ defaultValues: {
+ team: ""
+ }
+ });
+
+ return (
+
+
+ );
+}
+
+const meta = {
+ title: "Components/Combobox",
+ component: ControlledDemo,
+ parameters: {
+ layout: "centered"
+ },
+ tags: ["autodocs"]
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Controlled: Story = {
+ render: () =>
+};
+
+export const WithForm: Story = {
+ render: () =>
+};
diff --git a/apps/docs/src/components/command.stories.tsx b/apps/docs/src/components/command.stories.tsx
new file mode 100644
index 0000000..c419a8d
--- /dev/null
+++ b/apps/docs/src/components/command.stories.tsx
@@ -0,0 +1,109 @@
+import { useState } from "react";
+
+import {
+ Button,
+ Command,
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+ CommandShortcut
+} from "@ai-ui/ui";
+import type { Meta, StoryObj } from "@storybook/react";
+
+const inlineItems = [
+ {
+ heading: "Navigation",
+ items: [
+ { label: "Open docs", shortcut: "G D", value: "open-docs" },
+ { label: "Go to releases", shortcut: "G R", value: "go-releases" }
+ ]
+ },
+ {
+ heading: "Actions",
+ items: [
+ { label: "Publish update", shortcut: "P U", value: "publish-update" },
+ { label: "Invite reviewer", shortcut: "I R", value: "invite-reviewer" }
+ ]
+ }
+];
+
+function InlineCommandShowcase() {
+ return (
+
+
+
+ No matching actions.
+ {inlineItems.map((group, index) => (
+
+
+ {group.items.map((item) => (
+
+ {item.label}
+ {item.shortcut}
+
+ ))}
+
+ {index < inlineItems.length - 1 ? : null}
+
+ ))}
+
+
+ );
+}
+
+function DialogCommandShowcase() {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+
+
+ No commands available.
+
+ Launch checklist
+ Rollout audit
+ Brand theme tokens
+
+
+
+
+ Jordan Lee
+ @
+
+
+ Avery Carter
+ @
+
+
+
+
+
+ );
+}
+
+const meta = {
+ title: "Components/Command",
+ component: InlineCommandShowcase,
+ parameters: {
+ layout: "centered"
+ },
+ tags: ["autodocs"]
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Playground: Story = {
+ render: () =>
+};
+
+export const DialogPalette: Story = {
+ render: () =>
+};
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 534b4ce..1e7bf7f 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -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"
diff --git a/packages/ui/src/components/combobox.test.tsx b/packages/ui/src/components/combobox.test.tsx
new file mode 100644
index 0000000..64480ad
--- /dev/null
+++ b/packages/ui/src/components/combobox.test.tsx
@@ -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(
+
+ );
+
+ 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 (
+ {
+ onValueChange(nextValue);
+ setValue(nextValue);
+ }}
+ value={value}
+ />
+ );
+ }
+
+ render();
+
+ 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 (
+
+
+ );
+ }
+
+ render();
+
+ 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", "");
+ });
+});
diff --git a/packages/ui/src/components/combobox.tsx b/packages/ui/src/components/combobox.tsx
new file mode 100644
index 0000000..13fe164
--- /dev/null
+++ b/packages/ui/src/components/combobox.tsx
@@ -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) {
+ 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(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(null);
+ const itemRefs = useRef>([]);
+ 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();
+
+ 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) => {
+ if (resolvedDisabled) {
+ return;
+ }
+
+ if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ setOpenState(true);
+ }
+ };
+
+ const handleSearchKeyDown = (event: KeyboardEvent) => {
+ 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 (
+
+
+
+
+
+
+
+
+ {
+ setSearchState(event.target.value);
+ }}
+ onKeyDown={handleSearchKeyDown}
+ placeholder={searchPlaceholder}
+ ref={searchRef}
+ role="searchbox"
+ value={resolvedSearchValue}
+ />
+
+ {filteredItems.length === 0 ? (
+
+ {emptyMessage}
+
+ ) : (
+
+ {groupedItems.map(([group, groupItems]) => (
+
+ {group ? (
+
+ {group}
+
+ ) : null}
+ {groupItems.map((item) => {
+ const itemIndex = filteredItems.findIndex((entry) => entry.value === item.value);
+ const isSelected = item.value === resolvedValue;
+ const isActive = itemIndex === activeIndex;
+
+ return (
+
+ );
+ })}
+
+ ))}
+
+ )}
+
+
+
+ {props.name ?
: null}
+
+ );
+});
diff --git a/packages/ui/src/components/combobox.variants.ts b/packages/ui/src/components/combobox.variants.ts
new file mode 100644
index 0000000..8b3bc6b
--- /dev/null
+++ b/packages/ui/src/components/combobox.variants.ts
@@ -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)]"
+]);
diff --git a/packages/ui/src/components/command.test.tsx b/packages/ui/src/components/command.test.tsx
new file mode 100644
index 0000000..083b98e
--- /dev/null
+++ b/packages/ui/src/components/command.test.tsx
@@ -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(
+
+
+
+ No results.
+
+ Launch release
+
+ Open legal review
+ G L
+
+
+
+
+
+ );
+
+ 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(
+
+
+
+
+ Launch release
+
+
+
+ );
+
+ 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 (
+
+
+
+ Open docs
+
+
+ );
+ }
+
+ render();
+
+ 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();
+ });
+ });
+});
diff --git a/packages/ui/src/components/command.tsx b/packages/ui/src/components/command.tsx
new file mode 100644
index 0000000..0f2379c
--- /dev/null
+++ b/packages/ui/src/components/command.tsx
@@ -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 (
+
+ );
+}
+
+export type CommandProps = ComponentPropsWithoutRef;
+
+export const Command = forwardRef, CommandProps>(
+ function Command({ className, ...props }, ref) {
+ return (
+
+ );
+ }
+);
+
+export type CommandDialogProps = ComponentPropsWithoutRef & {
+ children?: ReactNode;
+ contentClassName?: string;
+ commandClassName?: string;
+};
+
+export function CommandDialog({
+ children,
+ commandClassName,
+ contentClassName,
+ ...props
+}: CommandDialogProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export type CommandInputProps = ComponentPropsWithoutRef & {
+ wrapperClassName?: string;
+};
+
+export const CommandInput = forwardRef<
+ ElementRef,
+ CommandInputProps
+>(function CommandInput({ className, wrapperClassName, ...props }, ref) {
+ return (
+
+
+
+
+ );
+});
+
+export const CommandList = forwardRef<
+ ElementRef,
+ ComponentPropsWithoutRef
+>(function CommandList({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+export const CommandEmpty = forwardRef<
+ ElementRef,
+ ComponentPropsWithoutRef
+>(function CommandEmpty({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+export const CommandGroup = forwardRef<
+ ElementRef,
+ ComponentPropsWithoutRef
+>(function CommandGroup({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+export type CommandItemProps = ComponentPropsWithoutRef & {
+ keywords?: string[];
+};
+
+export const CommandItem = forwardRef<
+ ElementRef,
+ CommandItemProps
+>(function CommandItem({ className, keywords, ...props }, ref) {
+ return (
+
+ );
+});
+
+export const CommandSeparator = forwardRef<
+ ElementRef,
+ ComponentPropsWithoutRef
+>(function CommandSeparator({ className, ...props }, ref) {
+ return (
+
+ );
+});
+
+export type CommandShortcutProps = ComponentPropsWithoutRef<"span">;
+
+export const CommandShortcut = forwardRef(
+ function CommandShortcut({ className, ...props }, ref) {
+ return (
+
+ );
+ }
+);
diff --git a/packages/ui/src/components/command.variants.ts b/packages/ui/src/components/command.variants.ts
new file mode 100644
index 0000000..ee7c4ad
--- /dev/null
+++ b/packages/ui/src/components/command.variants.ts
@@ -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)]"
+]);
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 1a8b213..e138134 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -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,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 58c9408..bfcb732 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -157,6 +157,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ cmdk:
+ specifier: ^1.1.1
+ version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1986,6 +1989,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
+ cmdk@1.1.1:
+ resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
+ peerDependencies:
+ react: ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^18 || ^19 || ^19.0.0-rc
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -5068,6 +5077,18 @@ snapshots:
clsx@2.1.1: {}
+ cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1)
+ '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+ transitivePeerDependencies:
+ - '@types/react'
+ - '@types/react-dom'
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4