Basic Input
A labelled text input with helper text wired to it via aria-describedby.
6 個のフレームワーク初級
A debounced combobox with real loading, empty and idle states - and a race guard.
<!--
The same combobox as multiselect-search-basic, with the results coming from
somewhere slow. Everything below exists because the network is not instant and
not ordered:
- DEBOUNCE. 250ms after the last keystroke, not on every one. "typescript" is
one request, not ten.
- RACE GUARD. Each request gets a sequence number, and a response whose number
is stale is dropped. Without it, typing "go" then "golang" can paint the
slower "go" results over the newer ones - a bug that only shows up on a bad
connection, which is to say: on a user's connection, not yours.
- THREE empty states, not one. "Type to search" (idle), "Searching…"
(in flight) and "No results" (answered) are three different facts and the
user is entitled to know which one they are in. Collapsing them into one
"No results" tells a lie for as long as the request is open.
- aria-busy on the listbox, plus the status in a live region - a spinner is
invisible to a screen reader.
The fetch here is stubbed with a local promise so the snippet runs anywhere.
Swap searchProjects() for your endpoint; keep the seq guard.
-->
<div class="masync">
<label class="masync__label" for="masync-input">Projects</label>
<div class="masync__control">
<input
class="masync__input"
id="masync-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="masync-listbox"
aria-autocomplete="list"
aria-describedby="masync-status"
autocomplete="off"
placeholder="Search projects…"
/>
<span class="masync__spinner" hidden aria-hidden="true"></span>
</div>
<p class="masync__status" id="masync-status" role="status" aria-live="polite">Type to search projects.</p>
<ul class="masync__listbox" id="masync-listbox" role="listbox" aria-multiselectable="true" aria-label="Projects" hidden></ul>
</div>
<script>
document.querySelectorAll('.masync').forEach(function (root) {
var DEBOUNCE_MS = 250;
var input = root.querySelector('.masync__input');
var listbox = root.querySelector('.masync__listbox');
var status = root.querySelector('.masync__status');
var spinner = root.querySelector('.masync__spinner');
var CATALOGUE = [
'api-gateway', 'billing-service', 'design-tokens', 'docs-site',
'edge-cache', 'identity-provider', 'mobile-app', 'web-dashboard',
];
var chosen = [];
var results = [];
var active = 0;
var timer = null;
var seq = 0;
// STUB. Replace with fetch('/api/projects?q=' + encodeURIComponent(query)).
// Never let a component library snippet touch the network.
function searchProjects(query) {
return new Promise(function (resolve) {
setTimeout(function () {
var q = query.trim().toLowerCase();
resolve(CATALOGUE.filter(function (name) { return name.includes(q); }));
}, 400);
});
}
function optionId(index) { return 'masync-opt-' + index; }
function setBusy(busy) {
spinner.hidden = !busy;
listbox.setAttribute('aria-busy', String(busy));
}
function renderList() {
listbox.innerHTML = '';
results.forEach(function (name, index) {
var li = document.createElement('li');
li.className = 'masync__option' + (index === active ? ' masync__option--active' : '');
li.id = optionId(index);
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', String(chosen.includes(name)));
li.innerHTML =
'<span class="masync__marker" aria-hidden="true"></span>' +
'<span class="masync__option-label">' + name + '</span>' +
'<span class="masync__tick" aria-hidden="true">✓</span>';
li.addEventListener('mousedown', function (event) {
event.preventDefault();
var at = chosen.indexOf(name);
if (at === -1) chosen.push(name);
else chosen.splice(at, 1);
renderList();
});
li.addEventListener('mousemove', function () {
active = index;
renderList();
});
listbox.appendChild(li);
});
listbox.setAttribute('aria-activedescendant', results.length ? optionId(active) : '');
listbox.hidden = !results.length || input.getAttribute('aria-expanded') !== 'true';
}
function run(query) {
if (!query.trim()) {
seq += 1; // invalidate anything in flight
setBusy(false);
results = [];
renderList();
status.textContent = 'Type to search projects.';
return;
}
var mine = ++seq;
setBusy(true);
status.textContent = 'Searching…';
searchProjects(query).then(function (found) {
// The race guard. A slower earlier request must never paint over a
// faster later one.
if (mine !== seq) return;
setBusy(false);
results = found;
active = 0;
renderList();
status.textContent = found.length
? found.length + (found.length === 1 ? ' project' : ' projects') + ' found. ' + chosen.length + ' selected.'
: 'No projects match “' + query + '”.';
});
}
input.addEventListener('input', function () {
input.setAttribute('aria-expanded', 'true');
window.clearTimeout(timer);
// Debounced: one request per pause, not one per keystroke.
timer = window.setTimeout(function () { run(input.value); }, DEBOUNCE_MS);
});
input.addEventListener('keydown', function (event) {
var key = event.key;
if (key === 'Escape') {
event.preventDefault();
input.setAttribute('aria-expanded', 'false');
listbox.hidden = true;
return;
}
if (!results.length) return;
if (key === 'Enter') {
event.preventDefault();
var name = results[active];
var at = chosen.indexOf(name);
if (at === -1) chosen.push(name);
else chosen.splice(at, 1);
renderList();
return;
}
var next = active;
if (key === 'ArrowDown') next = active + 1;
else if (key === 'ArrowUp') next = active - 1;
else return;
event.preventDefault();
active = (next + results.length) % results.length;
renderList();
});
document.addEventListener('mousedown', function (event) {
if (root.contains(event.target)) return;
input.setAttribute('aria-expanded', 'false');
listbox.hidden = true;
});
});
</script>| Prop | 型 | デフォルト | 説明 |
|---|---|---|---|
label必須 | string | - | 読み込み中に読み上げられるアクセシブルなラベル。 |
value必須 | string[] | - | 指標の現在値。フォーマット済みの文字列を渡します。 |
onSelect | (next: string[]) => void | - | ユーザーが選択したメニュー項目を引数に呼ばれます。 |
loading | boolean | false | true の間はスピナーを表示し、入力をブロックします。 |
disabled | boolean | false | 操作を無効にし、コントロールを淡色表示にします。 |
The fetch is stubbed with a local promise and a `setTimeout` so the snippet runs anywhere; swap `searchProjects()` for your endpoint and keep everything around it. The debounce is the obvious part. The race guard is the one that matters: each request takes a sequence number and a response holding a stale one is dropped, or typing "go" then "golang" can paint the slower earlier results over the newer ones - a bug that only reproduces on a bad connection, which is to say on a user’s, never on yours. There are three empty states, not one: "type to search", "searching…" and "no results" are three different facts, and collapsing them means showing an empty state that is a lie for as long as the request is open. `aria-busy` plus the live region carry all of it - a spinner is invisible to a screen reader.