diff --git a/.changeset/smooth-lobsters-begin.md b/.changeset/smooth-lobsters-begin.md
new file mode 100644
index 00000000..9d0fdd3c
--- /dev/null
+++ b/.changeset/smooth-lobsters-begin.md
@@ -0,0 +1,12 @@
+---
+"@tailor-platform/app-shell": minor
+---
+
+Add `useOpenCommandPalette()` for opening the built-in command palette from application code.
+
+```tsx
+const openCommandPalette = useOpenCommandPalette();
+
+openCommandPalette();
+openCommandPalette({ search: "PO: alice" });
+```
diff --git a/packages/core/src/components/command-palette.test.tsx b/packages/core/src/components/command-palette.test.tsx
index dbf3e3a1..0cf98908 100644
--- a/packages/core/src/components/command-palette.test.tsx
+++ b/packages/core/src/components/command-palette.test.tsx
@@ -10,8 +10,13 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CommandPaletteContent } from "./command-palette";
import { AppShellConfigContext, AppShellDataContext } from "@/contexts/appshell-context";
-import { CommandPaletteProvider } from "@/contexts/command-palette-context";
+import {
+ CommandPaletteProvider,
+ useOpenCommandPalette,
+ type SearchSource,
+} from "@/contexts/command-palette-context";
import { MemoryRouter } from "react-router";
+import type { ReactNode } from "react";
import type { Resource } from "@/resource";
import type { NavItem } from "../routing/navigation";
@@ -38,7 +43,25 @@ afterEach(() => {
cleanup();
});
-const renderCommandPaletteContent = (navItems: NavItem[] = mockNavItems) => {
+const ProgrammaticOpenButton = ({ search }: { search?: string }) => {
+ const openCommandPalette = useOpenCommandPalette();
+
+ return (
+
+ );
+};
+
+const TestCommandPalette = ({
+ navItems = mockNavItems,
+ searchSources,
+ opener,
+}: {
+ navItems?: NavItem[];
+ searchSources?: readonly SearchSource[];
+ opener?: ReactNode;
+}) => {
const configurations = {
modules: [],
settingsResources: [] as Array,
@@ -47,19 +70,28 @@ const renderCommandPaletteContent = (navItems: NavItem[] = mockNavItems) => {
locale: "en",
};
- return render(
+ return (
-
+
+ {opener}
- ,
+
);
};
+const renderCommandPaletteContent = (
+ props: {
+ navItems?: NavItem[];
+ searchSources?: readonly SearchSource[];
+ opener?: ReactNode;
+ } = {},
+) => render();
+
describe("CommandPaletteContent Integration", () => {
describe("keyboard shortcut to open", () => {
it("opens with Cmd+K", async () => {
@@ -145,6 +177,84 @@ describe("CommandPaletteContent Integration", () => {
});
});
+ describe("programmatic open", () => {
+ it("opens with a prefilled search string", async () => {
+ const user = userEvent.setup();
+ renderCommandPaletteContent({ opener: });
+
+ await user.click(screen.getByRole("button", { name: "Open palette" }));
+
+ const input = (await screen.findByPlaceholderText("Search pages...")) as HTMLInputElement;
+ await vi.waitFor(() => {
+ expect(input.value).toBe("report");
+ });
+ });
+
+ it("opens directly in a search mode from the prefilled search string", async () => {
+ const user = userEvent.setup();
+ const searchSources: SearchSource[] = [
+ {
+ prefix: "PO",
+ title: "Purchase Orders",
+ search: vi.fn().mockResolvedValue([]),
+ },
+ ];
+
+ renderCommandPaletteContent({
+ searchSources,
+ opener: ,
+ });
+
+ await user.click(screen.getByRole("button", { name: "Open palette" }));
+
+ const input = (await screen.findByPlaceholderText("Search pages...")) as HTMLInputElement;
+ await vi.waitFor(() => {
+ expect(input.value).toBe("alice");
+ });
+ expect(screen.getByText("PO")).toBeDefined();
+ });
+
+ it("does not replay a stale programmatic search after searchSources rerender", async () => {
+ const user = userEvent.setup();
+ const initialSearchSources: SearchSource[] = [
+ {
+ prefix: "PO",
+ title: "Purchase Orders",
+ search: vi.fn().mockResolvedValue([]),
+ },
+ ];
+
+ const { rerender } = renderCommandPaletteContent({
+ searchSources: initialSearchSources,
+ opener: ,
+ });
+
+ await user.click(screen.getByRole("button", { name: "Open palette" }));
+
+ const input = (await screen.findByPlaceholderText("Search pages...")) as HTMLInputElement;
+ await vi.waitFor(() => {
+ expect(input.value).toBe("foo");
+ });
+
+ await user.clear(input);
+ await user.type(input, "bar");
+ expect(input.value).toBe("bar");
+
+ rerender(
+ }
+ />,
+ );
+
+ await vi.waitFor(() => {
+ expect((screen.getByPlaceholderText("Search pages...") as HTMLInputElement).value).toBe(
+ "bar",
+ );
+ });
+ });
+ });
+
describe("UI state", () => {
it("displays routes with breadcrumb hierarchy", async () => {
renderCommandPaletteContent();
diff --git a/packages/core/src/components/command-palette.tsx b/packages/core/src/components/command-palette.tsx
index 6eabc698..a7930cbd 100644
--- a/packages/core/src/components/command-palette.tsx
+++ b/packages/core/src/components/command-palette.tsx
@@ -96,6 +96,7 @@ export type UseCommandPaletteReturn = {
handleOpenChange: (open: boolean) => void;
search: string;
setSearch: (search: string) => void;
+ initializeSearch: (search: string) => void;
selectedIndex: number;
filteredActions: Array;
filteredRoutes: Array;
@@ -136,13 +137,18 @@ type PaletteState =
isSearching: boolean;
};
+// Parsed search-mode metadata shared by both normal typing and programmatic
+// open. We compute this before dispatch because the reducer only sees state +
+// action and does not have direct access to `searchSources`.
+type ParsedSearchActionPayload = {
+ value: string;
+ detectedSource?: SearchSource;
+ detectedQuery?: string;
+};
+
type PaletteAction =
- | {
- type: "SET_SEARCH";
- value: string;
- detectedSource?: SearchSource;
- detectedQuery?: string;
- }
+ | ({ type: "SET_SEARCH" } & ParsedSearchActionPayload)
+ | ({ type: "INITIALIZE_SEARCH" } & ParsedSearchActionPayload)
| { type: "ARROW_DOWN"; maxIndex: number }
| { type: "ARROW_UP" }
| { type: "ENTER_SEARCH_MODE"; source: SearchSource }
@@ -178,6 +184,23 @@ function paletteReducer(state: PaletteState, action: PaletteAction): PaletteStat
}
return { ...state, search: action.value, selectedIndex: 0 };
+ case "INITIALIZE_SEARCH":
+ if (action.detectedSource) {
+ return {
+ mode: "search",
+ source: action.detectedSource,
+ query: action.detectedQuery ?? "",
+ selectedIndex: 0,
+ results: [],
+ isSearching: false,
+ };
+ }
+ return {
+ mode: "browse",
+ search: action.value,
+ selectedIndex: 0,
+ };
+
case "ENTER_SEARCH_MODE":
return {
mode: "search",
@@ -362,6 +385,19 @@ export function useCommandPalette({
[state.mode, searchSources],
);
+ const initializeSearch = useCallback(
+ (newSearch: string) => {
+ const parsed = parseSearchMode(newSearch, searchSources);
+ dispatch({
+ type: "INITIALIZE_SEARCH",
+ value: newSearch,
+ detectedSource: parsed.activeSource ?? undefined,
+ detectedQuery: parsed.activeSource ? parsed.searchQuery : undefined,
+ });
+ },
+ [searchSources],
+ );
+
// Handler for dialog open state changes
const handleOpenChange = useCallback(
(newOpen: boolean) => {
@@ -448,6 +484,7 @@ export function useCommandPalette({
handleOpenChange,
search,
setSearch,
+ initializeSearch,
selectedIndex,
filteredActions,
filteredRoutes,
@@ -468,13 +505,14 @@ type CommandPaletteContentProps = {
export function CommandPaletteContent({ navItems }: CommandPaletteContentProps) {
const t = useT();
const contextualActions = useCommandPaletteActions();
- const { searchSources, open, setOpen } = useCommandPaletteState();
+ const { searchSources, open, setOpen, openRequest, clearOpenRequest } = useCommandPaletteState();
const routes = useMemo(() => navItemsToRoutes(navItems), [navItems]);
const {
open: paletteOpen,
handleOpenChange,
search,
setSearch,
+ initializeSearch,
selectedIndex,
filteredActions,
filteredRoutes,
@@ -493,6 +531,12 @@ export function CommandPaletteContent({ navItems }: CommandPaletteContentProps)
setOpen,
});
+ useEffect(() => {
+ if (!openRequest) return;
+ initializeSearch(openRequest.search ?? "");
+ clearOpenRequest();
+ }, [openRequest, initializeSearch, clearOpenRequest]);
+
// Compute index offsets for each section
const searchModeItems = activeSearchSource
? []
diff --git a/packages/core/src/contexts/command-palette-context.test.tsx b/packages/core/src/contexts/command-palette-context.test.tsx
index 9851d0da..61dfe194 100644
--- a/packages/core/src/contexts/command-palette-context.test.tsx
+++ b/packages/core/src/contexts/command-palette-context.test.tsx
@@ -6,6 +6,7 @@ import {
useCommandPaletteDispatch,
useCommandPaletteActions,
useCommandPaletteState,
+ useOpenCommandPalette,
useRegisterCommandPaletteActions,
type CommandPaletteAction,
} from "./command-palette-context";
@@ -194,6 +195,33 @@ describe("CommandPaletteProvider", () => {
});
});
+ describe("useOpenCommandPalette", () => {
+ it("throws when used outside provider", () => {
+ expect(() => {
+ renderHook(() => useOpenCommandPalette());
+ }).toThrow("useOpenCommandPalette must be used within CommandPaletteProvider");
+ });
+
+ it("opens the palette programmatically", () => {
+ const { result } = renderHook(
+ () => {
+ const openCommandPalette = useOpenCommandPalette();
+ const state = useCommandPaletteState();
+ return { openCommandPalette, state };
+ },
+ { wrapper },
+ );
+
+ expect(result.current.state.open).toBe(false);
+
+ act(() => {
+ result.current.openCommandPalette();
+ });
+
+ expect(result.current.state.open).toBe(true);
+ });
+ });
+
describe("global keyboard shortcut (Cmd+K / Ctrl+K)", () => {
it("opens the palette with Cmd+K", () => {
const { result } = renderHook(() => useCommandPaletteState(), { wrapper });
diff --git a/packages/core/src/contexts/command-palette-context.tsx b/packages/core/src/contexts/command-palette-context.tsx
index aa1dd165..aab5d3a5 100644
--- a/packages/core/src/contexts/command-palette-context.tsx
+++ b/packages/core/src/contexts/command-palette-context.tsx
@@ -73,6 +73,11 @@ export type SearchSource = {
) => Promise;
};
+export type OpenCommandPaletteOptions = {
+ /** Optional initial text to prefill into the palette input. */
+ search?: string;
+};
+
type DispatchContextValue = {
register: (sourceId: string, actions: CommandPaletteAction[]) => () => void;
};
@@ -82,6 +87,9 @@ type StateContextValue = {
searchSources: readonly SearchSource[];
open: boolean;
setOpen: (open: boolean) => void;
+ openRequest: OpenCommandPaletteOptions | null;
+ clearOpenRequest: () => void;
+ openCommandPalette: (options?: OpenCommandPaletteOptions) => void;
};
const CommandPaletteDispatchContext = createContext(null);
@@ -106,6 +114,7 @@ export function CommandPaletteProvider({
const registryRef = useRef(new Map());
const [actions, setActions] = useState([]);
const [open, setOpen] = useState(false);
+ const [openRequest, setOpenRequest] = useState(null);
const updateActions = useCallback(() => {
setActions(Array.from(registryRef.current.values()).flat());
@@ -123,6 +132,15 @@ export function CommandPaletteProvider({
[updateActions],
);
+ const clearOpenRequest = useCallback(() => {
+ setOpenRequest(null);
+ }, []);
+
+ const openCommandPalette = useCallback((options: OpenCommandPaletteOptions = {}) => {
+ setOpen(true);
+ setOpenRequest({ search: options.search });
+ }, []);
+
// Global keyboard shortcut: Cmd+K (Mac) / Ctrl+K (Windows)
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
@@ -137,8 +155,16 @@ export function CommandPaletteProvider({
const dispatchValue = useMemo(() => ({ register }), [register]);
const stateValue = useMemo(
- () => ({ actions, searchSources, open, setOpen }),
- [actions, searchSources, open],
+ () => ({
+ actions,
+ searchSources,
+ open,
+ setOpen,
+ openRequest,
+ clearOpenRequest,
+ openCommandPalette,
+ }),
+ [actions, searchSources, open, openRequest, clearOpenRequest, openCommandPalette],
);
return (
@@ -222,6 +248,20 @@ export function useCommandPaletteState(): StateContextValue {
return ctx;
}
+/**
+ * Returns a function that opens the CommandPalette.
+ *
+ * Optionally accepts an initial search string, including prefix-activated
+ * search modes such as `PO: alice`.
+ */
+export function useOpenCommandPalette(): (options?: OpenCommandPaletteOptions) => void {
+ const ctx = useContext(CommandPaletteStateContext);
+ if (!ctx) {
+ throw new Error("useOpenCommandPalette must be used within CommandPaletteProvider");
+ }
+ return ctx.openCommandPalette;
+}
+
/**
* Returns the current list of contextual actions registered in the CommandPalette.
*
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 264f59f7..1030c1dd 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -2,9 +2,11 @@ export { AppShell, type AppShellProps } from "./components/appshell";
export { SidebarLayout, DefaultSidebar, DefaultHeader } from "./components/sidebar/index";
export { CommandPalette } from "./components/command-palette";
export {
+ useOpenCommandPalette,
useRegisterCommandPaletteActions,
type CommandPaletteAction,
type CommandPaletteSearchResult,
+ type OpenCommandPaletteOptions,
type SearchSource,
} from "./contexts/command-palette-context";