Typeahead
Add typeahead to a list or menu so typing a label focuses its item, with prefix matching, repeated-character cycling and accent handling.
Create a typeahead handler to focus items by typing their labels. In this list, S focuses Spanish and W immediately after it focuses Swedish. After a 500 ms pause, the next character starts a new prefix.
- Arabic
- Bengali
- Chinese
- Czech
- Danish
- Dutch
- English
- Finnish
- French
- German
- Greek
- Hindi
- Hungarian
- Italian
- Japanese
- Korean
- Norwegian
- Polish
- Portuguese
- Romanian
- Russian
- Spanish
- Swedish
- Turkish
<ul id="languages">
<li data-keyrove-item tabindex="0">Arabic</li>
<li data-keyrove-item tabindex="0">Bengali</li>
<li data-keyrove-item tabindex="0">Chinese</li>
<!-- … -->
<li data-keyrove-item tabindex="0">Turkish</li>
</ul>
import { createTypeahead, keyRove } from '@mixedrays/keyrove';
const typeahead = createTypeahead();
document
.querySelector('#languages')
.addEventListener('keydown', (e) => keyRove(e) || typeahead(e));
Call keyRove first so navigation bindings take precedence. It returns null
for an unhandled key, letting typeahead process it. For example, a KeyJ
navigation binding moves focus instead of adding J to the prefix.
Why a factory
The returned handler stores the prefix and the time of the last character.
Create it once per listener, outside the keydown callback. It checks the time
on each press, so there is no timer to clean up. Set resetMs to change the
pause that clears the prefix:
const typeahead = createTypeahead({ resetMs: 800 });
Cycling repeated characters
Prefix matching is the default: pressing S twice looks
for a label starting with ss, and every prefix matches from the top of the
list. A menu can instead cycle through the items sharing one initial character,
the way a native <select> does:
const typeahead = createTypeahead({ matchMode: 'cycle' });
With that mode, a single character moves focus to the next item after the focused one that begins with it, wrapping after the last. Pressing it again moves on rather than growing the buffer, however slowly it is pressed, so repeated S presses alternate between Spanish and Swedish. A different character typed before the reset still refines the prefix, so S W matches Swedish. The one thing the mode gives up is a label that opens with a doubled letter: A A cycles the A items and never looks for "aa".
What counts as typing
Bindings match the physical key, e.code. Typeahead reads e.key, the
character the press produced in the user's layout, so "é" and "ß" work where
the keyboard has them. A press joins the buffer when it is typing and nothing
else:
- a single character, with none of Ctrl, Alt or Meta held. Shift is allowed; it is how capitals are typed, and matching ignores case anyway;
- not inside an editable target, so a field inside an item keeps its letters;
- a space only once the buffer holds a character, so the spacebar keeps scrolling the page and activating buttons while "do n" still reaches Do not disturb below.
Matching is by prefix. A character that matches nothing is left to the
browser but still joins the buffer, so a mistyped prefix goes quiet until the
reset clears it rather than jumping somewhere unexpected. Items carrying
data-keyrove-skip or disabled are never matched.
When the text is not the label
The label is the item's text, trimmed and with runs of whitespace collapsed.
When the text starts with something nobody types, an emoji, an icon's fallback
text, a code, data-keyrove-typeahead names the label instead:
- 🟢 Available
- 🟡 Away
- 🔴 Do not disturb
- ⚫ Appear offline
<ul id="presence">
<li data-keyrove-item data-keyrove-typeahead="Available" tabindex="0">
🟢 Available
</li>
<li data-keyrove-item data-keyrove-typeahead="Away" tabindex="0">🟡 Away</li>
<li data-keyrove-item data-keyrove-typeahead="Do not disturb" tabindex="0">
🔴 Do not disturb
</li>
<li data-keyrove-item data-keyrove-typeahead="Appear offline" tabindex="0">
⚫ Appear offline
</li>
</ul>
A is Available, A W is Away, D is Do not disturb. The attribute is the whole label rather than a prefix added to the text, and an empty one falls back to the text, so a template can set it conditionally.
Accented labels
Accents are ignored on both sides: E reaches Émilie,
A reaches Ángel, and on a keyboard that can type it,
É reaches a plain Emilie too. Each letter is compared
with its marks taken off, whether the label comes from the text, the attribute
or a label function, so no label needs folding by hand. A letter that is not
a base letter plus a mark, such as ø, ł or ß, is matched as itself.
Where an accent is what tells two items apart, turn the folding off:
const typeahead = createTypeahead({ foldDiacritics: false });
What it reports
The handler returns null when it left the key alone and
{ action: 'typeahead', from, to } when it consumed it, with to: null when
the match is already focused or cannot receive focus. onMove fires only
after a successful focus move.
Type "swez" quickly to see each result in the readout:
- S and W are indigo: focus moves to Spanish, then Swedish.
- E is amber, typeahead · moved nothing: "swe" still matches the focused item, so
toisnull. - Z is gray, left to the browser: "swez" matches nothing, so the handler returns
null.
The unmatched character stays in the buffer. Pause for 500 ms to start again.
Both handlers can feed one follow-focus callback, a preview pane for one:
const onMove = ({ to }) => showPreview(to);
const typeahead = createTypeahead({ onMove });
list.addEventListener('keydown', (e) => keyRove(e, { onMove }) || typeahead(e));
The roving tab stop moves with the match, as it does for an arrow move.
Several groups, one handler
The buffer clears when the next typing key resolves to a different root. One handler can therefore serve several groups under a delegated listener without sharing prefixes between them. See several groups, one listener.