Basic Input
A labelled text input with helper text wired to it via aria-describedby.
6 個のフレームワーク初級
A listbox with the full ARIA pattern - virtual focus, arrow keys, and dismiss on outside click.
<!--
The listbox pattern, in full. Everything below is what a native <select> gives
you for nothing, which is why this markup is the price of admission rather
than a nice-to-have:
- the trigger is a button with aria-haspopup="listbox" and aria-expanded, so
it announces "collapsed"/"expanded" instead of just "button";
- the popup is role="listbox" and every row is role="option" with
aria-selected - a <ul><li> alone announces as a bare list;
- focus never leaves the trigger. aria-activedescendant points at the
highlighted option instead, so typing keeps working and the browser's own
focus ring stays on the control the user tabbed to;
- the highlight is a background AND a bar in the leading gutter, because a
background alone is a colour-only signal.
-->
<div class="csel">
<span class="csel__label" id="csel-label">Plan</span>
<button
class="csel__trigger"
id="csel-trigger"
type="button"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="csel-listbox"
aria-labelledby="csel-label csel-trigger"
>
<span class="csel__value">Growth</span>
<svg class="csel__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<ul class="csel__listbox" id="csel-listbox" role="listbox" aria-labelledby="csel-label" tabindex="-1" hidden>
<li class="csel__option" id="csel-opt-starter" role="option" data-value="starter" aria-selected="false">
<span class="csel__marker" aria-hidden="true"></span>
<span class="csel__option-label">Starter</span>
<svg class="csel__check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 5 5L20 7" /></svg>
</li>
<li class="csel__option" id="csel-opt-growth" role="option" data-value="growth" aria-selected="true">
<span class="csel__marker" aria-hidden="true"></span>
<span class="csel__option-label">Growth</span>
<svg class="csel__check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 5 5L20 7" /></svg>
</li>
<li class="csel__option" id="csel-opt-scale" role="option" data-value="scale" aria-selected="false">
<span class="csel__marker" aria-hidden="true"></span>
<span class="csel__option-label">Scale</span>
<svg class="csel__check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 5 5L20 7" /></svg>
</li>
<li class="csel__option" id="csel-opt-enterprise" role="option" data-value="enterprise" aria-selected="false">
<span class="csel__marker" aria-hidden="true"></span>
<span class="csel__option-label">Enterprise</span>
<svg class="csel__check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m5 12 5 5L20 7" /></svg>
</li>
</ul>
</div>
<script>
document.querySelectorAll('.csel').forEach(function (root) {
var trigger = root.querySelector('.csel__trigger');
var listbox = root.querySelector('.csel__listbox');
var valueEl = root.querySelector('.csel__value');
var options = Array.prototype.slice.call(root.querySelectorAll('.csel__option'));
var active = options.findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; });
if (active < 0) active = 0;
function paint() {
options.forEach(function (option, index) {
option.classList.toggle('csel__option--active', index === active);
});
// Virtual focus: the DOM focus stays on the trigger, so this attribute is
// the only thing telling a screen reader which row is current.
listbox.setAttribute('aria-activedescendant', options[active] ? options[active].id : '');
if (options[active]) options[active].scrollIntoView({ block: 'nearest' });
}
function setOpen(open) {
listbox.hidden = !open;
trigger.setAttribute('aria-expanded', String(open));
if (open) paint();
}
function close() {
setOpen(false);
// Focus must come back to the trigger, or it lands on <body> and the next
// Tab restarts from the top of the page.
trigger.focus();
}
function commit(index) {
var option = options[index];
if (!option) return;
options.forEach(function (o) { o.setAttribute('aria-selected', String(o === option)); });
valueEl.textContent = option.querySelector('.csel__option-label').textContent;
active = index;
close();
}
trigger.addEventListener('click', function () {
setOpen(trigger.getAttribute('aria-expanded') !== 'true');
});
options.forEach(function (option, index) {
option.addEventListener('click', function () { commit(index); });
option.addEventListener('mousemove', function () { active = index; paint(); });
});
trigger.addEventListener('keydown', function (event) {
var open = trigger.getAttribute('aria-expanded') === 'true';
var key = event.key;
if (!open && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ')) {
event.preventDefault();
setOpen(true);
return;
}
if (!open) return;
if (key === 'Escape') { event.preventDefault(); close(); return; }
if (key === 'Enter' || key === ' ') { event.preventDefault(); commit(active); return; }
if (key === 'Tab') { setOpen(false); 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 = options.length - 1;
else return;
event.preventDefault();
// Wrap, so End then ArrowDown lands back on the first row.
active = (next + options.length) % options.length;
paint();
});
document.addEventListener('mousedown', function (event) {
if (!root.contains(event.target)) setOpen(false);
});
});
</script>| Prop | 型 | デフォルト | 説明 |
|---|---|---|---|
label必須 | string | - | 読み込み中に読み上げられるアクセシブルなラベル。 |
items必須 | SelectItem[] | - | レンダリングする項目の配列。 |
value | string | - | 指標の現在値。フォーマット済みの文字列を渡します。 |
onSelect | (value: string) => void | - | ユーザーが選択したメニュー項目を引数に呼ばれます。 |
disabled | boolean | false | 操作を無効にし、コントロールを淡色表示にします。 |
className | string | - | ルート要素にマージされる追加クラス。 |
The part to keep is not the styling, it is the contract: `aria-haspopup="listbox"` and `aria-expanded` on the trigger, `role="option"` with `aria-selected` on every row, and `aria-activedescendant` for virtual focus. That last one is what people cut, and it is the load-bearing piece - DOM focus stays on the trigger while only the attribute moves, which is how the browser’s own focus ring stays where the user put it. Note `activeIndex` and `value` are separate state: arrowing moves the highlight, only Enter moves the selection. Collapse the two and the list commits on every keypress.