Skip to content
Closed
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
82 changes: 64 additions & 18 deletions apps/app/src/components/commands/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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")
Expand All @@ -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",
Expand All @@ -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(() =>
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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");
Expand Down
7 changes: 3 additions & 4 deletions apps/app/src/components/commands/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => [
Expand Down Expand Up @@ -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"
Expand Down
41 changes: 29 additions & 12 deletions apps/app/src/components/commands/PaletteShell.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,6 +29,7 @@ interface PaletteShellProps {
accessory?: ReactNode;
children: ReactNode;
footerKeys: readonly { keys: readonly string[]; label: string }[];
inputDescription: string;
inputLabel: string;
inputRef?: Ref<HTMLInputElement>;
listId: string;
Expand All @@ -33,6 +47,7 @@ export function PaletteShell({
accessory,
children,
footerKeys,
inputDescription,
inputLabel,
inputRef,
listId,
Expand All @@ -44,6 +59,7 @@ export function PaletteShell({
placeholder,
value,
}: PaletteShellProps) {
const inputDescriptionId = useId();
const overflow = useScrollOverflowState<HTMLDivElement>({
measureOverflow: true,
});
Expand All @@ -67,16 +83,15 @@ export function PaletteShell({
className="flex h-10 items-center gap-2 px-3"
data-palette-input-frame
>
{modeChip === undefined ? null : (
<PaletteModeChip {...modeChip} />
)}
{modeChip === undefined ? null : <PaletteModeChip {...modeChip} />}
<input
ref={inputRef}
autoFocus
role="combobox"
aria-expanded
aria-controls={listId}
aria-activedescendant={activeDescendantId}
aria-describedby={inputDescriptionId}
aria-label={inputLabel}
autoComplete="off"
spellCheck={false}
Expand All @@ -86,6 +101,9 @@ export function PaletteShell({
onChange={(event) => onInputChange(event.target.value)}
onKeyDown={onInputKeyDown}
/>
<span id={inputDescriptionId} className="sr-only">
{inputDescription}
</span>
{accessory}
</div>
</div>
Expand Down Expand Up @@ -121,6 +139,7 @@ export function PaletteShell({
</div>
</div>
<div
aria-hidden
className="relative z-10 flex flex-wrap items-center gap-x-4 gap-y-1 rounded-b-[inherit] border-t border-border bg-surface-recessed-soft-solid px-4 py-2 text-xs text-subtle-foreground"
data-palette-footer
>
Expand All @@ -133,20 +152,18 @@ export function PaletteShell({
{hint.keys.map((keys, index) => (
<span key={keys} className="inline-flex items-center gap-1">
{index === 0 ? null : (
<span
aria-hidden
className="text-muted-foreground/60"
>
<span aria-hidden className="text-muted-foreground/60">
/
</span>
)}
<kbd className="inline-flex min-w-5 items-center justify-center rounded border border-border/70 bg-background/70 px-1.5 py-0.5 font-mono text-xs leading-none text-muted-foreground shadow-xs">
{keys}
</kbd>
<kbd className={PALETTE_FOOTER_KEYCAP_CLASS}>{keys}</kbd>
</span>
))}
</span>
<span className="opacity-70" data-palette-footer-label>
<span
className={PALETTE_FOOTER_LABEL_CLASS}
data-palette-footer-label
>
{hint.label}
</span>
</span>
Expand Down
30 changes: 19 additions & 11 deletions apps/app/src/components/commands/ThreadSearchPaletteMode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -276,6 +275,7 @@ export function ThreadSearchPaletteMode({
/>
}
footerKeys={presentation.footerKeys}
inputDescription={presentation.inputDescription}
inputLabel="Search threads"
inputRef={inputRef}
listId={listId}
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -477,13 +480,18 @@ function ThreadSearchPaletteRow({
ranges={primary.highlightRanges}
/>
</span>
<span
className="block min-w-0 truncate text-xs leading-4 text-subtle-foreground"
title={row.metadataText}
data-palette-thread-metadata
>
{row.metadataText}
</span>
{row.metadataText.length === 0 ? null : (
<span
className={cn(
"block min-w-0 truncate text-xs leading-4",
PALETTE_FOOTER_LABEL_CLASS,
)}
title={row.metadataText}
data-palette-thread-metadata
>
{row.metadataText}
</span>
)}
</span>
{stateLabel === null ? null : (
<span
Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/components/ui/tab-pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface TabPillCloseAction {

interface TabPillProps {
label: string;
className?: string;
ariaLabel?: string;
ariaKeyshortcuts?: string;
iconOnly?: boolean;
Expand All @@ -39,6 +40,7 @@ interface TabPillProps {

export function TabPill({
label,
className,
ariaLabel,
ariaKeyshortcuts,
iconOnly = false,
Expand Down Expand Up @@ -72,6 +74,7 @@ export function TabPill({
isActive
? cn(CONTEXT_SELECTION_SURFACE_CLASS, "text-foreground")
: "text-muted-foreground hover:bg-state-hover",
className,
)}
>
<button
Expand Down
1 change: 1 addition & 0 deletions apps/app/src/lib/command-palette/palette-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface PaletteModePresentation {
keys: readonly string[];
label: string;
}[];
inputDescription: string;
placeholder: string;
}

Expand Down
6 changes: 3 additions & 3 deletions apps/app/src/lib/command-palette/palette-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ export const PALETTE_MODES: readonly PaletteModeRegistration[] = [
chip: { icon: "Search", label: "Threads" },
placeholder: "Search title, project, or message…",
footerKeys: [
{ keys: ["↑↓"], label: "Select" },
{ keys: ["↵"], label: "Open" },
{ keys: ["⌘↵"], label: "Split" },
{ keys: ["Backspace", "Esc"], label: "Back" },
{ keys: ["Esc"], label: "Back" },
],
inputDescription:
"Use Command-Enter or Control-Enter to open the selected thread in a split. Use Escape to return to commands.",
View: ThreadSearchPaletteMode,
},
];
Expand Down
Loading
Loading