diff --git a/bun.lock b/bun.lock index a897954..1571e8d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "so4-market", @@ -125,6 +124,7 @@ "@vitest/coverage-v8": "^3.2.0", "axe-core": "^4.12.1", "eslint": "^9.39.2", + "jsdom": "^30.0.1", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vitest": "3", diff --git a/packages/ui/package.json b/packages/ui/package.json index eebc517..3b3cbd5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/__tests__/command-menu.test.tsx b/packages/ui/src/components/__tests__/command-menu.test.tsx new file mode 100644 index 0000000..8c0af8f --- /dev/null +++ b/packages/ui/src/components/__tests__/command-menu.test.tsx @@ -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(null) + + return ( + <> + + + + ) +} + +afterEach(() => { + cleanup() + openBilling.mockClear() + openSettings.mockClear() +}) + +describe("CommandMenu", () => { + it("opens with focus in the search input", async () => { + const user = userEvent.setup() + render() + + 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() + 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() + 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() + 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() + 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) + }) +}) diff --git a/packages/ui/src/components/combobox.tsx b/packages/ui/src/components/combobox.tsx new file mode 100644 index 0000000..bedde99 --- /dev/null +++ b/packages/ui/src/components/combobox.tsx @@ -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(props: ComboboxPrimitive.Root.Props) { + return +} + +function ComboboxInput({ className, ...props }: ComboboxPrimitive.Input.Props) { + return ( + } + 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 ( + + ) +} + +function ComboboxItem({ className, ...props }: ComboboxPrimitive.Item.Props) { + return ( + + ) +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ) +} + +function ComboboxGroupLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( + + ) +} + +export { + Combobox, + ComboboxEmpty, + ComboboxGroup, + ComboboxGroupLabel, + ComboboxInput, + ComboboxItem, + ComboboxList, +} diff --git a/packages/ui/src/components/command-menu.tsx b/packages/ui/src/components/command-menu.tsx new file mode 100644 index 0000000..3c937e3 --- /dev/null +++ b/packages/ui/src/components/command-menu.tsx @@ -0,0 +1,208 @@ +import * as React from "react" + +import { + Combobox, + ComboboxEmpty, + ComboboxGroup, + ComboboxGroupLabel, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@workspace/ui/components/combobox" +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@workspace/ui/components/dialog" +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@workspace/ui/components/empty" +import { Kbd, KbdGroup } from "@workspace/ui/components/kbd" +import { ScrollArea } from "@workspace/ui/components/scroll-area" +import { cn } from "@workspace/ui/lib/utils" + +export type CommandMenuItem = { + id: string + label: string + description?: string + icon?: React.ReactNode + shortcut?: string | readonly string[] + disabled?: boolean + onSelect: () => void +} + +export type CommandMenuGroup = { + id: string + label?: string + items: readonly CommandMenuItem[] +} + +export type CommandMenuProps = { + open: boolean + onOpenChange: (open: boolean) => void + query: string + onQueryChange: (query: string) => void + groups: readonly CommandMenuGroup[] + triggerRef?: React.RefObject + title?: string + description?: string + placeholder?: string + emptyTitle?: string + emptyDescription?: string + className?: string +} + +function ShortcutHint({ shortcut }: { shortcut: CommandMenuItem["shortcut"] }) { + if (!shortcut) return null + + const keys = Array.isArray(shortcut) ? shortcut : [shortcut] + + return ( + + {keys.map((key) => ( + {key} + ))} + + ) +} + +export function CommandMenu({ + open, + onOpenChange, + query, + onQueryChange, + groups, + triggerRef, + title = "Command menu", + description = "Search for a command or destination.", + placeholder = "Search commands...", + emptyTitle = "No commands found", + emptyDescription = "Try a different search term.", + className, +}: CommandMenuProps) { + const inputRef = React.useRef(null) + const normalizedQuery = query.trim().toLocaleLowerCase() + const filteredGroups = React.useMemo(() => { + if (!normalizedQuery) return groups + + return groups + .map((group) => ({ + ...group, + items: group.items.filter((item) => + [item.label, item.description] + .filter(Boolean) + .some((value) => value!.toLocaleLowerCase().includes(normalizedQuery)), + ), + })) + .filter((group) => group.items.length > 0) + }, [groups, normalizedQuery]) + const allItems = React.useMemo( + () => groups.flatMap((group) => group.items), + [groups], + ) + + const handleOpenChange = (nextOpen: boolean) => { + onOpenChange(nextOpen) + if (!nextOpen && query) { + onQueryChange("") + } + } + + const handleItemSelect = (item: CommandMenuItem) => { + if (item.disabled) return + item.onSelect() + handleOpenChange(false) + } + + let itemIndex = 0 + + return ( + + + {title} + {description} + + onQueryChange(String(value))} + itemToStringLabel={(item: CommandMenuItem) => item.label} + value={null} + onValueChange={() => undefined} + inputRef={inputRef} + > +
+ +
+ + + + {filteredGroups.map((group) => { + const groupItems = group.items.map((item) => { + const currentIndex = itemIndex + itemIndex += 1 + return { item, index: currentIndex } + }) + + return ( + + {group.label && {group.label}} +
+ {groupItems.map(({ item, index }) => ( + handleItemSelect(item)} + > + {item.icon && ( + + {item.icon} + + )} + + {item.label} + {item.description && ( + + {item.description} + + )} + + + + ))} +
+
+ ) + })} + + + + {emptyTitle} + + {normalizedQuery ? emptyDescription : "No commands are available yet."} + + + + +
+
+
+
+
+ ) +} + +export { ShortcutHint } diff --git a/packages/ui/src/components/empty.tsx b/packages/ui/src/components/empty.tsx new file mode 100644 index 0000000..de4b2e1 --- /dev/null +++ b/packages/ui/src/components/empty.tsx @@ -0,0 +1,33 @@ +import * as React from "react" + +import { cn } from "@workspace/ui/lib/utils" + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return
+} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"h3">) { + return

+} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +

+ ) +} + +export { Empty, EmptyDescription, EmptyHeader, EmptyTitle } diff --git a/packages/ui/src/components/kbd.tsx b/packages/ui/src/components/kbd.tsx new file mode 100644 index 0000000..95f6134 --- /dev/null +++ b/packages/ui/src/components/kbd.tsx @@ -0,0 +1,28 @@ +import * as React from "react" + +import { cn } from "@workspace/ui/lib/utils" + +function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { + return ( + + ) +} + +function KbdGroup({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ) +} + +export { Kbd, KbdGroup } diff --git a/packages/ui/src/examples/command-menu-gallery.tsx b/packages/ui/src/examples/command-menu-gallery.tsx new file mode 100644 index 0000000..ac1a041 --- /dev/null +++ b/packages/ui/src/examples/command-menu-gallery.tsx @@ -0,0 +1,80 @@ +import { useState } from "react" +import { HugeiconsIcon } from "@hugeicons/react" +import { + Home01Icon, + Search01Icon, + Settings01Icon, +} from "@hugeicons/core-free-icons" + +import { Button } from "@workspace/ui/components/button" +import { CommandMenu, type CommandMenuGroup } from "@workspace/ui/components/command-menu" +import { Kbd } from "@workspace/ui/components/kbd" + +export function CommandMenuGallery() { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState("") + const [lastAction, setLastAction] = useState("No command selected") + + const groups: CommandMenuGroup[] = [ + { + id: "navigation", + label: "Navigation", + items: [ + { + id: "open-dashboard", + label: "Open dashboard", + description: "View account activity and positions", + icon: , + shortcut: ["⌘", "1"], + onSelect: () => setLastAction("Opened dashboard"), + }, + { + id: "search-markets", + label: "Search markets", + description: "Find a market by name or symbol", + icon: , + shortcut: ["⌘", "K"], + onSelect: () => setLastAction("Started market search"), + }, + ], + }, + { + id: "preferences", + label: "Preferences", + items: [ + { + id: "open-settings", + label: "Open settings", + description: "Manage application preferences", + icon: , + shortcut: ["⌘", ","], + onSelect: () => setLastAction("Opened settings"), + }, + ], + }, + ] + + return ( +

+
+
+

Command menu

+

A keyboard-first command surface.

+
+ +
+

+ {lastAction} +

+ +
+ ) +}