diff --git a/packages/core/src/components/command-palette/command-palette-context.ts b/packages/core/src/components/command-palette/command-palette-context.ts new file mode 100644 index 0000000..022a7d7 --- /dev/null +++ b/packages/core/src/components/command-palette/command-palette-context.ts @@ -0,0 +1,52 @@ +// packages/core/src/components/command-palette/command-palette-context.ts +import type { Accessor } from "solid-js"; +import { createContext, useContext } from "solid-js"; +import type { ListNavigationState } from "../../primitives/create-list-navigation"; + +/** A registered item entry held by the root. */ +export interface RegisteredItem { + value: string; + keywords?: string[]; + disabled?: boolean; +} + +/** Internal context shared between all CommandPalette sub-components. */ +export interface CommandPaletteContextValue { + /** Current search string. */ + searchValue: Accessor; + /** Update the search string. */ + setSearch: (value: string) => void; + /** Items that pass the current filter. */ + filteredItems: Accessor; + /** Register an item with the root. */ + registerItem: (item: RegisteredItem) => void; + /** Unregister an item from the root. */ + unregisterItem: (value: string) => void; + /** Called when an item is activated (from item click or keyboard). */ + onActivate: (value: string) => void; + /** List navigation state for keyboard handling and item props. */ + navigation: ListNavigationState; +} + +const CommandPaletteContext = createContext(); + +/** + * Returns the internal CommandPalette context. Throws if used outside CommandPalette. + */ +export function useCommandPaletteContext(): CommandPaletteContextValue { + const ctx = useContext(CommandPaletteContext); + if (!ctx) { + throw new Error( + "[PettyUI] CommandPalette parts must be used inside .\n" + + " Fix: \n" + + " \n" + + " \n" + + ' Action\n' + + " \n" + + " ", + ); + } + return ctx; +} + +export const CommandPaletteContextProvider = CommandPaletteContext.Provider; diff --git a/packages/core/src/components/command-palette/command-palette-empty.tsx b/packages/core/src/components/command-palette/command-palette-empty.tsx new file mode 100644 index 0000000..61f3fb5 --- /dev/null +++ b/packages/core/src/components/command-palette/command-palette-empty.tsx @@ -0,0 +1,22 @@ +// packages/core/src/components/command-palette/command-palette-empty.tsx +import type { JSX } from "solid-js"; +import { Show, splitProps } from "solid-js"; +import { useCommandPaletteContext } from "./command-palette-context"; +import type { CommandPaletteEmptyProps } from "./command-palette.props"; + +/** + * Rendered only when the current search produces no matching items. + * Wrap your "No results" message inside this component. + */ +export function CommandPaletteEmpty(props: CommandPaletteEmptyProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + const ctx = useCommandPaletteContext(); + + return ( + +
+ {local.children} +
+
+ ); +} diff --git a/packages/core/src/components/command-palette/command-palette-group.tsx b/packages/core/src/components/command-palette/command-palette-group.tsx new file mode 100644 index 0000000..11f4938 --- /dev/null +++ b/packages/core/src/components/command-palette/command-palette-group.tsx @@ -0,0 +1,21 @@ +// packages/core/src/components/command-palette/command-palette-group.tsx +import type { JSX } from "solid-js"; +import { Show, splitProps } from "solid-js"; +import type { CommandPaletteGroupProps } from "./command-palette.props"; + +/** + * Groups related CommandPalette items with an optional visible heading. + * Uses role="group" and aria-label for accessibility. + */ +export function CommandPaletteGroup(props: CommandPaletteGroupProps): JSX.Element { + const [local, rest] = splitProps(props, ["heading", "children"]); + + return ( +
+ +
{local.heading}
+
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/command-palette/command-palette-input.tsx b/packages/core/src/components/command-palette/command-palette-input.tsx new file mode 100644 index 0000000..7a1e9ba --- /dev/null +++ b/packages/core/src/components/command-palette/command-palette-input.tsx @@ -0,0 +1,37 @@ +// packages/core/src/components/command-palette/command-palette-input.tsx +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { useCommandPaletteContext } from "./command-palette-context"; +import type { CommandPaletteInputProps } from "./command-palette.props"; + +/** + * Search input for CommandPalette. Updates search state on input and delegates + * arrow-key / Enter navigation to the list navigation handler. + */ +export function CommandPaletteInput(props: CommandPaletteInputProps): JSX.Element { + const [local, rest] = splitProps(props, ["placeholder"]); + const ctx = useCommandPaletteContext(); + + function handleInput(e: InputEvent & { currentTarget: HTMLInputElement }): void { + ctx.setSearch(e.currentTarget.value); + } + + function handleKeyDown(e: KeyboardEvent): void { + ctx.navigation.containerProps.onKeyDown(e); + } + + return ( + + ); +} diff --git a/packages/core/src/components/command-palette/command-palette-item.tsx b/packages/core/src/components/command-palette/command-palette-item.tsx new file mode 100644 index 0000000..7756b35 --- /dev/null +++ b/packages/core/src/components/command-palette/command-palette-item.tsx @@ -0,0 +1,42 @@ +// packages/core/src/components/command-palette/command-palette-item.tsx +import type { JSX } from "solid-js"; +import { onCleanup, onMount, splitProps } from "solid-js"; +import { useCommandPaletteContext } from "./command-palette-context"; +import type { CommandPaletteItemProps } from "./command-palette.props"; + +/** + * An individual action item in CommandPalette. Registers itself with the root + * for filtering, and delegates navigation/activation to createListNavigation. + */ +export function CommandPaletteItem(props: CommandPaletteItemProps): JSX.Element { + const [local, rest] = splitProps(props, ["value", "keywords", "disabled", "onSelect", "children"]); + const ctx = useCommandPaletteContext(); + + onMount(() => { + ctx.registerItem({ value: local.value, keywords: local.keywords, disabled: local.disabled }); + }); + + onCleanup(() => { + ctx.unregisterItem(local.value); + }); + + const itemProps = () => ctx.navigation.getItemProps(local.value); + + function handleClick(): void { + if (local.disabled) return; + local.onSelect?.(); + ctx.onActivate(local.value); + } + + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/command-palette/command-palette-list.tsx b/packages/core/src/components/command-palette/command-palette-list.tsx new file mode 100644 index 0000000..80b2795 --- /dev/null +++ b/packages/core/src/components/command-palette/command-palette-list.tsx @@ -0,0 +1,26 @@ +// packages/core/src/components/command-palette/command-palette-list.tsx +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { useCommandPaletteContext } from "./command-palette-context"; +import type { CommandPaletteListProps } from "./command-palette.props"; + +/** + * Container for CommandPalette items. Spreads list navigation container props + * so keyboard events are handled correctly. + */ +export function CommandPaletteList(props: CommandPaletteListProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + const ctx = useCommandPaletteContext(); + const { role, "aria-orientation": ariaOrientation, onPointerLeave } = ctx.navigation.containerProps; + + return ( +
+ {local.children} +
+ ); +} diff --git a/packages/core/src/components/command-palette/command-palette-root.tsx b/packages/core/src/components/command-palette/command-palette-root.tsx new file mode 100644 index 0000000..8d2effd --- /dev/null +++ b/packages/core/src/components/command-palette/command-palette-root.tsx @@ -0,0 +1,100 @@ +// packages/core/src/components/command-palette/command-palette-root.tsx +import type { JSX } from "solid-js"; +import { createMemo, createSignal, splitProps } from "solid-js"; +import { createControllableSignal } from "../../primitives/create-controllable-signal"; +import { createListNavigation } from "../../primitives/create-list-navigation"; +import { CommandPaletteContextProvider } from "./command-palette-context"; +import type { RegisteredItem } from "./command-palette-context"; +import type { CommandPaletteRootProps } from "./command-palette.props"; + +/** + * Root component for CommandPalette. Manages search state, item registration, + * filtering, and keyboard navigation via context. + */ +export function CommandPaletteRoot(props: CommandPaletteRootProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "value", + "defaultValue", + "search", + "defaultSearch", + "loop", + "filter", + "onValueChange", + "onSearchChange", + "onSelect", + "children", + ]); + + void rest; + + const [allItems, setAllItems] = createSignal([]); + + const [searchValue, setSearchInternal] = createControllableSignal({ + value: () => local.search, + defaultValue: () => local.defaultSearch ?? "", + onChange: (v) => local.onSearchChange?.(v), + }); + + const filterEnabled = () => local.filter !== false; + + const filteredItems = createMemo(() => { + if (!filterEnabled()) return allItems(); + const query = searchValue().toLowerCase(); + if (!query) return allItems(); + return allItems().filter((item) => { + const text = item.value.toLowerCase(); + const keywords = item.keywords?.join(" ").toLowerCase() ?? ""; + return text.includes(query) || keywords.includes(query); + }); + }); + + const navigation = createListNavigation({ + items: () => filteredItems().map((i) => i.value), + mode: "activation", + loop: local.loop !== false, + onActivate: (value) => { + local.onSelect?.(value); + local.onValueChange?.(value); + }, + }); + + /** Register an item into the root's collection. */ + function registerItem(item: RegisteredItem): void { + setAllItems((prev) => { + if (prev.some((i) => i.value === item.value)) return prev; + return [...prev, item]; + }); + } + + /** Unregister an item from the root's collection. */ + function unregisterItem(value: string): void { + setAllItems((prev) => prev.filter((i) => i.value !== value)); + } + + /** Handle activation from item click or keyboard Enter. */ + function onActivate(value: string): void { + local.onSelect?.(value); + local.onValueChange?.(value); + } + + /** Update the search string. */ + function setSearch(value: string): void { + setSearchInternal(value); + } + + return ( + + {local.children} + + ); +} diff --git a/packages/core/src/components/command-palette/command-palette-separator.tsx b/packages/core/src/components/command-palette/command-palette-separator.tsx new file mode 100644 index 0000000..27793fe --- /dev/null +++ b/packages/core/src/components/command-palette/command-palette-separator.tsx @@ -0,0 +1,13 @@ +import type { JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import type { CommandPaletteSeparatorProps } from "./command-palette.props"; + +/** + * Visual divider between groups or sections in a CommandPalette. + * Uses role="separator" for accessibility. + */ +export function CommandPaletteSeparator(props: CommandPaletteSeparatorProps): JSX.Element { + const [, rest] = splitProps(props, []); + + return
; +} diff --git a/packages/core/src/components/command-palette/command-palette.props.ts b/packages/core/src/components/command-palette/command-palette.props.ts new file mode 100644 index 0000000..5e8d31f --- /dev/null +++ b/packages/core/src/components/command-palette/command-palette.props.ts @@ -0,0 +1,56 @@ +// packages/core/src/components/command-palette/command-palette.props.ts +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const CommandPaletteRootPropsSchema = z.object({ + value: z.string().optional().describe("Controlled selected item value"), + defaultValue: z.string().optional().describe("Initial selected item (uncontrolled)"), + search: z.string().optional().describe("Controlled search input value"), + defaultSearch: z.string().optional().describe("Initial search value (uncontrolled)"), + loop: z.boolean().optional().describe("Whether keyboard navigation wraps. Defaults to true"), + filter: z.boolean().optional().describe("Whether built-in filtering is enabled. Defaults to true"), +}); + +export interface CommandPaletteRootProps extends z.infer { + onValueChange?: (value: string) => void; + onSearchChange?: (search: string) => void; + onSelect?: (value: string) => void; + children: JSX.Element; +} + +export interface CommandPaletteInputProps + extends Omit, "value" | "onInput"> { + placeholder?: string; +} + +export interface CommandPaletteListProps extends JSX.HTMLAttributes { + children: JSX.Element; +} + +export interface CommandPaletteItemProps extends JSX.HTMLAttributes { + value: string; + keywords?: string[]; + disabled?: boolean; + onSelect?: () => void; + children?: JSX.Element; +} + +export interface CommandPaletteGroupProps extends JSX.HTMLAttributes { + heading?: string; + children: JSX.Element; +} + +export interface CommandPaletteEmptyProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export interface CommandPaletteSeparatorProps extends JSX.HTMLAttributes {} + +export const CommandPaletteMeta: ComponentMeta = { + name: "CommandPalette", + description: + "Search-driven command menu for finding and executing actions, with keyboard navigation and grouping", + parts: ["Root", "Input", "List", "Item", "Group", "Empty", "Separator"] as const, + requiredParts: ["Root", "Input", "List", "Item"] as const, +} as const; diff --git a/packages/core/src/components/command-palette/index.ts b/packages/core/src/components/command-palette/index.ts new file mode 100644 index 0000000..45cfa8a --- /dev/null +++ b/packages/core/src/components/command-palette/index.ts @@ -0,0 +1,19 @@ +import { CommandPaletteRoot } from "./command-palette-root"; +import { CommandPaletteInput } from "./command-palette-input"; +import { CommandPaletteList } from "./command-palette-list"; +import { CommandPaletteItem } from "./command-palette-item"; +import { CommandPaletteGroup } from "./command-palette-group"; +import { CommandPaletteEmpty } from "./command-palette-empty"; +import { CommandPaletteSeparator } from "./command-palette-separator"; + +export const CommandPalette = Object.assign(CommandPaletteRoot, { + Input: CommandPaletteInput, + List: CommandPaletteList, + Item: CommandPaletteItem, + Group: CommandPaletteGroup, + Empty: CommandPaletteEmpty, + Separator: CommandPaletteSeparator, +}); + +export type { CommandPaletteRootProps, CommandPaletteInputProps, CommandPaletteListProps, CommandPaletteItemProps, CommandPaletteGroupProps, CommandPaletteEmptyProps, CommandPaletteSeparatorProps } from "./command-palette.props"; +export { CommandPaletteRootPropsSchema, CommandPaletteMeta } from "./command-palette.props"; diff --git a/packages/core/tests/components/command-palette/command-palette.test.tsx b/packages/core/tests/components/command-palette/command-palette.test.tsx new file mode 100644 index 0000000..8e12250 --- /dev/null +++ b/packages/core/tests/components/command-palette/command-palette.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from "@solidjs/testing-library"; +import { describe, expect, it } from "vitest"; +import { CommandPalette } from "../../../src/components/command-palette/index"; +import { CommandPaletteRootPropsSchema, CommandPaletteMeta } from "../../../src/components/command-palette/command-palette.props"; + +describe("CommandPalette", () => { + it("renders input and items", () => { + render(() => ( + + + + Copy + Paste + + + )); + expect(screen.getByPlaceholderText("Search commands...")).toBeTruthy(); + expect(screen.getByText("Copy")).toBeTruthy(); + expect(screen.getByText("Paste")).toBeTruthy(); + }); + + it("renders groups with headings", () => { + render(() => ( + + + + + Save + + + + )); + expect(screen.getByText("Actions")).toBeTruthy(); + expect(screen.getByText("Save")).toBeTruthy(); + }); + + it("calls onSelect when item activated", () => { + let selected = ""; + render(() => ( + { selected = v; }}> + + + Run + + + )); + fireEvent.click(screen.getByText("Run")); + expect(selected).toBe("run"); + }); + + it("schema validates", () => { + expect(CommandPaletteRootPropsSchema.safeParse({ search: "test", loop: true, filter: false }).success).toBe(true); + }); + + it("meta has fields", () => { + expect(CommandPaletteMeta.name).toBe("CommandPalette"); + expect(CommandPaletteMeta.parts).toContain("Root"); + expect(CommandPaletteMeta.parts).toContain("Input"); + expect(CommandPaletteMeta.parts).toContain("Item"); + }); +});