Basic Input
A labelled text input with helper text wired to it via aria-describedby.
6 frameworksBeginner
The complete keyboard contract - arrows, Home/End, Enter, two-stage Escape and Backspace.
<!--
The full keyboard contract, written out. Every other combobox in this category
implements a subset; this one is the reference.
↓ / ↑ move the highlight, wrapping at both ends
Home / End first / last result - absolute, not per screenful
Enter toggle the highlighted row; the list stays open
Escape TWO stages: clear the query first, close only if empty
Backspace on an empty query, remove the last chip
Tab close and move on - never trap focus
Two of those deserve their reasons spelled out:
ESCAPE IS TWO-STAGE. One press to undo the filter, a second to leave. A single
Escape that closes a popup holding a half-typed query throws away work to
perform a lesser action - the user wanted their list back, not their field
gone. Every good picker on the platform does this and no spec mandates it.
WRAPPING. ↓ at the bottom lands on the top. In a list you filter down to two
rows, wrapping is the difference between a flick and a count.
The shortcuts are also PRINTED, in a legend wired up with aria-describedby. An
undiscoverable shortcut is a shortcut that does not exist.
-->
<div class="mkeys">
<label class="mkeys__label" for="mkeys-input">Commands</label>
<div class="mkeys__control">
<span class="mkeys__chip">Deploy</span>
<input
class="mkeys__input"
id="mkeys-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="mkeys-listbox"
aria-autocomplete="list"
aria-describedby="mkeys-help mkeys-status"
autocomplete="off"
placeholder="Type to filter…"
/>
</div>
<p class="mkeys__help" id="mkeys-help">
<kbd class="mkeys__kbd">↑</kbd><kbd class="mkeys__kbd">↓</kbd> move ·
<kbd class="mkeys__kbd">Home</kbd><kbd class="mkeys__kbd">End</kbd> jump ·
<kbd class="mkeys__kbd">Enter</kbd> toggle ·
<kbd class="mkeys__kbd">Esc</kbd> clear, then close ·
<kbd class="mkeys__kbd">⌫</kbd> remove last
</p>
<p class="mkeys__status" id="mkeys-status" role="status" aria-live="polite">6 commands available. 1 selected.</p>
<ul class="mkeys__listbox" id="mkeys-listbox" role="listbox" aria-multiselectable="true" aria-label="Commands" hidden></ul>
</div>
<script>
document.querySelectorAll('.mkeys').forEach(function (root) {
var DATA = ['Build', 'Deploy', 'Rollback', 'Restart', 'Scale up', 'Scale down'];
var input = root.querySelector('.mkeys__input');
var listbox = root.querySelector('.mkeys__listbox');
var control = root.querySelector('.mkeys__control');
var status = root.querySelector('.mkeys__status');
var chosen = ['Deploy'];
var results = DATA.slice();
var active = 0;
function optionId(index) { return 'mkeys-opt-' + index; }
function renderChips() {
Array.prototype.slice.call(root.querySelectorAll('.mkeys__chip')).forEach(function (c) { c.remove(); });
chosen.forEach(function (name) {
var chip = document.createElement('span');
chip.className = 'mkeys__chip';
chip.textContent = name;
control.insertBefore(chip, input);
});
}
function renderList() {
var q = input.value.trim().toLowerCase();
results = DATA.filter(function (item) { return item.toLowerCase().includes(q); });
if (active >= results.length) active = Math.max(0, results.length - 1);
listbox.innerHTML = '';
if (!results.length) {
var empty = document.createElement('li');
empty.setAttribute('role', 'presentation');
empty.className = 'mkeys__empty';
empty.textContent = 'No commands match “' + input.value + '”.';
listbox.appendChild(empty);
} else {
results.forEach(function (name, index) {
var li = document.createElement('li');
li.id = optionId(index);
li.className = 'mkeys__option' + (index === active ? ' mkeys__option--active' : '');
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', String(chosen.includes(name)));
li.innerHTML =
'<span class="mkeys__marker" aria-hidden="true"></span>' +
'<span class="mkeys__option-label">' + name + '</span>' +
'<span class="mkeys__tick" aria-hidden="true">✓</span>';
li.addEventListener('mousedown', function (event) {
event.preventDefault();
toggle(name);
});
li.addEventListener('mousemove', function () { active = index; renderList(); });
listbox.appendChild(li);
});
}
listbox.setAttribute('aria-activedescendant', results.length ? optionId(active) : '');
listbox.hidden = input.getAttribute('aria-expanded') !== 'true';
status.textContent =
(results.length
? results.length + (results.length === 1 ? ' command' : ' commands') + ' available.'
: 'No commands match.') + ' ' + chosen.length + ' selected.';
if (results[active]) {
var el = listbox.querySelector('#' + optionId(active));
if (el) el.scrollIntoView({ block: 'nearest' });
}
}
function render() { renderChips(); renderList(); }
function toggle(name) {
var at = chosen.indexOf(name);
if (at === -1) chosen.push(name);
else chosen.splice(at, 1);
render();
}
function setOpen(open) {
input.setAttribute('aria-expanded', String(open));
listbox.hidden = !open;
if (open) renderList();
}
input.addEventListener('focus', function () { setOpen(true); });
input.addEventListener('input', function () {
active = 0;
setOpen(true);
});
input.addEventListener('keydown', function (event) {
var key = event.key;
var open = input.getAttribute('aria-expanded') === 'true';
if (key === 'Escape') {
event.preventDefault();
// Stage one: give the list back. Stage two: leave.
if (input.value) {
input.value = '';
active = 0;
renderList();
} else {
setOpen(false);
}
return;
}
if (key === 'Tab') {
// Never swallowed - the browser is moving focus on and stealing it back
// would trap the user in the control.
setOpen(false);
return;
}
if (key === 'Backspace' && !input.value && chosen.length) {
event.preventDefault();
chosen.pop();
render();
return;
}
if (!open && (key === 'ArrowDown' || key === 'ArrowUp')) {
event.preventDefault();
setOpen(true);
return;
}
if (!open || !results.length) return;
if (key === 'Enter') {
event.preventDefault();
toggle(results[active]);
return;
}
var next = active;
if (key === 'ArrowDown') next = active + 1;
else if (key === 'ArrowUp') next = active - 1;
else if (key === 'Home') next = 0;
else if (key === 'End') next = results.length - 1;
else return;
event.preventDefault();
// Wrapping: ↓ at the bottom lands on the top. In a two-row filtered list
// that is the difference between a flick and a count.
active = (next + results.length) % results.length;
renderList();
});
document.addEventListener('mousedown', function (event) {
if (!root.contains(event.target)) setOpen(false);
});
render();
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | - | Accessible label announced while loading. |
itemsrequired | SelectItem[] | - | The array of items to render. |
valuerequired | string[] | - | The metric's current value, pre-formatted. |
onSelect | (next: string[]) => void | - | Fired with the menu item the user chose. |
disabled | boolean | false | Prevents interaction and dims the control. |
Every other combobox in this category implements a subset of this one. Two behaviours are worth lifting wholesale. Escape is two-stage: one press clears the query, a second closes - a single Escape that dismisses a popup holding a half-typed query throws away work to perform the lesser action, since the user wanted their list back, not their field gone. No spec mandates it; every picker worth using does it. And the arrows wrap: ↓ at the bottom lands on the top, which in a list filtered to two rows is the difference between a flick and a count. Home/End are absolute - the last result, not the bottom of the visible box - and pair with `scrollIntoView`, because a jump to a row you cannot see is not a jump. Tab is never swallowed, or the combobox becomes a keyboard trap. The shortcuts are printed in a legend wired up with `aria-describedby`: an undiscoverable shortcut is a shortcut that does not exist.