diff --git a/CHANGELOG.md b/CHANGELOG.md index c3adfbe2..f2db32e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ contract — modules that are not exported are internal and may change in any re now an explicit `panel` slot with no fallback — pass ``, ``, or any other content. A caller that omitted `panel` to get the dither canvas for free now gets a bare panel instead. +- **`ui/command` is removed.** `Command` was a second, weaker inline command surface next + to `CommandPaletteInline` — index-based keyboard navigation (which loses the highlighted + row when the list re-sorts under a keystroke) and self-filtering, where the palette + family deliberately leaves matching to the caller. `CommandAction` moves to + `lib/command-registry`, its only consumer; `commandMatches` is gone with the component. + Root-barrel imports of `CommandAction` still work; the `./ui/command` subpath is gone. ## [0.1.0] - 2026-09-17 diff --git a/package.json b/package.json index 28d24833..1d9551d5 100644 --- a/package.json +++ b/package.json @@ -393,10 +393,6 @@ "types": "./dist/ui/checkbox.d.ts", "default": "./dist/ui/checkbox.js" }, - "./ui/command": { - "types": "./dist/ui/command.d.ts", - "default": "./dist/ui/command.js" - }, "./ui/command-palette": { "types": "./dist/ui/command-palette.d.ts", "default": "./dist/ui/command-palette.js" diff --git a/src/index.ts b/src/index.ts index 68ae9ffd..e1a934d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,7 +82,6 @@ export * from "./ui/chat-input.js"; export * from "./ui/chat-panel.js"; export * from "./ui/chat-thread.js"; export * from "./ui/checkbox.js"; -export * from "./ui/command.js"; export * from "./ui/command-palette.js"; export * from "./ui/command-queue.js"; export * from "./ui/compare-body.js"; diff --git a/src/lib/command-registry.tsx b/src/lib/command-registry.tsx index e9abe9ca..508c9cff 100644 --- a/src/lib/command-registry.tsx +++ b/src/lib/command-registry.tsx @@ -1,7 +1,19 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; -import type { CommandAction } from "../ui/command.js"; +/** One thing the user can do. */ +export type CommandAction = { + readonly id: string; + readonly label: string; + /** Section heading. Actions with no group render before any grouped ones. */ + readonly group?: string; + /** Extra words that should find this action — synonyms, the old name for it. */ + readonly keywords?: readonly string[]; + readonly icon?: ReactNode; + /** Displayed hint like "⌘K". Purely a label; this file binds nothing. */ + readonly shortcut?: string; + readonly run: () => void; +}; /** * Lets a page contribute commands to the palette without the palette importing diff --git a/src/ui/command.tsx b/src/ui/command.tsx deleted file mode 100644 index 56f03279..00000000 --- a/src/ui/command.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { useEffect, useId, useMemo, useRef, useState } from "react"; -import type { ReactNode } from "react"; - -import { cn } from "../lib/utils.js"; - -/** One thing the user can do. */ -export type CommandAction = { - readonly id: string; - readonly label: string; - /** Section heading. Actions with no group render before any grouped ones. */ - readonly group?: string; - /** Extra words that should find this action — synonyms, the old name for it. */ - readonly keywords?: readonly string[]; - readonly icon?: ReactNode; - /** Displayed hint like "⌘K". Purely a label; this file binds nothing. */ - readonly shortcut?: string; - readonly run: () => void; -}; - -/** - * Subsequence matching, case-insensitive: "wfr" finds "Workflow runs". - * - * Deliberately not fuzzy-with-scoring. A scored ranker reorders results as you - * type, so the row under your cursor moves out from under it — and in a command - * palette, muscle memory beats cleverness. Order here is the order given. - */ -export function commandMatches(action: CommandAction, query: string): boolean { - const needle = query.trim().toLowerCase(); - if (needle.length === 0) return true; - - const haystacks = [action.label, action.group ?? "", ...(action.keywords ?? [])]; - return haystacks.some((haystack) => { - const text = haystack.toLowerCase(); - let index = 0; - for (const char of needle) { - index = text.indexOf(char, index); - if (index === -1) return false; - index += 1; - } - return true; - }); -} - -export type CommandProps = { - readonly actions: readonly CommandAction[]; - /** Accessible name for the surface, e.g. "Commands". */ - readonly label: string; - /** Called after an action runs — a palette closes itself here. */ - readonly onRun?: (action: CommandAction) => void; - readonly placeholder?: string; - readonly empty?: ReactNode; - readonly className?: string; -}; - -/** - * A filterable, keyboard-driven action list — the primitive behind a command - * palette, a picker, or any "type to narrow, arrow to choose" surface. - * - * Hand-rolled rather than wrapping cmdk. This file is copied into a consumer's - * repo and owned by them, and the behaviour worth changing — the matching — is - * one exported function they can rewrite. A dependency would make the one thing - * every app customises the one thing they cannot touch. - * - * Actions are a prop rather than something items self-register: a flat list the - * component filters is testable as a pure function of its input, and a - * registration protocol would put the render order at the mercy of mount order. - * - * ARIA combobox: focus stays in the input and the highlight is carried by - * `aria-activedescendant`. Moving real focus onto options would take it out of - * the input and stop typing, which is the entire interaction. - */ -export function Command({ - actions, - label, - onRun, - placeholder = "Type a command…", - empty = "No matching commands.", - className, -}: CommandProps) { - const [query, setQuery] = useState(""); - const [activeIndex, setActiveIndex] = useState(0); - const baseId = useId(); - const listRef = useRef(null); - const inputRef = useRef(null); - - const visible = useMemo(() => actions.filter((action) => commandMatches(action, query)), [actions, query]); - - // The highlight is an index into a list that shrinks as you type; clamp it - // rather than letting aria-activedescendant point at an id that is gone. - const active = Math.min(activeIndex, Math.max(visible.length - 1, 0)); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - useEffect(() => { - listRef.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: "nearest" }); - }, [active, query]); - - const run = (action: CommandAction) => { - action.run(); - onRun?.(action); - }; - - const optionId = (index: number) => `${baseId}-option-${index}`; - - // Grouped for display, but the keyboard walks `visible` in order — so the - // index the user is on always means the same row they can see. - const groups = useMemo(() => { - const order: string[] = []; - const byGroup = new Map(); - visible.forEach((action, index) => { - const key = action.group ?? ""; - if (!byGroup.has(key)) { - byGroup.set(key, []); - order.push(key); - } - byGroup.get(key)?.push({ action, index }); - }); - return order.map((key) => ({ heading: key, items: byGroup.get(key) ?? [] })); - }, [visible]); - - return ( -
{ - if (visible.length === 0) return; - if (event.key === "ArrowDown") { - event.preventDefault(); - setActiveIndex((active + 1) % visible.length); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - setActiveIndex((active - 1 + visible.length) % visible.length); - } else if (event.key === "Home") { - event.preventDefault(); - setActiveIndex(0); - } else if (event.key === "End") { - event.preventDefault(); - setActiveIndex(visible.length - 1); - } else if (event.key === "Enter") { - event.preventDefault(); - const action = visible[active]; - if (action !== undefined) run(action); - } - }} - > - { - setQuery(event.target.value); - setActiveIndex(0); - }} - className="h-12 w-full shrink-0 border-b border-border bg-transparent px-4 text-sm outline-none placeholder:text-muted-foreground" - /> - -
    - {visible.length === 0 ? ( -
  • - {empty} -
  • - ) : ( - groups.map((group) => ( -
  • - {group.heading === "" ? null : ( -

    - {group.heading} -

    - )} -
      - {group.items.map(({ action, index }) => ( -
    • setActiveIndex(index)} - onClick={() => run(action)} - className={cn( - "flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-sm", - index === active && "bg-muted", - )} - > - {action.icon === undefined ? null : ( - - {action.icon} - - )} - {action.label} - {action.shortcut === undefined ? null : ( - - {action.shortcut} - - )} -
    • - ))} -
    -
  • - )) - )} -
-
- ); -}