diff --git a/.changeset/README.md b/.changeset/README.md index e5b6d8d..5fe687f 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -6,3 +6,20 @@ find the full documentation for it [in our repository](https://github.com/change We have a quick list of common questions to get you started engaging with this project in [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) + +## Releasing `commandmenu` + +Releases take two merges. That's how Changesets works, not a fault: + +1. A PR that should ship carries a changeset: run `pnpm changeset`, pick the bump, describe the change, and commit the `.changeset/*.md` file it writes. +2. When that PR lands on `master`, the Release workflow doesn't publish yet. It opens (or updates) a **"Version Packages"** PR from `changeset-release/master`. That PR bumps `packages/commandmenu/package.json` and writes `CHANGELOG.md`. +3. Merging the "Version Packages" PR runs the Release workflow again. With no changesets left, it builds, lints, typechecks and tests the package, then publishes to npm through trusted publishing and pushes the `commandmenu@x.y.z` tag and GitHub release. + +Pushes without a changeset publish only if `package.json` holds a version npm doesn't have yet, which is how the 0.5.x releases went out. + +The workflow needs these to be in place: + +- Settings → Actions → General → Workflow permissions: "Allow GitHub Actions to create and approve pull requests" is on, or step 2 fails. +- npm → `commandmenu` → Settings → Trusted publishing points at `wojtekolek/commandmenu`, workflow `release.yml`, environment `Production`. + +The "Version Packages" PR is opened with the workflow's own token, so CI doesn't run on it. Don't make CI checks required on `master` without also giving the Release workflow a token that can trigger them. 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 820222b..bf946ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,11 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version-file: package.json cache: pnpm @@ -32,11 +32,11 @@ jobs: name: Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version-file: package.json cache: pnpm @@ -49,15 +49,17 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version-file: package.json cache: pnpm - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9019ab9..97a026b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,11 +16,11 @@ jobs: id-token: write environment: Production steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version-file: package.json cache: pnpm 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 `