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