Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2f0580a
fix: never match a combo with no code, and read blank bindings as unset
mixedrays Sep 22, 2026
ebfdb14
feat: switch a move off with 'none' value, from its attribute or the …
mixedrays Sep 22, 2026
1f51379
feat: accept a comma-separated list of combos per move
mixedrays Sep 22, 2026
fccaf3e
fix: report no move when the target does not take focus
mixedrays Sep 22, 2026
ae9672d
feat: count a CSS grid's columns with data-keyrove-cols="auto"
mixedrays Sep 22, 2026
cb1ea71
feat: ignore accents in typeahead matching
mixedrays Sep 22, 2026
ab5f153
feat: add initRovingTabindex to give a roving group one tab stop
mixedrays Sep 22, 2026
60f63b0
feat: add followFocus to move the roving tab stop on focusin
mixedrays Sep 22, 2026
fff9e67
fix: tell a document or window listener apart by value, not by name
mixedrays Sep 22, 2026
683e361
feat: let initRovingTabindex take the item to hold the tab stop
mixedrays Sep 22, 2026
3b0f040
docs: update docs, README and package.json descriptions for clarity a…
mixedrays Sep 22, 2026
bee4d05
docs: update docs, README and package.json descriptions for clarity a…
mixedrays Sep 22, 2026
9a20785
feat: add rove to make a move from code without a keypress
mixedrays Sep 22, 2026
aeb7599
feat: add exit and enter keys to move between nested roots
mixedrays Sep 22, 2026
05fab60
feat: implement attribute builders for root and item settings with ty…
mixedrays Sep 22, 2026
7f6603f
fix: keep each nested group's roving tab stop when navigation crosses…
mixedrays Sep 22, 2026
25624b9
fix: find the focused item inside a shadow root
mixedrays Sep 22, 2026
d187688
fix: skip keydown events another handler already consumed
mixedrays Sep 22, 2026
ca715cb
feat: never land on a skipped item, even when every item is skipped
mixedrays Sep 22, 2026
2065477
docs: complete the attribute and option differences
mixedrays Sep 22, 2026
b0ef55d
feat: export KeyRoveOptions and KeyCombo type names
mixedrays Sep 22, 2026
6ae8949
docs: add a complete roving setup recipe
mixedrays Sep 22, 2026
158f72f
chore: add a browser benchmark for keydown handling
mixedrays Sep 22, 2026
2a6b30d
docs: implement documentation search functionality with MiniSearch
mixedrays Sep 22, 2026
bb70faf
docs: enhance documentation on search functionality and navigation be…
mixedrays Sep 22, 2026
e5ba17e
docs: navigate search results with keyrove (dogfooding)
mixedrays Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pnpm-store/v10/projects/f8319dbbd94c7aa820b9caf9498e7396
23 changes: 23 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ Run from the repo root:
| `pnpm lint` | Checks formatting across the workspace with Prettier. |
| `pnpm format` | Rewrites files to Prettier style. |
| `pnpm build` | Builds every package. |
| `pnpm bench` | Times `keyRove` in headless Chrome. |
| `pnpm typecheck` | Type-checks every package. |
| `pnpm preview` | Serves the built docs site. |

The benchmark's method and recorded results are in
[packages/keyrove/bench](packages/keyrove/bench/README.md).

Any script can be aimed at one package with a filter:

```sh
Expand Down Expand Up @@ -71,6 +75,25 @@ The served markdown is not the file verbatim: frontmatter is site plumbing, so
it is replaced by the `title` as an H1 and the `description` as a blockquote,
leaving a document that stands on its own when fetched in isolation.

### Documentation search

The header search button and `Cmd+K` / `Ctrl+K` open a native dialog. MiniSearch
and `search-index.json` load on first use. The index contains docs page leads
and individual sections, with heading IDs allocated by the same parser as the
rendered pages; landing and `noindex` pages are excluded. Titles and headings
rank above body matches, with prefix matching and typo tolerance enabled.

The results are a keyrove group: the site navigating with its own library.
Each result is a link, `initRovingTabindex` gives the list one tab stop after
every render, `keyRove` moves focus between the links and `followFocus` keeps
the stop in step. keyrove leaves a text field its caret keys, so the box
focuses an end of the list itself to hand focus over, and a character typed on
a result sends focus back to the box.

The Vite plugin generates the index in development and production. Markdown
edits invalidate the development cache and reload the page, including search.
Run the search indexing tests with `pnpm --filter @mixedrays/keyrove/docs test`.

### Live demos

A content file embeds a demo with `<div data-demo="grid"></div>`. The markup
Expand Down
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
[![minzipped size](https://img.shields.io/bundlejs/size/%40mixedrays%2Fkeyrove?color=4f46e5&label=minzipped%20size)](https://bundlejs.com/?q=%40mixedrays%2Fkeyrove)
[![license](https://img.shields.io/npm/l/@mixedrays/keyrove?color=4f46e5)](https://github.com/mixedrays/keyrove/blob/main/LICENSE)

Framework-agnostic keyboard navigation for lists, grids and trees, driven by
`data-*` attributes or a plain options object. Any key can move focus — arrows
are only the default — and native <kbd>Tab</kbd> navigation keeps working.
Keyboard navigation for lists, grids and trees. Configure it with data
attributes or JavaScript options, in any framework.

## Getting started

Expand All @@ -31,20 +30,22 @@ import { keyRove } from '@mixedrays/keyrove';
document.querySelector('#menu').addEventListener('keydown', (e) => keyRove(e));
```

Where the markup is not yours to change — a component library's menu, a CMS's
output — every attribute has an option of the same name, so the same list can
be described in the call instead:
Use an `items` option when you cannot add attributes to the markup. This
selects the same list items without `data-keyrove-item`; keep their tabindex:

```ts
document
.querySelector('#menu')
.addEventListener('keydown', (e) => keyRove(e, { items: 'li' }));
```

`keyRove` takes anything shaped like a keydown event, so React, Vue and Svelte
synthetic events work without an adapter. The
[installation guide](https://keyrove.pages.dev/docs/installation) shows the
wiring in each framework.
Options override attributes one setting at a time, with
[scope and replacement differences](https://keyrove.pages.dev/docs/attributes-and-options#configuration-differences).

`keyRove` accepts native keyboard events and compatible framework events,
including React synthetic events. The
[installation guide](https://keyrove.pages.dev/docs/installation) shows setup
for vanilla JavaScript, React, Vue and Svelte.

## Documentation

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"lint": "prettier --check .",
"format": "prettier --write .",
"build": "pnpm -r build",
"bench": "pnpm --filter @mixedrays/keyrove bench",
"typecheck": "pnpm -r typecheck",
"preview": "pnpm --filter @mixedrays/keyrove/docs preview",
"release": "pnpm --filter @mixedrays/keyrove release"
Expand Down
4 changes: 4 additions & 0 deletions packages/docs/build/icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
LayoutGrid,
Menu,
Moon,
Search,
Sun,
X,
type IconNode,
} from 'lucide';

Expand Down Expand Up @@ -55,6 +57,8 @@ const fromLucide = (node: IconNode): IconDef => ({
});

const ICONS = {
search: fromLucide(Search),
close: fromLucide(X),
menu: fromLucide(Menu),
book: fromLucide(BookOpen),
keyboard: fromLucide(Keyboard),
Expand Down
35 changes: 32 additions & 3 deletions packages/docs/build/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,14 @@ const renderHeader = (resolveHref: HrefResolver, page: Page) => {
${link(resolveHref('/'), `${icon('keyboard', 'size-6')}keyrove`, 'wordmark')}
</div>
<div class="flex items-center gap-1 sm:gap-4">
<nav class="hidden items-center gap-4 text-sm sm:flex sm:gap-6">
<button type="button" class="search-trigger" data-search-open hidden
aria-label="Search documentation" aria-haspopup="dialog" aria-controls="docs-search"
aria-keyshortcuts="Meta+k Control+k">
${icon('search', 'size-4')}
<span class="hidden lg:inline">Search</span>
<kbd class="hidden sm:inline" data-search-shortcut>Ctrl K</kbd>
</button>
<nav class="hidden items-center gap-4 text-sm md:flex md:gap-6">
${link(resolveHref('/docs/introduction'), `${icon('book', 'size-4')}Docs`, 'header-link')}
${link(resolveHref('/docs/examples/basic'), `${icon('grid', 'size-4')}Examples`, 'header-link')}
${link(resolveHref('/docs/api'), `${icon('braces', 'size-4')}API`, 'header-link')}
Expand All @@ -96,7 +103,7 @@ const renderHeader = (resolveHref: HrefResolver, page: Page) => {
<!-- The nav is hidden on narrow screens, so the repo keeps an
icon-only stop in the header there rather than dropping out of
it entirely. -->
${link(META.repoUrl, icon('github', 'size-4'), 'icon-button sm:hidden', ' aria-label="keyrove on GitHub"')}
${link(META.repoUrl, icon('github', 'size-4'), 'icon-button md:hidden', ' aria-label="keyrove on GitHub"')}
<!-- Both glyphs ship; style.css shows one per theme. Picking in
script would mean an empty button until the bundle ran, and the
theme is not known until the inline head script has run anyway. -->
Expand All @@ -111,6 +118,28 @@ const renderHeader = (resolveHref: HrefResolver, page: Page) => {
</header>`;
};

const renderSearch =
() => `<dialog id="docs-search" class="search-dialog" aria-labelledby="search-title">
<div class="search-panel">
<h2 id="search-title" class="sr-only">Search documentation</h2>
<div class="search-input-row">
${icon('search', 'size-5')}
<label for="search-input" class="sr-only">Search documentation</label>
<input id="search-input" type="text" autofocus
placeholder="Search documentation…" autocomplete="off" spellcheck="false"
aria-describedby="search-help" enterkeyhint="go" />
<button type="button" class="icon-button" data-search-close aria-label="Close search">
${icon('close', 'size-4')}
</button>
</div>
<!-- keyrove moves focus to native links; the input is not a combobox. -->
<ul id="search-results" class="search-results" aria-label="Search results"></ul>
<p class="search-status" data-search-status role="status" aria-live="polite" aria-atomic="true"></p>
<button type="button" class="search-retry" data-search-retry hidden>Retry search</button>
<p id="search-help" class="search-help"><span><kbd class="kbd">↑</kbd> <kbd class="kbd">↓</kbd> to select</span><span><kbd class="kbd">Enter</kbd> to open</span><span><kbd class="kbd">Esc</kbd> to close</span></p>
</div>
</dialog>`;

const renderSidebar = (
nav: NavGroup[],
current: Page,
Expand Down Expand Up @@ -418,6 +447,6 @@ export const renderPage = (template: string, render: PageRender) => {
template
.replace(SLOTS.head, head)
.replace(SLOTS.styles, renderStylesheet(render.stylesheet))
.replace(SLOTS.body, body),
.replace(SLOTS.body, `${body}${renderSearch()}`),
);
};
37 changes: 35 additions & 2 deletions packages/docs/build/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,13 @@ const tabLabel = (info: string) => {
/** The visible text of a heading, with the markdown syntax dropped. */
const plainText = (token: Token): string =>
(token.children ?? [])
.filter((child) => child.type === 'text' || child.type === 'code_inline')
.map((child) => child.content)
.map((child) =>
child.type === 'softbreak' || child.type === 'hardbreak'
? ' '
: child.type === 'text' || child.type === 'code_inline'
? child.content
: '',
)
.join('');

const slugify = (text: string) =>
Expand Down Expand Up @@ -112,6 +117,34 @@ const collectHeadings = (tokens: Token[]): Heading[] => {
return headings;
};

/** Search uses the same parser and ID allocator, without loading Shiki. */
const searchParser = MarkdownIt({ html: true, linkify: true });

export const collectSearchSections = (source: string) => {
const tokens = searchParser.parse(source, {});
collectHeadings(tokens);
const sections = [{ id: '', heading: '', text: '' }];
for (let index = 0; index < tokens.length; index++) {
const token = tokens[index];
if (token.type === 'heading_open' && ANCHORED_TAGS.has(token.tag)) {
sections.push({
id: String(token.attrGet('id')),
heading: plainText(tokens[index + 1]),
text: '',
});
index += 2;
} else if (token.type === 'inline') {
sections[sections.length - 1].text += `${plainText(token)} `;
} else if (token.type === 'fence' || token.type === 'code_block') {
sections[sections.length - 1].text += `${token.content} `;
}
}
return sections.map((section) => ({
...section,
text: section.text.replace(/\s+/g, ' ').trim(),
}));
};

/**
* Rewrites the site-absolute links authors write in markdown.
*
Expand Down
32 changes: 32 additions & 0 deletions packages/docs/build/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import MiniSearch from 'minisearch';

import { searchOptions, type SearchDocument } from '../src/search-model.ts';
import type { Page } from './content.ts';
import { expandDemos, type Demos } from './demos.ts';
import { collectSearchSections } from './markdown.ts';
import { expandMeta } from './meta.ts';

export const toSearchIndex = (pages: Page[], demos: Demos): string => {
const documents: SearchDocument[] = pages
.filter((page) => page.layout === 'docs' && !page.noindex)
.flatMap((page) =>
collectSearchSections(
expandMeta(expandDemos(page.body, demos, 'markdown')),
).map((section) => {
const url = `/${page.route}${section.id ? `#${section.id}` : ''}`;
return {
id: url,
url,
title: page.title,
heading: section.heading,
text: section.id
? section.text
: `${page.description} ${section.text}`.trim(),
};
}),
);

const index = new MiniSearch<SearchDocument>(searchOptions);
index.addAll(documents);
return JSON.stringify(index);
};
1 change: 1 addition & 0 deletions packages/docs/content/_demos/nested.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
data-keyrove-root
data-keyrove-next-key="ArrowRight"
data-keyrove-prev-key="ArrowLeft"
data-keyrove-exit-key="Escape"
>
<button data-keyrove-item tabindex="0">👍</button>
<button data-keyrove-item tabindex="0">❤️</button>
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/content/_demos/responsive.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
}
</style>

<div id="months" data-keyrove-root data-keyrove-cols="2">
<div id="months" data-keyrove-root data-keyrove-cols="auto">
<button data-keyrove-item tabindex="0">January</button>
<button data-keyrove-item tabindex="0">February</button>
<button data-keyrove-item tabindex="0">March</button>
Expand Down
15 changes: 7 additions & 8 deletions packages/docs/content/docs/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@ group: Guide
order: 5
---

keyrove is a framework-agnostic, dependency-free library for keyboard navigation
in lists, grids and trees; the [introduction](/docs/introduction) explains what
it does and how it fits together. It is developed in the open under the MIT
licence, and issues and pull requests are welcome.
keyrove provides keyboard navigation for lists, grids and trees, in any
framework and with no runtime dependencies. See the [introduction](/docs/introduction)
for setup and behavior. The project is open source under the MIT license;
issues and pull requests are welcome.

## This site

<div data-about></div>

Every page is also available as markdown — append `.md` to any URL, or use
**View as Markdown** in the right-hand rail. The pages are generated from the
files under `packages/docs/content` in the same repository, which is what the
**View source** link on each page opens.
To read a page as Markdown, append `.md` to its URL or choose **View as
Markdown** in the right-hand rail. **View source** opens its source file under
`packages/docs/content` in the repository.
Loading
Loading