Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@tanstack/eslint-config": "^0.3.0",
"axe-core": "^4.12.1",
"eslint": "^9.39.2",
"jsdom": "^30.0.1",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"@vitest/coverage-v8": "^3.2.0",
Expand Down
121 changes: 121 additions & 0 deletions packages/ui/src/components/__tests__/command-menu.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useRef, useState } from "react"
import { cleanup, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { afterEach, describe, expect, it, vi } from "vitest"

import { CommandMenu, type CommandMenuGroup } from "@workspace/ui/components/command-menu"

const openBilling = vi.fn()
const openSettings = vi.fn()

const groups: CommandMenuGroup[] = [
{
id: "workspace",
label: "Workspace",
items: [
{
id: "billing",
label: "Open billing",
description: "Manage invoices and payment methods",
shortcut: ["⌘", "B"],
onSelect: openBilling,
},
{
id: "settings",
label: "Open settings",
description: "Manage account preferences",
shortcut: ["⌘", ","],
onSelect: openSettings,
},
],
},
]

function CommandMenuHarness() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const triggerRef = useRef<HTMLButtonElement>(null)

return (
<>
<button ref={triggerRef} type="button" onClick={() => setOpen(true)}>
Open commands
</button>
<CommandMenu
open={open}
onOpenChange={setOpen}
query={query}
onQueryChange={setQuery}
groups={groups}
triggerRef={triggerRef}
/>
</>
)
}

afterEach(() => {
cleanup()
openBilling.mockClear()
openSettings.mockClear()
})

describe("CommandMenu", () => {
it("opens with focus in the search input", async () => {
const user = userEvent.setup()
render(<CommandMenuHarness />)

const trigger = screen.getByRole("button", { name: "Open commands" })
await user.click(trigger)

expect(document.activeElement).toBe(
screen.getByRole("combobox", { name: "Search commands" }),
)
})

it("filters grouped results from the controlled query", async () => {
const user = userEvent.setup()
render(<CommandMenuHarness />)
await user.click(screen.getByRole("button", { name: "Open commands" }))

await user.type(screen.getByRole("combobox", { name: "Search commands" }), "billing")

expect(screen.getByText("Open billing")).toBeTruthy()
expect(screen.queryByText("Open settings")).toBeNull()
})

it("activates the highlighted result with keyboard input", async () => {
const user = userEvent.setup()
render(<CommandMenuHarness />)
await user.click(screen.getByRole("button", { name: "Open commands" }))

const input = screen.getByRole("combobox", { name: "Search commands" })
await user.type(input, "billing")
await user.keyboard("{ArrowDown}{Enter}")

expect(openBilling).toHaveBeenCalledOnce()
expect(screen.queryByRole("dialog")).toBeNull()
})

it("shows the shared empty state for no matches", async () => {
const user = userEvent.setup()
render(<CommandMenuHarness />)
await user.click(screen.getByRole("button", { name: "Open commands" }))

await user.type(screen.getByRole("combobox", { name: "Search commands" }), "unknown")

expect(screen.getByText("No commands found")).toBeTruthy()
expect(screen.getByText("Try a different search term.")).toBeTruthy()
})

it("closes on Escape and restores focus to the trigger", async () => {
const user = userEvent.setup()
render(<CommandMenuHarness />)
const trigger = screen.getByRole("button", { name: "Open commands" })
await user.click(trigger)

await user.keyboard("{Escape}")

expect(screen.queryByRole("dialog")).toBeNull()
expect(document.activeElement).toBe(trigger)
})
})
83 changes: 83 additions & 0 deletions packages/ui/src/components/combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"

import { Input } from "@workspace/ui/components/input"
import { cn } from "@workspace/ui/lib/utils"

function Combobox<Value>(props: ComboboxPrimitive.Root.Props<Value>) {
return <ComboboxPrimitive.Root data-slot="combobox" {...props} />
}

function ComboboxInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-input"
render={<Input />}
className={cn("h-10 border-0 bg-transparent px-3 text-sm shadow-none focus-visible:ring-0", className)}
{...props}
/>
)
}

function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn("outline-none", className)}
{...props}
/>
)
}

function ComboboxItem({ className, ...props }: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"flex min-h-10 w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm outline-none transition-colors data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className,
)}
{...props}
/>
)
}

function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn("space-y-1", className)}
{...props}
/>
)
}

function ComboboxGroupLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-group-label"
className={cn("px-3 py-2 text-[0.625rem] font-medium tracking-[0.08em] text-muted-foreground uppercase", className)}
{...props}
/>
)
}

function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn("p-0", className)}
{...props}
/>
)
}

export {
Combobox,
ComboboxEmpty,
ComboboxGroup,
ComboboxGroupLabel,
ComboboxInput,
ComboboxItem,
ComboboxList,
}
Loading
Loading