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 ( +
+ { + setSubmitted(values); + })} + > +
+

+ Launch routing +

+

+ Combobox can live inside FormControl and surface RHF validation state. +

+
+ + ( + + Routing team + + + + + The chosen team becomes the primary owner for approvals and notifications. + + + + )} + /> + +
+ + +
+ +
+

+ Submitted payload +

+
+            {submitted ? JSON.stringify(submitted, null, 2) : "Submit the form to inspect values."}
+          
+
+ + + ); +} + +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 ( +
+ onSubmit(values))}> + ( + + Review lane + + + + Select the primary reviewer before publishing. + + + )} + /> + + + + ); + } + + 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