feat(nexus): CommandPaletteContext replaces Cmd+K shim
Introduces a provider that owns the palette open state and installs a single document-level Cmd+K / Ctrl+K listener. Replaces the Phase 8 pattern where CmdKButton synthesized KeyboardEvents and CommandPalette attached its own listener inside a useEffect. Used by CmdKButton (setOpen(true)), CommandPalette (open/setOpen), and Layout's onSearch wiring. Tests cover direct toggle, provider-level keyboard listener (both metaKey and ctrlKey), and the missing-provider throw path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
14ecbf00bb
commit
673962fad8
2 changed files with 249 additions and 0 deletions
153
ui/src/context/CommandPaletteContext.test.tsx
Normal file
153
ui/src/context/CommandPaletteContext.test.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
CommandPaletteProvider,
|
||||||
|
useCommandPalette,
|
||||||
|
} from "./CommandPaletteContext";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("CommandPaletteContext", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: ReturnType<typeof createRoot> | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root!.unmount();
|
||||||
|
});
|
||||||
|
root = null;
|
||||||
|
}
|
||||||
|
if (container.parentNode) container.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderWithProvider(
|
||||||
|
Consumer: React.FC,
|
||||||
|
{ registerKeyboardShortcut = true, initialOpen = false } = {},
|
||||||
|
) {
|
||||||
|
root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root!.render(
|
||||||
|
<CommandPaletteProvider
|
||||||
|
initialOpen={initialOpen}
|
||||||
|
registerKeyboardShortcut={registerKeyboardShortcut}
|
||||||
|
>
|
||||||
|
<Consumer />
|
||||||
|
</CommandPaletteProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("starts closed and opens via setOpen(true)", () => {
|
||||||
|
let openValue = false;
|
||||||
|
let setOpenRef: ((v: boolean) => void) | null = null;
|
||||||
|
|
||||||
|
const Consumer = () => {
|
||||||
|
const ctx = useCommandPalette();
|
||||||
|
openValue = ctx.open;
|
||||||
|
setOpenRef = ctx.setOpen;
|
||||||
|
return <div data-testid="flag">{ctx.open ? "open" : "closed"}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProvider(Consumer, { registerKeyboardShortcut: false });
|
||||||
|
expect(openValue).toBe(false);
|
||||||
|
expect(container.textContent).toContain("closed");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setOpenRef!(true);
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggle() flips the open state", () => {
|
||||||
|
let toggleRef: (() => void) | null = null;
|
||||||
|
|
||||||
|
const Consumer = () => {
|
||||||
|
const ctx = useCommandPalette();
|
||||||
|
toggleRef = ctx.toggle;
|
||||||
|
return <div>{ctx.open ? "open" : "closed"}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProvider(Consumer, { registerKeyboardShortcut: false });
|
||||||
|
expect(container.textContent).toContain("closed");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
toggleRef!();
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain("open");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
toggleRef!();
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain("closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers a global Cmd+K listener that toggles open", () => {
|
||||||
|
const Consumer = () => {
|
||||||
|
const ctx = useCommandPalette();
|
||||||
|
return <div>{ctx.open ? "open" : "closed"}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProvider(Consumer, { registerKeyboardShortcut: true });
|
||||||
|
expect(container.textContent).toContain("closed");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: "k",
|
||||||
|
metaKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also listens for Ctrl+K", () => {
|
||||||
|
const Consumer = () => {
|
||||||
|
const ctx = useCommandPalette();
|
||||||
|
return <div>{ctx.open ? "open" : "closed"}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProvider(Consumer, { registerKeyboardShortcut: true });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key: "k",
|
||||||
|
ctrlKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(container.textContent).toContain("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when useCommandPalette is used outside a provider", () => {
|
||||||
|
const Consumer = () => {
|
||||||
|
useCommandPalette();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
root = createRoot(container);
|
||||||
|
expect(() =>
|
||||||
|
act(() => {
|
||||||
|
root!.render(<Consumer />);
|
||||||
|
}),
|
||||||
|
).toThrow(/CommandPaletteProvider/);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
96
ui/src/context/CommandPaletteContext.tsx
Normal file
96
ui/src/context/CommandPaletteContext.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CommandPaletteContext — Phase 14 replacement for the Phase 8 keyboard
|
||||||
|
* shim. The provider owns the palette's open state and installs a single
|
||||||
|
* document-level keydown listener for Cmd+K / Ctrl+K. Consumers include:
|
||||||
|
*
|
||||||
|
* - `CmdKButton`: calls `setOpen(true)` instead of dispatching synthetic
|
||||||
|
* KeyboardEvents at document.
|
||||||
|
* - `CommandPalette`: reads `open` from context and closes via
|
||||||
|
* `setOpen(false)`.
|
||||||
|
* - `useKeyboardShortcuts.onSearch`: wired through Layout to
|
||||||
|
* `setOpen(true)` so the destructure bug no longer matters.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CommandPaletteContextValue {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (next: boolean) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandPaletteContext = createContext<
|
||||||
|
CommandPaletteContextValue | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
interface CommandPaletteProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Optional initial state for tests. */
|
||||||
|
initialOpen?: boolean;
|
||||||
|
/**
|
||||||
|
* When false the provider will not attach the global Cmd+K listener. Used
|
||||||
|
* by tests that want to exercise the API without racing on document
|
||||||
|
* events.
|
||||||
|
*/
|
||||||
|
registerKeyboardShortcut?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPaletteProvider({
|
||||||
|
children,
|
||||||
|
initialOpen = false,
|
||||||
|
registerKeyboardShortcut = true,
|
||||||
|
}: CommandPaletteProviderProps) {
|
||||||
|
const [open, setOpenState] = useState<boolean>(initialOpen);
|
||||||
|
|
||||||
|
const setOpen = useCallback((next: boolean) => {
|
||||||
|
setOpenState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setOpenState((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!registerKeyboardShortcut) return;
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpenState((prev) => !prev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [registerKeyboardShortcut]);
|
||||||
|
|
||||||
|
const value = useMemo<CommandPaletteContextValue>(
|
||||||
|
() => ({ open, setOpen, toggle }),
|
||||||
|
[open, setOpen, toggle],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPaletteContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</CommandPaletteContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommandPalette(): CommandPaletteContextValue {
|
||||||
|
const ctx = useContext(CommandPaletteContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(
|
||||||
|
"useCommandPalette must be used within a CommandPaletteProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue