feat: add command and combobox components
This commit is contained in:
@@ -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", "");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)]"
|
||||
]);
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user