From 4af0944360613707b0a6bfdb19716e15d3c50975 Mon Sep 17 00:00:00 2001 From: wojtekolek Date: Thu, 1 Oct 2026 13:20:42 +0200 Subject: [PATCH 1/3] Rework commandmenu hook: ordered list, ARIA, perf and fixes - Single ordered item list drives both rendering and keyboard order - Combobox/listbox/option ARIA wiring via searchProps, listProps, itemProps - Selection follows items by id; scrolling only the list, on keyboard moves - Keyboard: IME, key repeat, modifier and layout-aware shortcut handling - Stable item and group identity: typing and async results re-render only the rows that change - Types: distributive PreparedItem, separate async result type parameter - Internals split into list, selection, keyboard and scroll modules - Ship README, LICENSE and CHANGELOG in the package; drop committed dist - Tests (render counts and type-level), benchmark, typecheck script Co-Authored-By: Claude Opus 5.5 --- .changeset/stable-order-aria.md | 61 + .gitignore | 4 + LICENSE | 21 + README.md | 231 ++- packages/commandmenu/README.md | 1 - packages/commandmenu/dist/index.d.ts | 73 - packages/commandmenu/dist/index.js | 170 --- packages/commandmenu/dist/index.mjs | 148 -- packages/commandmenu/package.json | 21 +- packages/commandmenu/src/index.ts | 9 +- packages/commandmenu/src/keyboard.ts | 42 + packages/commandmenu/src/list.ts | 125 ++ packages/commandmenu/src/scroll.ts | 42 + packages/commandmenu/src/selection.ts | 54 + packages/commandmenu/src/types.ts | 105 +- .../commandmenu/src/useCommandMenu.bench.tsx | 97 ++ .../commandmenu/src/useCommandMenu.test.tsx | 1265 +++++++++++++---- packages/commandmenu/src/useCommandMenu.ts | 364 +++-- packages/commandmenu/tsconfig.json | 4 +- 19 files changed, 1937 insertions(+), 900 deletions(-) create mode 100644 .changeset/stable-order-aria.md create mode 100644 LICENSE delete mode 120000 packages/commandmenu/README.md delete mode 100644 packages/commandmenu/dist/index.d.ts delete mode 100644 packages/commandmenu/dist/index.js delete mode 100644 packages/commandmenu/dist/index.mjs create mode 100644 packages/commandmenu/src/keyboard.ts create mode 100644 packages/commandmenu/src/list.ts create mode 100644 packages/commandmenu/src/scroll.ts create mode 100644 packages/commandmenu/src/selection.ts create mode 100644 packages/commandmenu/src/useCommandMenu.bench.tsx diff --git a/.changeset/stable-order-aria.md b/.changeset/stable-order-aria.md new file mode 100644 index 0000000..f4e7bc5 --- /dev/null +++ b/.changeset/stable-order-aria.md @@ -0,0 +1,61 @@ +--- +"commandmenu": minor +--- + +Rework the hook around a single ordered item list and add ARIA wiring. + +Breaking: + +- Items now expose `itemProps` (`id`, `role`, `aria-disabled`, `onClick`, `onMouseDown`, `onPointerMove`) instead of top-level `onClick`/`onPointerMove`. Spread it onto the item element. +- `selection` is replaced by `selectedId`. Scrolling needs no ref wiring from you: `listProps` now carries a `ref`, which the hook uses. +- `Group` now accepts either an item type or a config array type. +- Without `groups`, async results are appended to the flat list instead of replacing it with a single group. +- `PreparedItem` keeps `disabled` so you can style it. +- Arrow keys and Enter are handled only when pressed in the search input (or on the menu element itself). Other controls inside the menu keep their own keys. +- Cmd/Ctrl and Alt combinations that match no shortcut are left to the browser. For example, Cmd+ArrowDown no longer moves the highlight. +- Shortcuts match the character the key types first, then the physical key. +- Item ids must be unique. An id repeated across groups or async results now appears only once, at its first occurrence. +- `menuProps.onKeyUp` is always a function and calls your latest `onKeyUp`. +- `isAsyncLoading` is removed, and `AsyncResultsGroup` no longer takes `isLoading`. Both only handed back the value you passed in; keep the loading state in your own component. + +Fixes: + +- Keyboard order now follows the rendered (grouped) order. Items missing from every group are no longer navigable and their shortcuts don't fire. +- Enter and shortcuts no longer trigger disabled items. Arrow keys skip disabled items. A disabled item no longer hides a later enabled item that has the same shortcut. +- ArrowUp works right after the list shrinks. +- The highlight follows its item by id, so async results arriving above it no longer move it to a different item. +- Pressing an item no longer moves focus out of the search input, so the keyboard keeps working after a click. +- Scrolling happens after keyboard moves and query resets, never on hover. It scrolls only the nearest scrollable container, never the page, and works inside shadow roots and iframes. It honours `scroll-margin` and `scroll-padding`. +- IME: + - Keys pressed during composition are ignored. + - So is the Enter that commits a composition, which Safari reports only as keyCode 229. +- Held-down Enter and shortcuts no longer select repeatedly. +- Shortcuts work with digits, with non-QWERTY and non-Latin layouts, and case-insensitively. Alt combinations don't trigger them. +- `onSearchChange("")` fires when the query is reset after a selection. +- Consumer `onKeyDown` runs before shortcut matching, so it can intercept. +- Inline `onKeyDown`, `onKeyUp` and `onSearchChange` no longer rebuild the list. `menuProps` and `listProps` keep their identity across renders. +- `list` no longer rebuilds when `asyncResultsGroup` is recreated around the same `items` array, or around a fresh empty one. +- Option DOM ids can't collide with the listbox id, and stay valid for item ids that contain spaces. +- `isGroupList` is no longer fooled by items that carry their own `items` field. +- Types: + - `PreparedItem` distributes over unions, so discriminated item types keep each variant's fields. + - `as const` configs keep the optional `Config` fields readable. + - Async results get their own type parameter, so they work together with an `as const` config and typed `groups`. +- The published package now includes the README and the LICENSE. + +Performance: + +- Each item is prepared once and keeps its identity while the query changes. Typing no longer re-renders memoized rows that stay in the list; widening the query renders only the rows that come back. +- Async results arriving render only the new rows. Groups whose rows didn't change keep their identity. +- Labels are lowercased once per config instead of on every keystroke, and the display order is computed once per `config`/`groups` rather than per keystroke. +- These interactions no longer render at all: + - hovering the highlighted item, which before cost an extra render after a change + - pressing an arrow key at the end of the list + - selecting while the menu is already reset +- The first keystroke renders once instead of twice. + +Added: + +- `useCommandMenu` is generic over the item type. Extra fields on items are preserved in `PreparedItem`. +- `searchProps` carries combobox ARIA attributes, `listProps` carries `role="listbox"`, its `id` and a `ref`. +- `sideEffects: false` and per-format type entries in `exports`. diff --git a/.gitignore b/.gitignore index 06d9eeb..305bd72 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ yarn-error.log* # turbo .turbo + +# copied from the repo root at pack time (see packages/commandmenu "prepack") +packages/commandmenu/README.md +packages/commandmenu/LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8d3af52 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-present Wojtek Olek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7eebfae..3c2a5a4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CommandMenu -A headless React hook for building command menus. It handles search, keyboard navigation, shortcuts, and selection — you bring your own UI. +A headless React hook for building command menus. It handles search, keyboard navigation, shortcuts, selection, scrolling and ARIA wiring. You bring your own UI. Demo: [commandmenu.wojtekolek.com](https://commandmenu.wojtekolek.com/) @@ -34,23 +34,22 @@ const config = [ shortcut: "F", onSelect: () => console.log("search"), }, -] as const satisfies Config[]; +] as const satisfies readonly Config[]; const CommandMenu = () => { - const { menuProps, searchProps, list, selection } = useCommandMenu({ config }); + const { menuProps, searchProps, listProps, list, selectedId } = useCommandMenu({ config }); return (
-
    +
      {list.map((item) => { - const isSelected = item.id === selection.id; + const isSelected = item.id === selectedId; return (
    • {item.label} @@ -60,12 +59,46 @@ const CommandMenu = () => {
); -} +}; +``` + +`menuProps` goes on the element that wraps the input and the list. It carries the keyboard handlers. `searchProps` turns the input into an ARIA combobox, `listProps` marks the list as its listbox, and `item.itemProps` marks each row as an option with a stable DOM id. Pressing a row doesn't take focus away from the input, so the keyboard keeps working after a click. + +`listProps` carries a `ref` the hook uses to scroll the highlighted option into view. If you need your own ref on the list too, merge the two. Item `id`s must be unique across `config` and async results; if one repeats, only its first occurrence is kept. + +### Keyboard + +| Key | Action | +|-----|--------| +| `ArrowUp` / `ArrowDown` | Move the highlight, skipping disabled items | +| `Enter` | Select the highlighted item. Held-down repeats are ignored | +| `Cmd`/`Ctrl` + `shortcut` | Select the item with that `shortcut`, from anywhere in the menu | + +Arrow keys and Enter are handled only when they're pressed in the search input (or on the menu element itself), so other controls inside the menu, like a button, keep their own keys. Keys pressed during IME composition are left to the IME. That includes the Enter that commits a composition, which Safari reports only as `keyCode` 229. Other Cmd/Ctrl and Alt combinations, like Cmd+ArrowDown, are left to the browser and the input. So are `Home`/`End`, which keep moving the caret. + +`shortcut` is matched case-insensitively against the character the key types, then against the physical key. So `"F"` fires on Cmd+F or Ctrl+F, follows layouts like AZERTY, and still works on non-Latin layouts. Digits work too (`"1"`). Prefix a shortcut with `"⇧ "` to require Shift, e.g. `"⇧ S"`. Shortcuts ignore Alt and key repeat, skip disabled items, and only cover items that are on the menu: with `groups`, an item in no group has no shortcut. If two enabled items share a shortcut, the first one wins. + +## Custom item fields + +`useCommandMenu` is generic over the item type. Any extra field you put on a config item is preserved, fully typed, on the corresponding `PreparedItem`. Only `onSelect` is stripped. A union of item types keeps each variant's fields, so you can narrow on a discriminant. The optional `Config` fields (`description`, `shortcut`, `icon`, `disabled`) stay readable on items that don't set them, which matters for `as const` configs. + +```tsx +import type { Config } from "commandmenu"; +import type { ElementType } from "react"; + +type Item = Config & { icon: ElementType; keywords?: string[] }; + +const config: Item[] = [ + { id: "home", label: "Home", icon: HomeIcon, onSelect: () => {} }, +]; + +const { list } = useCommandMenu({ config }); +list[0].icon; // ElementType ``` ## Grouping -Pass a `groups` array to organize items into sections. Each group references item IDs from your config. +Pass a `groups` array to organize items into sections. Each group references item IDs from your config. Rendering order and keyboard order both follow the groups. Items that appear in no group are not shown and cannot be selected. ```tsx import { type Config, type Group, isGroupList, useCommandMenu } from "commandmenu"; @@ -75,17 +108,16 @@ const config = [ { id: "about", label: "About", onSelect: () => {} }, { id: "new-file", label: "New File", shortcut: "N", onSelect: () => {} }, { id: "settings", label: "Settings", onSelect: () => {} }, -] as const satisfies Config[]; +] as const satisfies readonly Config[]; -type MyConfig = typeof config; - -const groups: Group[] = [ +// `Group` restricts `items` to the ids above. +const groups: Group[] = [ { id: "nav", label: "Navigation", items: ["home", "about"] }, { id: "actions", label: "Actions", items: ["new-file", "settings"] }, ]; const CommandMenu = () => { - const { menuProps, searchProps, list, selection } = useCommandMenu({ + const { menuProps, searchProps, listProps, list, selectedId } = useCommandMenu({ config, groups, }); @@ -93,37 +125,60 @@ const CommandMenu = () => { return (
-
    - {isGroupList(list) && - list.map((group) => ( -
  • -
    {group.label}
    -
      - {group.items.map((item) => { - const isSelected = item.id === selection.id; - return ( -
    • - {item.label} -
    • - ); - })} -
    -
  • - ))} +
      + {list.map((group) => ( +
    • + +
        + {group.items.map((item) => ( +
      • + {item.label} +
      • + ))} +
      +
    • + ))}
); -} +}; +``` + +When `groups` is always present, `list` is typed as `PreparedGroup[]`. When it may be `undefined`, `list` is a union and `isGroupList(list)` narrows it. + +The `group` role sits on the inner `