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: 2 additions & 0 deletions frontend/src/app/(authenticated)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState } from "react";
import dynamic from "next/dynamic";
import AuthGuard from "@/components/AuthGuard";
import Breadcrumbs from "@/components/Breadcrumbs";
import { CommandPaletteTrigger } from "@/components/CommandPalette";
import LocaleSwitcher from "@/components/LocaleSwitcher";
import PaymentToastListener from "@/components/PaymentToastListener";
import Sidebar from "@/components/Sidebar";
Expand Down Expand Up @@ -65,6 +66,7 @@ export default function AuthenticatedLayout({

<div className="flex items-center gap-4">
<div className="flex items-center gap-2 mr-2">
<CommandPaletteTrigger className="lg:hidden" />
<NotificationCenter />
<LocaleSwitcher className="text-[#0A0A0A] border-none bg-transparent hover:bg-[#F5F5F5] rounded-lg transition-colors p-1" />
</div>
Expand Down
124 changes: 123 additions & 1 deletion frontend/src/components/CommandPalette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from "react";
import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CommandPalette from "./CommandPalette";
import CommandPalette, { CommandPaletteTrigger, openCommandPalette } from "./CommandPalette";

const pushMock = vi.fn();
const toggleThemeMock = vi.fn();
Expand Down Expand Up @@ -51,6 +51,10 @@ function pressGlobalShortcut() {
async function openPalette() {
pressGlobalShortcut();
await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument());
// Also wait for the rAF focus, otherwise focus assertions race it.
await waitFor(() =>
expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true),
);
}

describe("CommandPalette", () => {
Expand Down Expand Up @@ -428,3 +432,121 @@ describe("CommandPalette", () => {
});
});
});

describe("CommandPalette mobile drawer", () => {
function swipe(el: Element, fromY: number, toY: number) {
fireEvent.touchStart(el, { touches: [{ clientX: 100, clientY: fromY }] });
fireEvent.touchMove(el, { touches: [{ clientX: 100, clientY: toY }] });
fireEvent.touchEnd(el, { changedTouches: [{ clientX: 100, clientY: toY }] });
}

afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
document.body.style.overflow = "";
});

it("opens from the on-screen trigger, which has no keyboard shortcut dependency", async () => {
render(
<>
<CommandPaletteTrigger />
<CommandPalette />
</>,
);

const trigger = screen.getByRole("button", { name: "Open command palette" });
expect(trigger).toHaveAttribute("aria-haspopup", "dialog");

fireEvent.click(trigger);
expect(await screen.findByRole("dialog", { name: "Command palette" })).toBeInTheDocument();
});

it("opens via the openCommandPalette() event helper", async () => {
render(<CommandPalette />);
act(() => openCommandPalette());
expect(await screen.findByRole("dialog")).toBeInTheDocument();
});

it("renders a drag handle and a Cancel button for touch users", async () => {
render(<CommandPalette />);
await openPalette();

expect(screen.getByTestId("command-palette-drawer-handle")).toHaveAttribute("aria-hidden", "true");
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("locks page scroll while open and restores it on close", async () => {
render(<CommandPalette />);
await openPalette();
expect(document.body.style.overflow).toBe("hidden");

fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
expect(document.body.style.overflow).toBe("");
});

it("swiping down on the drawer dismisses it", async () => {
render(<CommandPalette />);
await openPalette();

swipe(screen.getByTestId("command-palette-drawer-handle"), 100, 260);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("follows the finger while dragging and snaps back on a short, slow drag", async () => {
render(<CommandPalette />);
await openPalette();
const dialog = screen.getByRole("dialog");
const handle = screen.getByTestId("command-palette-drawer-handle");

fireEvent.touchStart(handle, { touches: [{ clientX: 100, clientY: 100 }] });
fireEvent.touchMove(handle, { touches: [{ clientX: 100, clientY: 130 }] });
expect(dialog.style.transform).toContain("30px");
expect(dialog).toHaveAttribute("data-dragging", "true");

const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Date.now() + 2000);
fireEvent.touchEnd(handle, { changedTouches: [{ clientX: 100, clientY: 130 }] });
nowSpy.mockRestore();

expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(dialog.style.transform).toBe("");
expect(dialog).not.toHaveAttribute("data-dragging");
});

it("does not dismiss when the command list is scrolled and the user drags down", async () => {
render(<CommandPalette />);
await openPalette();
const list = screen.getByRole("listbox");
list.scrollTop = 120;

swipe(list.querySelector("li")!, 100, 400);
expect(screen.getByRole("dialog")).toBeInTheDocument();
});

it("leaves swipe handling to the converter view instead of closing the palette", async () => {
render(<CommandPalette />);
await openPalette();
fireEvent.change(screen.getByRole("combobox"), { target: { value: "converter" } });
fireEvent.keyDown(screen.getByRole("dialog"), { key: "Enter" });
expect(screen.getByText("Asset Converter View")).toBeInTheDocument();

swipe(screen.getByText("Asset Converter View"), 100, 400);
expect(screen.getByRole("dialog")).toBeInTheDocument();
});

it("focuses the dialog instead of the input on touch devices to keep the keyboard closed", async () => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query === "(pointer: coarse)",
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));

render(<CommandPalette />);
pressGlobalShortcut();
const dialog = await screen.findByRole("dialog");

await waitFor(() => expect(dialog).toHaveFocus());
expect(screen.getByRole("combobox")).not.toHaveFocus();
});
});
137 changes: 126 additions & 11 deletions frontend/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ import { useMerchantApiKey } from "@/lib/merchant-store";
import { ThemeMode, useThemeActions, useThemeState } from "@/lib/theme-context";
import { toast } from "sonner";
import { filterPaletteCommands, PaletteCommand } from "@/components/commandPaletteData";
import { useSwipeToDismiss } from "@/hooks/useSwipeToDismiss";

/**
* Window event that opens the palette. Touch devices have no Cmd/Ctrl+K, so
* on-screen triggers (e.g. the mobile header search button) dispatch this.
*/
export const OPEN_COMMAND_PALETTE_EVENT = "command-palette:open";

export function openCommandPalette() {
window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT));
}

/**
* On touch-first devices, focusing the search input on open would pop the
* on-screen keyboard over the bottom drawer. There the dialog is focused
* instead and the user taps the input when they want to search.
*/
function prefersTouchInput() {
return typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches === true;
}

const THEME_CYCLE: ThemeMode[] = ["light", "dark", "system"];

Expand Down Expand Up @@ -166,6 +186,16 @@ export default function CommandPalette() {

const filtered = useMemo(() => filterPaletteCommands(query), [query]);

const close = useCallback(() => setOpen(false), []);

// Swipe down to dismiss the mobile drawer. Disabled in the converter view,
// which handles its own swipe-down (back to the command list).
const swipe = useSwipeToDismiss({
onDismiss: close,
enabled: view === "commands",
scrollRef: listRef,
});

useEffect(() => {
function handleGlobalKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
Expand All @@ -174,10 +204,28 @@ export default function CommandPalette() {
}
}

function handleOpenRequest() {
setOpen(true);
}

window.addEventListener("keydown", handleGlobalKeydown);
return () => window.removeEventListener("keydown", handleGlobalKeydown);
window.addEventListener(OPEN_COMMAND_PALETTE_EVENT, handleOpenRequest);
return () => {
window.removeEventListener("keydown", handleGlobalKeydown);
window.removeEventListener(OPEN_COMMAND_PALETTE_EVENT, handleOpenRequest);
};
}, []);

// Lock page scroll behind the palette so swipes don't scroll the page.
useEffect(() => {
if (!open) return;
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = originalOverflow;
};
}, [open]);

useEffect(() => {
if (open) {
setQuery("");
Expand All @@ -186,7 +234,9 @@ export default function CommandPalette() {
setExecutingId(null);
// Store the previously focused element to restore focus on close
previousActiveElementRef.current = document.activeElement as HTMLElement;
requestAnimationFrame(() => inputRef.current?.focus());
requestAnimationFrame(() =>
(prefersTouchInput() ? dialogRef.current : inputRef.current)?.focus(),
);
} else {
// Restore focus to the previously focused element when dialog closes
if (previousActiveElementRef.current) {
Expand Down Expand Up @@ -378,19 +428,32 @@ export default function CommandPalette() {
if (!open) return null;

return (
// Mobile: bottom drawer. sm and up: centered palette near the top.
<div
className="fixed inset-0 z-[100] flex items-start justify-center bg-black/60 pt-[15vh] backdrop-blur-sm"
onClick={() => setOpen(false)}
className="fixed inset-0 z-[100] flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-start sm:pt-[15vh]"
style={{ backgroundColor: swipe.offset > 0 ? `rgba(0,0,0,${Math.max(0.2, 0.6 - swipe.offset / 600)})` : undefined }}
onClick={close}
data-testid="command-palette-backdrop"
>
<div
ref={dialogRef}
role="dialog"
aria-label="Command palette"
aria-modal="true"
aria-describedby="command-description"
className="w-full max-w-lg overflow-hidden rounded-[2rem] border border-[#1F1F1F] bg-black shadow-[0_30px_60px_rgba(0,0,0,0.8)] backdrop-blur-xl"
tabIndex={-1}
data-dragging={swipe.dragging || undefined}
style={{ transform: swipe.offset > 0 ? `translate3d(0, ${swipe.offset}px, 0)` : undefined }}
className={`flex max-h-[85dvh] w-full flex-col rounded-t-[2rem] border-t border-[#1F1F1F] bg-black pb-[env(safe-area-inset-bottom)] shadow-[0_30px_60px_rgba(0,0,0,0.8)] focus:outline-none motion-safe:animate-palette-drawer-in sm:max-h-none sm:max-w-lg sm:overflow-hidden sm:rounded-[2rem] sm:border sm:pb-0 sm:backdrop-blur-xl sm:motion-safe:animate-none ${
// AssetConverter is its own fixed bottom drawer on mobile; a clipping or
// backdrop-filtered parent would trap it, so the shell steps aside there.
view === "converter"
? "max-sm:border-0 max-sm:bg-transparent max-sm:shadow-none"
: "overflow-hidden backdrop-blur-xl"
} ${swipe.dragging ? "" : "transition-transform duration-200 ease-out motion-reduce:transition-none"}`}
onClick={(e) => e.stopPropagation()}
onKeyDown={handlePaletteKeydown}
{...swipe.handlers}
>
{view === "converter" ? (
<AssetConverter onBack={() => setView("commands")} />
Expand All @@ -401,7 +464,20 @@ export default function CommandPalette() {
Command palette dialog. Use arrow keys to navigate, Enter to select, Escape to close. Tab cycles through focusable elements.
</span>

<div className="flex items-center gap-3 border-b border-white/10 px-4 py-3">
{/* Mobile drag handle: swipe down to close */}
<div
className="flex shrink-0 touch-none justify-center pb-1 pt-3 sm:hidden"
data-testid="command-palette-drawer-handle"
aria-hidden="true"
>
<div
className={`h-1.5 w-12 rounded-full transition-colors ${
swipe.dragging ? "bg-[#00F5D4]/70" : "bg-white/20"
}`}
/>
</div>

<div className="flex shrink-0 items-center gap-3 border-b border-white/10 px-4 py-3">
<svg
viewBox="0 0 24 24"
className="h-5 w-5 shrink-0 text-[#A0A0A0]"
Expand All @@ -420,7 +496,7 @@ export default function CommandPalette() {
onChange={(e) => setQuery(e.target.value)}
disabled={executingId !== null}
placeholder="Type a command..."
className="flex-1 bg-transparent font-heading text-sm font-black tracking-widest text-white outline-none placeholder:text-white/10 disabled:opacity-60"
className="min-w-0 flex-1 bg-transparent font-heading text-base font-black tracking-widest text-white outline-none placeholder:text-white/10 disabled:opacity-60 sm:text-sm"
aria-label="Search commands"
aria-activedescendant={filtered.length > 0 ? `cmd-${filtered[activeIndex].id}` : undefined}
role="combobox"
Expand All @@ -433,9 +509,23 @@ export default function CommandPalette() {
<kbd className="hidden rounded-lg border border-[#1F1F1F] bg-white/[0.03] px-2 py-1 font-heading text-[10px] font-black text-[#A0A0A0] sm:inline-block">
ESC
</kbd>

{/* Touch users have no Escape key: give them an explicit close target */}
<button
type="button"
onClick={close}
className="inline-flex h-11 shrink-0 items-center rounded-xl px-3 font-heading text-[11px] font-black uppercase tracking-widest text-[#A0A0A0] transition-colors hover:text-white active:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#00F5D4] sm:hidden"
>
Cancel
</button>
</div>

<ul id="command-list" ref={listRef} role="listbox" className="max-h-72 overflow-y-auto p-2">
<ul
id="command-list"
ref={listRef}
role="listbox"
className="min-h-0 flex-1 touch-manipulation overflow-y-auto overscroll-contain p-2 sm:max-h-72 sm:flex-none"
>
{filtered.length === 0 && (
<li className="px-3 py-6 text-center text-sm text-slate-500">No matching commands</li>
)}
Expand All @@ -451,10 +541,10 @@ export default function CommandPalette() {
aria-selected={index === activeIndex}
aria-busy={isExecuting}
aria-disabled={executingId !== null && !isExecuting}
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 transition-colors ${
className={`flex select-none items-center gap-3 rounded-lg px-3 py-2.5 transition-colors ${
executingId !== null && !isExecuting
? "cursor-not-allowed opacity-40"
: "cursor-pointer"
: "cursor-pointer active:scale-[0.99] active:bg-white/10"
} ${index === activeIndex ? "bg-accent/10 text-white" : "text-slate-300 hover:bg-white/5"}`}
onMouseEnter={() => {
if (!executingId) setActiveIndex(index);
Expand Down Expand Up @@ -497,7 +587,11 @@ export default function CommandPalette() {
})}
</ul>

<div className="flex items-center gap-4 border-t border-white/10 px-4 py-2">
<p className="shrink-0 border-t border-white/10 px-4 py-3 text-center text-[11px] text-slate-500 sm:hidden">
Tap a command to run it · swipe down to close
</p>

<div className="hidden items-center gap-4 border-t border-white/10 px-4 py-2 sm:flex">
<span className="flex items-center gap-1 text-[11px] text-slate-500">
<kbd className="rounded border border-white/10 bg-white/5 px-1 py-0.5 font-mono text-[10px]">UP/DOWN</kbd>
navigate
Expand All @@ -517,3 +611,24 @@ export default function CommandPalette() {
</div>
);
}

/**
* On-screen button that opens the palette. Shown in the dashboard header
* below `lg`, where a hardware keyboard (and Cmd/Ctrl+K) is unlikely.
*/
export function CommandPaletteTrigger({ className = "" }: { className?: string }) {
return (
<button
type="button"
onClick={openCommandPalette}
aria-haspopup="dialog"
aria-label="Open command palette"
className={`inline-flex h-10 w-10 touch-manipulation items-center justify-center rounded-xl border border-[#E8E8E8] bg-white text-[#6B6B6B] transition-all hover:border-[#0A0A0A] hover:bg-[#F9F9F9] hover:text-[#0A0A0A] active:scale-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#0A0A0A] ${className}`}
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" strokeLinecap="round" />
</svg>
</button>
);
}
Loading
Loading