diff --git a/apps/app/src/components/commands/CommandPalette.test.tsx b/apps/app/src/components/commands/CommandPalette.test.tsx index 7283166f87..5171e73648 100644 --- a/apps/app/src/components/commands/CommandPalette.test.tsx +++ b/apps/app/src/components/commands/CommandPalette.test.tsx @@ -472,9 +472,16 @@ describe("CommandPalette", () => { const rootFooter = screen .getByTestId("command-palette") .querySelector("[data-palette-footer]"); - expectText(rootFooter, "Select"); - expectText(rootFooter, "Run"); - expect(rootFooter?.textContent).not.toContain("Open"); + expectText(rootFooter, "Close"); + expect(rootFooter?.textContent).not.toContain("Select"); + expect(rootFooter?.textContent).not.toContain("Run"); + expectAttribute(rootFooter, "aria-hidden", "true"); + const rootDescriptionId = searchField().getAttribute("aria-describedby"); + expect(rootDescriptionId).not.toBeNull(); + expectText( + document.getElementById(rootDescriptionId ?? ""), + "Use Escape to close the command palette.", + ); const threadRows = within(bucketGroup("Threads")).getAllByRole("option"); expect(threadRows.map((row) => row.textContent)).toEqual([ @@ -531,13 +538,15 @@ describe("CommandPalette", () => { const modeSelect = screen.getByRole("button", { name: "Threads search" }); expectAttribute(modeSelect, "aria-pressed", "true"); expect(modeSelect.querySelector('[data-icon="Search"]')).not.toBeNull(); + expectClasses(modeSelect.parentElement, "bg-state-active"); + expectNoClasses(modeSelect.parentElement, "bg-background/70"); expectAttribute( screen.getByRole("button", { name: "Return to commands" }), "data-tab-pill-close", ); - expect( - screen.getByRole("button", { name: "Thread scope" }).textContent, - ).toContain("All"); + const scope = screen.getByRole("button", { name: "Thread scope" }); + expect(scope.textContent).toContain("All"); + expectClasses(scope, "text-subtle-foreground", "opacity-70"); expectText(screen.getByTestId("command-palette"), "Split"); const footer = screen .getByTestId("command-palette") @@ -549,10 +558,20 @@ describe("CommandPalette", () => { "px-4", "py-2", ); + expectAttribute(footer, "aria-hidden", "true"); for (const keycap of footer?.querySelectorAll("kbd") ?? []) { expectClasses( keycap, - "rounded", + "rounded-sm", + "bg-state-hover", + "font-sans", + "font-normal", + "tabular-nums", + "text-subtle-foreground", + "opacity-60", + ); + expectNoClasses( + keycap, "border-border/70", "bg-background/70", "font-mono", @@ -568,8 +587,19 @@ describe("CommandPalette", () => { "text-subtle-foreground", ); } - expectText(footer, "Backspace"); + expect(footer?.textContent).not.toContain("Backspace"); + expect(footer?.textContent).not.toContain("Select"); + expect(footer?.textContent).not.toContain("Open"); expectText(footer, "Esc"); + const threadInput = screen.getByRole("combobox", { + name: "Search threads", + }); + const threadDescriptionId = threadInput.getAttribute("aria-describedby"); + expect(threadDescriptionId).not.toBeNull(); + expectText( + document.getElementById(threadDescriptionId ?? ""), + "Use Command-Enter or Control-Enter to open the selected thread in a split. Use Escape to return to commands.", + ); fireEvent.keyDown(screen.getByRole("combobox"), { key: "Escape" }); await waitFor(() => @@ -851,6 +881,12 @@ describe("CommandPalette", () => { expect(within(results).getByRole("option").textContent).toContain( "matching draft", ); + expectText( + within(results) + .getByRole("option") + .querySelector("[data-palette-thread-metadata]"), + "Palette project", + ); fireEvent.keyDown(scope, { key: "Escape" }); expect(document.activeElement).toBe(input); @@ -897,22 +933,26 @@ describe("CommandPalette", () => { expect(rows[1]?.textContent).toContain("Draft"); expect(rows[2]?.textContent).toContain("Title recent-archived"); expect(rows[2]?.textContent).toContain("Archived"); - expect(results.querySelector("[data-palette-thread-state]")).toBeNull(); expect(results.querySelector("[data-icon]")).toBeNull(); for (const row of rows) { expectClasses( row.querySelector("[data-palette-thread-metadata]"), "text-subtle-foreground", + "opacity-70", ); } - expectClasses( - within(rows[1] as HTMLElement).getByText("Draft"), - "text-subtle-foreground", - ); - expectClasses( - within(rows[2] as HTMLElement).getByText("Archived"), - "text-subtle-foreground", - ); + expect( + rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, + ).toBe("Palette project"); + expect( + rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, + ).toBe("Palette project · just now"); + const draftState = rows[1]?.lastElementChild; + const archivedState = rows[2]?.lastElementChild; + expectText(draftState, "Draft"); + expectText(archivedState, "Archived"); + expectClasses(draftState, "shrink-0", "text-subtle-foreground"); + expectClasses(archivedState, "shrink-0", "text-subtle-foreground"); expect(within(results).queryAllByRole("group")).toHaveLength(0); expect(within(results).queryByText("Recent")).toBeNull(); }); @@ -985,9 +1025,15 @@ describe("CommandPalette", () => { expectClasses( row.querySelector("[data-palette-thread-metadata]"), "text-subtle-foreground", + "opacity-70", ); } - expect(results.querySelector("[data-palette-thread-state]")).toBeNull(); + expect( + rows[1]?.querySelector("[data-palette-thread-metadata]")?.textContent, + ).toBe("Palette project"); + expect( + rows[2]?.querySelector("[data-palette-thread-metadata]")?.textContent, + ).toBe("Palette project · just now"); expect(within(results).queryAllByRole("group")).toHaveLength(0); expect(within(results).queryByText("Recent")).toBeNull(); expect(results.textContent).not.toContain("1/1"); diff --git a/apps/app/src/components/commands/CommandPalette.tsx b/apps/app/src/components/commands/CommandPalette.tsx index 2113331a92..4530c3fded 100644 --- a/apps/app/src/components/commands/CommandPalette.tsx +++ b/apps/app/src/components/commands/CommandPalette.tsx @@ -54,11 +54,9 @@ import { import { PaletteShell } from "./PaletteShell"; const PALETTE_INPUT_LABEL = "Search commands"; +const PALETTE_INPUT_DESCRIPTION = "Use Escape to close the command palette."; const PALETTE_PLACEHOLDER = "Search commands…"; -const ROOT_FOOTER_KEYS = [ - { keys: ["↑↓"], label: "Select" }, - { keys: ["↵"], label: "Run" }, -] as const; +const ROOT_FOOTER_KEYS = [{ keys: ["Esc"], label: "Close" }] as const; const MODE_ENTRY_HANDLER_PRIORITY = 100; const MODE_BY_ACTION_ID = new Map( PALETTE_MODES.map((mode) => [ @@ -359,6 +357,7 @@ export function CommandPalette({ : `${optionIdPrefix}-${activeIndex}` } footerKeys={ROOT_FOOTER_KEYS} + inputDescription={PALETTE_INPUT_DESCRIPTION} inputLabel={PALETTE_INPUT_LABEL} listId={listId} listLabel="Commands" diff --git a/apps/app/src/components/commands/PaletteShell.tsx b/apps/app/src/components/commands/PaletteShell.tsx index b5d5b38ac4..6eafcba185 100644 --- a/apps/app/src/components/commands/PaletteShell.tsx +++ b/apps/app/src/components/commands/PaletteShell.tsx @@ -1,8 +1,21 @@ -import type { KeyboardEventHandler, ReactNode, Ref } from "react"; +import { + useId, + type KeyboardEventHandler, + type ReactNode, + type Ref, +} from "react"; import { useComposedRefs } from "@radix-ui/react-compose-refs"; import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; import { useScrollOverflowState } from "@/components/thread/timeline/useScrollOverflowState"; import { TabPill } from "@/components/ui/tab-pill"; +import { APP_COMMAND_ACCESSORY_PILL_CLASS } from "./AppCommandShortcutHint"; + +export const PALETTE_FOOTER_KEYCAP_CLASS = cn( + APP_COMMAND_ACCESSORY_PILL_CLASS, + "min-w-5 py-0.5", +); +export const PALETTE_FOOTER_LABEL_CLASS = "text-subtle-foreground opacity-70"; interface PaletteModeChipProps { clearLabel: string; @@ -16,6 +29,7 @@ interface PaletteShellProps { accessory?: ReactNode; children: ReactNode; footerKeys: readonly { keys: readonly string[]; label: string }[]; + inputDescription: string; inputLabel: string; inputRef?: Ref; listId: string; @@ -33,6 +47,7 @@ export function PaletteShell({ accessory, children, footerKeys, + inputDescription, inputLabel, inputRef, listId, @@ -44,6 +59,7 @@ export function PaletteShell({ placeholder, value, }: PaletteShellProps) { + const inputDescriptionId = useId(); const overflow = useScrollOverflowState({ measureOverflow: true, }); @@ -67,9 +83,7 @@ export function PaletteShell({ className="flex h-10 items-center gap-2 px-3" data-palette-input-frame > - {modeChip === undefined ? null : ( - - )} + {modeChip === undefined ? null : } onInputChange(event.target.value)} onKeyDown={onInputKeyDown} /> + + {inputDescription} + {accessory} @@ -121,6 +139,7 @@ export function PaletteShell({
@@ -133,20 +152,18 @@ export function PaletteShell({ {hint.keys.map((keys, index) => ( {index === 0 ? null : ( - + / )} - - {keys} - + {keys} ))} - + {hint.label} diff --git a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx index c567fa667f..c4b6a59ace 100644 --- a/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx +++ b/apps/app/src/components/commands/ThreadSearchPaletteMode.tsx @@ -35,7 +35,7 @@ import { } from "@/lib/command-palette/palette-thread-search"; import { windowPaletteThreadSearchText } from "@/lib/command-palette/palette-thread-search-window"; import type { PaletteModeViewProps } from "@/lib/command-palette/palette-mode"; -import { PaletteShell } from "./PaletteShell"; +import { PALETTE_FOOTER_LABEL_CLASS, PaletteShell } from "./PaletteShell"; export function ThreadSearchPaletteMode({ onExit, @@ -253,8 +253,7 @@ export function ThreadSearchPaletteMode({ ? "Searching threads" : trimmedQuery.length === 1 ? "Type at least 2 characters" - : (navigation.isLoading || archivedThreads.isLoading) && - result.isRecent + : (navigation.isLoading || archivedThreads.isLoading) && result.isRecent ? "Loading recent threads" : result.isRecent ? "No recent threads" @@ -276,6 +275,7 @@ export function ThreadSearchPaletteMode({ /> } footerKeys={presentation.footerKeys} + inputDescription={presentation.inputDescription} inputLabel="Search threads" inputRef={inputRef} listId={listId} @@ -349,7 +349,10 @@ function ThreadSearchScopeFilter({ aria-haspopup="listbox" aria-expanded={open} aria-label="Thread scope" - className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-subtle-foreground outline-none hover:bg-state-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" + className={cn( + "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs outline-none hover:bg-state-hover hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring", + PALETTE_FOOTER_LABEL_CLASS, + )} onClick={() => setOpen((value) => !value)} onKeyDown={(event) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { @@ -477,13 +480,18 @@ function ThreadSearchPaletteRow({ ranges={primary.highlightRanges} /> - - {row.metadataText} - + {row.metadataText.length === 0 ? null : ( + + {row.metadataText} + + )} {stateLabel === null ? null : (