diff --git a/frontend/src/app/(authenticated)/layout.tsx b/frontend/src/app/(authenticated)/layout.tsx index e6afc53..6d95f64 100644 --- a/frontend/src/app/(authenticated)/layout.tsx +++ b/frontend/src/app/(authenticated)/layout.tsx @@ -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"; @@ -65,6 +66,7 @@ export default function AuthenticatedLayout({
+
diff --git a/frontend/src/components/CommandPalette.test.tsx b/frontend/src/components/CommandPalette.test.tsx index 1a5a740..49262f0 100644 --- a/frontend/src/components/CommandPalette.test.tsx +++ b/frontend/src/components/CommandPalette.test.tsx @@ -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(); @@ -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", () => { @@ -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( + <> + + + , + ); + + 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(); + act(() => openCommandPalette()); + expect(await screen.findByRole("dialog")).toBeInTheDocument(); + }); + + it("renders a drag handle and a Cancel button for touch users", async () => { + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + pressGlobalShortcut(); + const dialog = await screen.findByRole("dialog"); + + await waitFor(() => expect(dialog).toHaveFocus()); + expect(screen.getByRole("combobox")).not.toHaveFocus(); + }); +}); diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index e02013a..19dc793 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -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"]; @@ -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") { @@ -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(""); @@ -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) { @@ -378,9 +428,12 @@ export default function CommandPalette() { if (!open) return null; return ( + // Mobile: bottom drawer. sm and up: centered palette near the top.
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" >
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" ? ( setView("commands")} /> @@ -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. -
+ {/* Mobile drag handle: swipe down to close */} +