CommandPalette component

This commit is contained in:
Mats Bosson 2026-03-29 21:13:39 +07:00
parent de1c9a1cb8
commit 1106c2d020
11 changed files with 449 additions and 0 deletions

View File

@ -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<string>;
/** Update the search string. */
setSearch: (value: string) => void;
/** Items that pass the current filter. */
filteredItems: Accessor<RegisteredItem[]>;
/** 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<CommandPaletteContextValue>();
/**
* 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 <CommandPalette>.\n" +
" Fix: <CommandPalette>\n" +
" <CommandPalette.Input />\n" +
" <CommandPalette.List>\n" +
' <CommandPalette.Item value="action">Action</CommandPalette.Item>\n' +
" </CommandPalette.List>\n" +
" </CommandPalette>",
);
}
return ctx;
}
export const CommandPaletteContextProvider = CommandPaletteContext.Provider;

View File

@ -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 (
<Show when={ctx.filteredItems().length === 0}>
<div data-part="empty" {...rest}>
{local.children}
</div>
</Show>
);
}

View File

@ -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 (
<div role="group" aria-label={local.heading} {...rest}>
<Show when={local.heading}>
<div data-part="group-heading">{local.heading}</div>
</Show>
{local.children}
</div>
);
}

View File

@ -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 (
<input
type="text"
role="combobox"
aria-expanded="true"
aria-autocomplete="list"
aria-activedescendant={ctx.navigation.containerProps["aria-activedescendant"]}
placeholder={local.placeholder}
value={ctx.searchValue()}
onInput={handleInput}
onKeyDown={handleKeyDown}
{...rest}
/>
);
}

View File

@ -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 (
<div
aria-disabled={local.disabled || undefined}
data-disabled={local.disabled || undefined}
{...itemProps()}
onClick={handleClick}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -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 (
<div
role={role}
aria-orientation={ariaOrientation}
onPointerLeave={onPointerLeave}
{...rest}
>
{local.children}
</div>
);
}

View File

@ -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<RegisteredItem[]>([]);
const [searchValue, setSearchInternal] = createControllableSignal<string>({
value: () => local.search,
defaultValue: () => local.defaultSearch ?? "",
onChange: (v) => local.onSearchChange?.(v),
});
const filterEnabled = () => local.filter !== false;
const filteredItems = createMemo<RegisteredItem[]>(() => {
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 (
<CommandPaletteContextProvider
value={{
searchValue,
setSearch,
filteredItems,
registerItem,
unregisterItem,
onActivate,
navigation,
}}
>
{local.children}
</CommandPaletteContextProvider>
);
}

View File

@ -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 <div role="separator" data-part="separator" {...rest} />;
}

View File

@ -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<typeof CommandPaletteRootPropsSchema> {
onValueChange?: (value: string) => void;
onSearchChange?: (search: string) => void;
onSelect?: (value: string) => void;
children: JSX.Element;
}
export interface CommandPaletteInputProps
extends Omit<JSX.InputHTMLAttributes<HTMLInputElement>, "value" | "onInput"> {
placeholder?: string;
}
export interface CommandPaletteListProps extends JSX.HTMLAttributes<HTMLDivElement> {
children: JSX.Element;
}
export interface CommandPaletteItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
value: string;
keywords?: string[];
disabled?: boolean;
onSelect?: () => void;
children?: JSX.Element;
}
export interface CommandPaletteGroupProps extends JSX.HTMLAttributes<HTMLDivElement> {
heading?: string;
children: JSX.Element;
}
export interface CommandPaletteEmptyProps extends JSX.HTMLAttributes<HTMLDivElement> {
children?: JSX.Element;
}
export interface CommandPaletteSeparatorProps extends JSX.HTMLAttributes<HTMLDivElement> {}
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;

View File

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

View File

@ -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(() => (
<CommandPalette>
<CommandPalette.Input placeholder="Search commands..." />
<CommandPalette.List>
<CommandPalette.Item value="copy">Copy</CommandPalette.Item>
<CommandPalette.Item value="paste">Paste</CommandPalette.Item>
</CommandPalette.List>
</CommandPalette>
));
expect(screen.getByPlaceholderText("Search commands...")).toBeTruthy();
expect(screen.getByText("Copy")).toBeTruthy();
expect(screen.getByText("Paste")).toBeTruthy();
});
it("renders groups with headings", () => {
render(() => (
<CommandPalette>
<CommandPalette.Input placeholder="Search..." />
<CommandPalette.List>
<CommandPalette.Group heading="Actions">
<CommandPalette.Item value="save">Save</CommandPalette.Item>
</CommandPalette.Group>
</CommandPalette.List>
</CommandPalette>
));
expect(screen.getByText("Actions")).toBeTruthy();
expect(screen.getByText("Save")).toBeTruthy();
});
it("calls onSelect when item activated", () => {
let selected = "";
render(() => (
<CommandPalette onSelect={(v) => { selected = v; }}>
<CommandPalette.Input />
<CommandPalette.List>
<CommandPalette.Item value="run">Run</CommandPalette.Item>
</CommandPalette.List>
</CommandPalette>
));
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");
});
});