feat: add command and combobox components
This commit is contained in:
@@ -5,4 +5,5 @@ coverage
|
|||||||
.turbo
|
.turbo
|
||||||
.pnpm-store
|
.pnpm-store
|
||||||
/.storybook
|
/.storybook
|
||||||
|
.home
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="grid w-[380px] gap-4">
|
||||||
|
<Combobox
|
||||||
|
aria-label="Routing team"
|
||||||
|
items={[...teamItems]}
|
||||||
|
onValueChange={setValue}
|
||||||
|
searchPlaceholder="Search teams"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-card)] px-4 py-3 text-sm text-[var(--color-muted-foreground)] shadow-[var(--shadow-xs)]">
|
||||||
|
Current value: <span className="font-medium text-[var(--color-foreground)]">{value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LaunchRoutingForm() {
|
||||||
|
const [submitted, setSubmitted] = useState<Record<string, string> | null>(null);
|
||||||
|
const form = useForm<{ team: string }>({
|
||||||
|
defaultValues: {
|
||||||
|
team: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
className="grid w-[560px] gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]"
|
||||||
|
noValidate
|
||||||
|
onSubmit={form.handleSubmit((values) => {
|
||||||
|
setSubmitted(values);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="m-0 text-xl font-semibold tracking-[var(--tracking-tight)]">
|
||||||
|
Launch routing
|
||||||
|
</h2>
|
||||||
|
<p className="m-0 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
||||||
|
Combobox can live inside <code>FormControl</code> and surface RHF validation state.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="team"
|
||||||
|
rules={{
|
||||||
|
required: "Choose a routing team before submitting."
|
||||||
|
}}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem name="team">
|
||||||
|
<FormLabel>Routing team</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Combobox
|
||||||
|
aria-label="Routing team"
|
||||||
|
items={[...teamItems]}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
searchPlaceholder="Search teams"
|
||||||
|
value={field.value}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The chosen team becomes the primary owner for approvals and notifications.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset();
|
||||||
|
setSubmitted(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">Save routing</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
|
||||||
|
<p className="m-0 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
||||||
|
Submitted payload
|
||||||
|
</p>
|
||||||
|
<pre className="m-0 mt-3 overflow-x-auto text-sm leading-6 text-[var(--color-foreground)]">
|
||||||
|
<code>{submitted ? JSON.stringify(submitted, null, 2) : "Submit the form to inspect values."}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Components/Combobox",
|
||||||
|
component: ControlledDemo,
|
||||||
|
parameters: {
|
||||||
|
layout: "centered"
|
||||||
|
},
|
||||||
|
tags: ["autodocs"]
|
||||||
|
} satisfies Meta<typeof ControlledDemo>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Controlled: Story = {
|
||||||
|
render: () => <ControlledDemo />
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithForm: Story = {
|
||||||
|
render: () => <LaunchRoutingForm />
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Command className="w-[520px]">
|
||||||
|
<CommandInput placeholder="Search across the workspace" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No matching actions.</CommandEmpty>
|
||||||
|
{inlineItems.map((group, index) => (
|
||||||
|
<div key={group.heading}>
|
||||||
|
<CommandGroup heading={group.heading}>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<CommandItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
<CommandShortcut>{item.shortcut}</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
{index < inlineItems.length - 1 ? <CommandSeparator /> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogCommandShowcase() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button onClick={() => setOpen(true)}>Open command palette</Button>
|
||||||
|
<CommandDialog onOpenChange={setOpen} open={open}>
|
||||||
|
<CommandInput placeholder="Type a command or search" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No commands available.</CommandEmpty>
|
||||||
|
<CommandGroup heading="Suggestions">
|
||||||
|
<CommandItem value="launch-checklist">Launch checklist</CommandItem>
|
||||||
|
<CommandItem value="rollout-audit">Rollout audit</CommandItem>
|
||||||
|
<CommandItem value="brand-theme">Brand theme tokens</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading="People">
|
||||||
|
<CommandItem value="jordan-lee">
|
||||||
|
Jordan Lee
|
||||||
|
<CommandShortcut>@</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem value="avery-carter">
|
||||||
|
Avery Carter
|
||||||
|
<CommandShortcut>@</CommandShortcut>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: "Components/Command",
|
||||||
|
component: InlineCommandShowcase,
|
||||||
|
parameters: {
|
||||||
|
layout: "centered"
|
||||||
|
},
|
||||||
|
tags: ["autodocs"]
|
||||||
|
} satisfies Meta<typeof InlineCommandShowcase>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Playground: Story = {
|
||||||
|
render: () => <InlineCommandShowcase />
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DialogPalette: Story = {
|
||||||
|
render: () => <DialogCommandShowcase />
|
||||||
|
};
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"motion": "^12.38.0",
|
"motion": "^12.38.0",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
"tailwind-merge": "^3.5.0"
|
"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";
|
} from "./components/card.variants";
|
||||||
export { Checkbox, type CheckboxProps } from "./components/checkbox";
|
export { Checkbox, type CheckboxProps } from "./components/checkbox";
|
||||||
export { checkboxVariants } from "./components/checkbox.variants";
|
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 {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
DialogClose,
|
||||||
|
|||||||
Generated
+21
@@ -157,6 +157,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 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:
|
motion:
|
||||||
specifier: ^12.38.0
|
specifier: ^12.38.0
|
||||||
version: 12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
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==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -5068,6 +5077,18 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
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:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|||||||
Reference in New Issue
Block a user