CommandPalette component
This commit is contained in:
parent
de1c9a1cb8
commit
1106c2d020
@ -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;
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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} />;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
19
packages/core/src/components/command-palette/index.ts
Normal file
19
packages/core/src/components/command-palette/index.ts
Normal 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";
|
||||||
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user