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";