Basic Input
A labelled text input with helper text wired to it via aria-describedby.
6 个框架初级
Tags rendered inside the trigger, with Backspace removing the last one.
<!--
Tags live INSIDE the trigger, not beside it. That is the whole difference from
multiselect-chips, and it is a real one: the tags here are plain text with no
remove buttons, so the trigger stays a single button and a single tab stop.
Removal is Backspace, the way every tag field since the first one has worked -
and the reason the tags can afford to have no buttons at all. Two guards make
it safe:
- Backspace only fires while the popup is CLOSED. Open, it belongs to the list.
- Every removal is announced through a live region. A destructive key with no
feedback is a key that quietly eats work - the tag vanished from the screen,
and a screen-reader user has no idea which one.
Untick a row in the popup and the tag goes too; Backspace is the shortcut, not
the only route.
-->
<div class="mtags">
<span class="mtags__label" id="mtags-label">Labels</span>
<button
class="mtags__trigger"
id="mtags-trigger"
type="button"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="mtags-listbox"
aria-labelledby="mtags-label mtags-trigger"
aria-describedby="mtags-hint"
>
<span class="mtags__tags">
<span class="mtags__tag">bug</span>
<span class="mtags__tag">regression</span>
<span class="mtags__tag">p1</span>
</span>
<svg class="mtags__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="mtags__hint" id="mtags-hint">Press Backspace to remove the last label.</p>
<ul class="mtags__listbox" id="mtags-listbox" role="listbox" aria-multiselectable="true" aria-labelledby="mtags-label" tabindex="-1" hidden>
<li class="mtags__option" id="mtags-opt-bug" role="option" data-value="bug" aria-selected="true"><span class="mtags__marker" aria-hidden="true"></span><span class="mtags__option-label">bug</span></li>
<li class="mtags__option" id="mtags-opt-regression" role="option" data-value="regression" aria-selected="true"><span class="mtags__marker" aria-hidden="true"></span><span class="mtags__option-label">regression</span></li>
<li class="mtags__option" id="mtags-opt-p1" role="option" data-value="p1" aria-selected="true"><span class="mtags__marker" aria-hidden="true"></span><span class="mtags__option-label">p1</span></li>
<li class="mtags__option" id="mtags-opt-docs" role="option" data-value="docs" aria-selected="false"><span class="mtags__marker" aria-hidden="true"></span><span class="mtags__option-label">docs</span></li>
<li class="mtags__option" id="mtags-opt-good-first" role="option" data-value="good first issue" aria-selected="false"><span class="mtags__marker" aria-hidden="true"></span><span class="mtags__option-label">good first issue</span></li>
<li class="mtags__option" id="mtags-opt-wontfix" role="option" data-value="wontfix" aria-selected="false"><span class="mtags__marker" aria-hidden="true"></span><span class="mtags__option-label">wontfix</span></li>
</ul>
<!--
polite, not assertive: removing a tag is the user's own deliberate act, so
it should queue behind whatever is speaking rather than interrupt it.
-->
<p class="mtags__live" role="status" aria-live="polite"></p>
</div>
<script>
document.querySelectorAll('.mtags').forEach(function (root) {
var trigger = root.querySelector('.mtags__trigger');
var listbox = root.querySelector('.mtags__listbox');
var tagsSlot = root.querySelector('.mtags__tags');
var live = root.querySelector('.mtags__live');
var options = Array.prototype.slice.call(root.querySelectorAll('.mtags__option'));
var active = 0;
function selectedOptions() {
return options.filter(function (o) { return o.getAttribute('aria-selected') === 'true'; });
}
function paint() {
options.forEach(function (option, index) {
option.classList.toggle('mtags__option--active', index === active);
});
listbox.setAttribute('aria-activedescendant', options[active] ? options[active].id : '');
}
function renderTags() {
var chosen = selectedOptions();
tagsSlot.innerHTML = '';
if (!chosen.length) {
var empty = document.createElement('span');
empty.className = 'mtags__placeholder';
empty.textContent = 'Add labels…';
tagsSlot.appendChild(empty);
return;
}
chosen.forEach(function (option) {
var tag = document.createElement('span');
tag.className = 'mtags__tag';
tag.textContent = option.dataset.value;
tagsSlot.appendChild(tag);
});
}
function setOpen(open) {
listbox.hidden = !open;
trigger.setAttribute('aria-expanded', String(open));
if (open) paint();
}
function toggleOption(index) {
var option = options[index];
if (!option) return;
option.setAttribute('aria-selected', String(option.getAttribute('aria-selected') !== 'true'));
renderTags();
}
function removeLast() {
var chosen = selectedOptions();
var last = chosen[chosen.length - 1];
if (!last) return;
last.setAttribute('aria-selected', 'false');
renderTags();
live.textContent = last.dataset.value + ' removed. ' + selectedOptions().length + ' labels remaining.';
}
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) {
// Backspace lives here and only here - with the list open it belongs to
// the list, and eating tags from under an open popup is chaos.
if (key === 'Backspace') { event.preventDefault(); removeLast(); return; }
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;
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);
});
});
</script>| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
label必填 | string | - | 加载时朗读的无障碍标签。 |
items必填 | SelectItem[] | - | 要渲染的项目数组。 |
value必填 | string[] | - | 指标的当前值,需预先格式化。 |
onSelect | (next: string[]) => void | - | 用户选择菜单项时触发,参数为所选项。 |
disabled | boolean | false | 禁止交互并将控件置灰。 |
The tags here are inert text with no remove buttons, which is exactly what keeps the whole control one tab stop - the difference from `multiselect-chips`, and a real one. Backspace is the affordance that replaces the missing ×, and the printed hint below says so, because an invisible shortcut is not an affordance. Two guards make it safe: it fires only while the popup is closed (open, the key belongs to the list), and every removal goes through a polite live region - a destructive key with no feedback quietly eats work, since the tag leaves the screen and a screen-reader user never learns which one went.