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:
Nexus Dev 2026-04-11 13:22:56 +00:00
parent 14ecbf00bb
commit 673962fad8
2 changed files with 249 additions and 0 deletions

View 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();
});
});

View 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;
}