Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/smooth-lobsters-begin.md
Original file line number Diff line number Diff line change
@@ -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" });
```
120 changes: 115 additions & 5 deletions packages/core/src/components/command-palette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -38,7 +43,25 @@ afterEach(() => {
cleanup();
});

const renderCommandPaletteContent = (navItems: NavItem[] = mockNavItems) => {
const ProgrammaticOpenButton = ({ search }: { search?: string }) => {
const openCommandPalette = useOpenCommandPalette();

return (
<button type="button" onClick={() => openCommandPalette(search ? { search } : undefined)}>
Open palette
</button>
);
};

const TestCommandPalette = ({
navItems = mockNavItems,
searchSources,
opener,
}: {
navItems?: NavItem[];
searchSources?: readonly SearchSource[];
opener?: ReactNode;
}) => {
const configurations = {
modules: [],
settingsResources: [] as Array<Resource>,
Expand All @@ -47,19 +70,28 @@ const renderCommandPaletteContent = (navItems: NavItem[] = mockNavItems) => {
locale: "en",
};

return render(
return (
<AppShellConfigContext.Provider value={{ configurations }}>
<AppShellDataContext.Provider value={{ contextData: {} }}>
<CommandPaletteProvider>
<CommandPaletteProvider searchSources={searchSources}>
<MemoryRouter>
{opener}
<CommandPaletteContent navItems={navItems} />
</MemoryRouter>
</CommandPaletteProvider>
</AppShellDataContext.Provider>
</AppShellConfigContext.Provider>,
</AppShellConfigContext.Provider>
);
};

const renderCommandPaletteContent = (
props: {
navItems?: NavItem[];
searchSources?: readonly SearchSource[];
opener?: ReactNode;
} = {},
) => render(<TestCommandPalette {...props} />);

describe("CommandPaletteContent Integration", () => {
describe("keyboard shortcut to open", () => {
it("opens with Cmd+K", async () => {
Expand Down Expand Up @@ -145,6 +177,84 @@ describe("CommandPaletteContent Integration", () => {
});
});

describe("programmatic open", () => {
it("opens with a prefilled search string", async () => {
const user = userEvent.setup();
renderCommandPaletteContent({ opener: <ProgrammaticOpenButton search="report" /> });

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: <ProgrammaticOpenButton search="PO: alice" />,
});

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: <ProgrammaticOpenButton search="foo" />,
});

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(
<TestCommandPalette
searchSources={[...initialSearchSources]}
opener={<ProgrammaticOpenButton search="foo" />}
/>,
);

await vi.waitFor(() => {
expect((screen.getByPlaceholderText("Search pages...") as HTMLInputElement).value).toBe(
"bar",
);
});
});
});

describe("UI state", () => {
it("displays routes with breadcrumb hierarchy", async () => {
renderCommandPaletteContent();
Expand Down
58 changes: 51 additions & 7 deletions packages/core/src/components/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommandPaletteAction>;
filteredRoutes: Array<NavigatableRoute>;
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -448,6 +484,7 @@ export function useCommandPalette({
handleOpenChange,
search,
setSearch,
initializeSearch,
selectedIndex,
filteredActions,
filteredRoutes,
Expand All @@ -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,
Expand All @@ -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
? []
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/contexts/command-palette-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useCommandPaletteDispatch,
useCommandPaletteActions,
useCommandPaletteState,
useOpenCommandPalette,
useRegisterCommandPaletteActions,
type CommandPaletteAction,
} from "./command-palette-context";
Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading