Basic Input
A labelled text input with helper text wired to it via aria-describedby.
6 個のフレームワーク初級
A capped multi-select that disables the rest at the cap - and says why.
<!--
A cap of three. The interesting decisions are all about what happens AT the
cap, and every one of them is a rule about not lying to the user:
- Capped rows get aria-disabled="true", NOT the disabled attribute and not
removal from the list. They stay reachable by arrow key and still announce
themselves, so the user can read what they are missing and understand why.
A row that vanishes at the cap is a row the user thinks they imagined.
- The reason is stated, not implied. "3 of 3 chosen - remove one to add
another" is wired via aria-describedby AND spoken through a live region the
moment the cap is reached.
- Rows already chosen never disable. Untick has to keep working at the cap, or
the control becomes a trap.
-->
<div class="mlimit">
<span class="mlimit__label" id="mlimit-label">Focus areas</span>
<button
class="mlimit__trigger"
id="mlimit-trigger"
type="button"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="mlimit-listbox"
aria-labelledby="mlimit-label mlimit-trigger"
aria-describedby="mlimit-status"
>
<span class="mlimit__value">Performance, Accessibility, Security</span>
<svg class="mlimit__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>
<p class="mlimit__status mlimit__status--full" id="mlimit-status" role="status" aria-live="polite">
3 of 3 chosen - remove one to add another.
</p>
<ul class="mlimit__listbox" id="mlimit-listbox" role="listbox" aria-multiselectable="true" aria-labelledby="mlimit-label" tabindex="-1" hidden>
<li class="mlimit__option" id="mlimit-opt-perf" role="option" data-value="Performance" aria-selected="true"><span class="mlimit__marker" aria-hidden="true"></span><span class="mlimit__option-label">Performance</span><span class="mlimit__tick" aria-hidden="true">✓</span></li>
<li class="mlimit__option" id="mlimit-opt-a11y" role="option" data-value="Accessibility" aria-selected="true"><span class="mlimit__marker" aria-hidden="true"></span><span class="mlimit__option-label">Accessibility</span><span class="mlimit__tick" aria-hidden="true">✓</span></li>
<li class="mlimit__option" id="mlimit-opt-sec" role="option" data-value="Security" aria-selected="true"><span class="mlimit__marker" aria-hidden="true"></span><span class="mlimit__option-label">Security</span><span class="mlimit__tick" aria-hidden="true">✓</span></li>
<li class="mlimit__option" id="mlimit-opt-dx" role="option" data-value="Developer experience" aria-selected="false" aria-disabled="true"><span class="mlimit__marker" aria-hidden="true"></span><span class="mlimit__option-label">Developer experience</span><span class="mlimit__tick" aria-hidden="true">✓</span></li>
<li class="mlimit__option" id="mlimit-opt-seo" role="option" data-value="SEO" aria-selected="false" aria-disabled="true"><span class="mlimit__marker" aria-hidden="true"></span><span class="mlimit__option-label">SEO</span><span class="mlimit__tick" aria-hidden="true">✓</span></li>
<li class="mlimit__option" id="mlimit-opt-i18n" role="option" data-value="Internationalisation" aria-selected="false" aria-disabled="true"><span class="mlimit__marker" aria-hidden="true"></span><span class="mlimit__option-label">Internationalisation</span><span class="mlimit__tick" aria-hidden="true">✓</span></li>
</ul>
</div>
<script>
document.querySelectorAll('.mlimit').forEach(function (root) {
var LIMIT = 3;
var trigger = root.querySelector('.mlimit__trigger');
var listbox = root.querySelector('.mlimit__listbox');
var valueEl = root.querySelector('.mlimit__value');
var status = root.querySelector('.mlimit__status');
var options = Array.prototype.slice.call(root.querySelectorAll('.mlimit__option'));
var active = 0;
function chosen() {
return options.filter(function (o) { return o.getAttribute('aria-selected') === 'true'; });
}
function render() {
var picked = chosen();
var full = picked.length >= LIMIT;
options.forEach(function (option) {
var isSelected = option.getAttribute('aria-selected') === 'true';
// Selected rows NEVER disable - untick has to keep working at the cap
// or the control is a trap.
if (full && !isSelected) option.setAttribute('aria-disabled', 'true');
else option.removeAttribute('aria-disabled');
});
valueEl.textContent = picked.length
? picked.map(function (o) { return o.dataset.value; }).join(', ')
: 'Choose up to 3…';
valueEl.classList.toggle('mlimit__value--empty', picked.length === 0);
status.textContent = full
? LIMIT + ' of ' + LIMIT + ' chosen - remove one to add another.'
: picked.length + ' of ' + LIMIT + ' chosen.';
status.classList.toggle('mlimit__status--full', full);
}
function paint() {
options.forEach(function (option, index) {
option.classList.toggle('mlimit__option--active', index === active);
});
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 toggleOption(index) {
var option = options[index];
// aria-disabled is advisory - the browser will still deliver the click,
// so the guard has to be enforced here in code.
if (!option || option.getAttribute('aria-disabled') === 'true') return;
option.setAttribute('aria-selected', String(option.getAttribute('aria-selected') !== 'true'));
render();
}
trigger.addEventListener('click', function () {
setOpen(trigger.getAttribute('aria-expanded') !== 'true');
});
options.forEach(function (option, index) {
option.addEventListener('click', function () { toggleOption(index); paint(); });
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) {
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ') {
event.preventDefault();
setOpen(true);
}
return;
}
if (key === 'Escape') { event.preventDefault(); setOpen(false); trigger.focus(); return; }
if (key === 'Enter' || key === ' ') { event.preventDefault(); toggleOption(active); return; }
if (key === 'Tab') { setOpen(false); return; }
var next = active;
// Arrowing crosses disabled rows rather than skipping them: the user is
// entitled to read what the cap is costing them.
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();
active = (next + options.length) % options.length;
paint();
});
document.addEventListener('mousedown', function (event) {
if (!root.contains(event.target)) setOpen(false);
});
render();
});
</script>| Prop | 型 | デフォルト | 説明 |
|---|---|---|---|
label必須 | string | - | 読み込み中に読み上げられるアクセシブルなラベル。 |
items必須 | SelectItem[] | - | レンダリングする項目の配列。 |
value必須 | string[] | - | 指標の現在値。フォーマット済みの文字列を渡します。 |
count | number | 3 | バッジに表示される数値。99 を超えると「99+」と表示されます。 |
onSelect | (next: string[]) => void | - | ユーザーが選択したメニュー項目を引数に呼ばれます。 |
disabled | boolean | false | 操作を無効にし、コントロールを淡色表示にします。 |
Every decision here is a rule about not lying to the user. Capped rows get `aria-disabled="true"`, never the `disabled` attribute and never removal: they stay arrowable, announced and legible at 0.6 opacity, because the user is entitled to read what the cap is costing them - a row that vanishes is a row they think they imagined. Already-chosen rows never disable, or untick stops working and the cap becomes a trap. The status doubles as the `aria-describedby` target and a live region, so hitting the cap is announced when it happens; it states the remedy ("remove one to add another") because "maximum reached" tells you that you are stuck without telling you how to get unstuck. `aria-disabled` is advisory - the click still fires, so the guard lives in code too.