Basic Input
A labelled text input with helper text wired to it via aria-describedby.
6 个框架初级
Chosen options as removable chips in the control, over a multi-select listbox.
<!--
The structural trap in this pattern: a chip needs a remove button, and a
button cannot live inside a button. So the control is a DIV that looks like a
field - the chips and the popup toggle are siblings inside it, not children of
one another.
That gives every chip its own tab stop, which is the point. "Remove React" is
a real, reachable action; the alternative is reopening the popup and hunting
for a row to untick.
Two other things worth copying:
- The popup carries aria-multiselectable="true". Without it a screen reader
announces a single-select listbox and the user has no reason to expect a
second pick to stick.
- Picking does NOT close the popup. It is multi-select; closing after every
tick would make choosing four things cost four round trips.
-->
<div class="mchips">
<span class="mchips__label" id="mchips-label">Skills</span>
<div class="mchips__control">
<span class="mchips__chip">
React
<button class="mchips__remove" type="button" aria-label="Remove React">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</span>
<span class="mchips__chip">
TypeScript
<button class="mchips__remove" type="button" aria-label="Remove TypeScript">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</span>
<button
class="mchips__toggle"
id="mchips-toggle"
type="button"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="mchips-listbox"
aria-labelledby="mchips-label mchips-toggle"
>
<span class="mchips__placeholder">Add a skill…</span>
<svg class="mchips__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>
</div>
<ul class="mchips__listbox" id="mchips-listbox" role="listbox" aria-multiselectable="true" aria-labelledby="mchips-label" tabindex="-1" hidden>
<li class="mchips__option" id="mchips-opt-react" role="option" data-value="React" aria-selected="true"><span class="mchips__marker" aria-hidden="true"></span><span class="mchips__box" aria-hidden="true"></span><span class="mchips__option-label">React</span></li>
<li class="mchips__option" id="mchips-opt-ts" role="option" data-value="TypeScript" aria-selected="true"><span class="mchips__marker" aria-hidden="true"></span><span class="mchips__box" aria-hidden="true"></span><span class="mchips__option-label">TypeScript</span></li>
<li class="mchips__option" id="mchips-opt-go" role="option" data-value="Go" aria-selected="false"><span class="mchips__marker" aria-hidden="true"></span><span class="mchips__box" aria-hidden="true"></span><span class="mchips__option-label">Go</span></li>
<li class="mchips__option" id="mchips-opt-rust" role="option" data-value="Rust" aria-selected="false"><span class="mchips__marker" aria-hidden="true"></span><span class="mchips__box" aria-hidden="true"></span><span class="mchips__option-label">Rust</span></li>
<li class="mchips__option" id="mchips-opt-sql" role="option" data-value="SQL" aria-selected="false"><span class="mchips__marker" aria-hidden="true"></span><span class="mchips__box" aria-hidden="true"></span><span class="mchips__option-label">SQL</span></li>
<li class="mchips__option" id="mchips-opt-figma" role="option" data-value="Figma" aria-selected="false"><span class="mchips__marker" aria-hidden="true"></span><span class="mchips__box" aria-hidden="true"></span><span class="mchips__option-label">Figma</span></li>
</ul>
</div>
<script>
document.querySelectorAll('.mchips').forEach(function (root) {
var toggle = root.querySelector('.mchips__toggle');
var listbox = root.querySelector('.mchips__listbox');
var control = root.querySelector('.mchips__control');
var options = Array.prototype.slice.call(root.querySelectorAll('.mchips__option'));
var active = 0;
function paint() {
options.forEach(function (option, index) {
option.classList.toggle('mchips__option--active', index === active);
});
listbox.setAttribute('aria-activedescendant', options[active] ? options[active].id : '');
if (options[active]) options[active].scrollIntoView({ block: 'nearest' });
}
function renderChips() {
Array.prototype.slice.call(root.querySelectorAll('.mchips__chip')).forEach(function (chip) {
chip.remove();
});
options
.filter(function (o) { return o.getAttribute('aria-selected') === 'true'; })
.forEach(function (option) {
var value = option.dataset.value;
var chip = document.createElement('span');
chip.className = 'mchips__chip';
chip.textContent = value;
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'mchips__remove';
remove.setAttribute('aria-label', 'Remove ' + value);
remove.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>';
remove.addEventListener('click', function () {
option.setAttribute('aria-selected', 'false');
renderChips();
// Focus would land on <body> once this button is destroyed. Hand it
// to the toggle so the next Tab continues from the right place.
toggle.focus();
});
chip.appendChild(remove);
control.insertBefore(chip, toggle);
});
toggle.querySelector('.mchips__placeholder').textContent =
options.some(function (o) { return o.getAttribute('aria-selected') === 'true'; })
? 'Add a skill…'
: 'Choose skills…';
}
function setOpen(open) {
listbox.hidden = !open;
toggle.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'));
renderChips();
// The popup deliberately stays open - this is multi-select.
}
toggle.addEventListener('click', function () {
setOpen(toggle.getAttribute('aria-expanded') !== 'true');
});
options.forEach(function (option, index) {
option.addEventListener('click', function () { toggleOption(index); paint(); });
option.addEventListener('mousemove', function () { active = index; paint(); });
});
toggle.addEventListener('keydown', function (event) {
var open = toggle.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); toggle.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 structural trap: a chip needs a remove button, and a button cannot nest inside a button. So the control is a `<div>` dressed as a field with the chips and the toggle as siblings, and `:focus-within` keeps the box ringed while real focus sits on a chip. That buys every chip its own tab stop, which is the point - "Remove React" is a real, reachable action rather than a trip back into the popup. Each remove button hands focus to the toggle before it unmounts itself, or focus falls to `<body>`. `aria-multiselectable="true"` is not optional: without it the popup announces as single-select and nobody expects the second pick to stick.