Basic Input
A labelled text input with helper text wired to it via aria-describedby.
6 个框架初级
An avatar, name and email per row, with the filter searching name and email both.
<!--
A people picker. The row is three things - avatar, name, email - and each one
has a rule:
- The AVATAR is decorative, always. It sits next to the name it depicts, so
alt text would produce "Ada Lovelace Ada Lovelace". aria-hidden, alt="", no
exceptions. Initials are a fallback that cannot 404.
- The EMAIL is not decoration - it is how you tell two Chens apart. It is real
text in the row, and the filter searches it: "@adysre" finds every colleague.
- The row's accessible name is therefore "Ada Lovelace ada@adysre.com", which is
exactly what a human would say out loud to disambiguate.
Contrast note: the email is the smallest text here and still holds 4.5:1.
There is no exemption for secondary text.
-->
<div class="mavatar">
<label class="mavatar__label" for="mavatar-input">Assignees</label>
<div class="mavatar__control">
<span class="mavatar__chip">
<span class="mavatar__chip-avatar" aria-hidden="true">AL</span>
Ada Lovelace
<button class="mavatar__remove" type="button" aria-label="Remove Ada Lovelace">
<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>
</button>
</span>
<input
class="mavatar__input"
id="mavatar-input"
type="text"
role="combobox"
aria-expanded="false"
aria-controls="mavatar-listbox"
aria-autocomplete="list"
aria-describedby="mavatar-status"
autocomplete="off"
placeholder="Search people…"
/>
</div>
<p class="mavatar__status" id="mavatar-status" role="status" aria-live="polite">5 people available. 1 selected.</p>
<ul class="mavatar__listbox" id="mavatar-listbox" role="listbox" aria-multiselectable="true" aria-label="Assignees" hidden>
<li class="mavatar__option" id="mavatar-opt-0" role="option" aria-selected="true">
<span class="mavatar__marker" aria-hidden="true"></span>
<span class="mavatar__avatar" aria-hidden="true">AL</span>
<span class="mavatar__person">
<span class="mavatar__name">Ada Lovelace</span>
<span class="mavatar__email">ada@adysre.com</span>
</span>
<span class="mavatar__tick" aria-hidden="true">✓</span>
</li>
<li class="mavatar__option" id="mavatar-opt-1" role="option" aria-selected="false">
<span class="mavatar__marker" aria-hidden="true"></span>
<span class="mavatar__avatar" aria-hidden="true">GH</span>
<span class="mavatar__person">
<span class="mavatar__name">Grace Hopper</span>
<span class="mavatar__email">grace@adysre.com</span>
</span>
<span class="mavatar__tick" aria-hidden="true">✓</span>
</li>
<li class="mavatar__option" id="mavatar-opt-2" role="option" aria-selected="false">
<span class="mavatar__marker" aria-hidden="true"></span>
<span class="mavatar__avatar" aria-hidden="true">AT</span>
<span class="mavatar__person">
<span class="mavatar__name">Alan Turing</span>
<span class="mavatar__email">alan@adysre.com</span>
</span>
<span class="mavatar__tick" aria-hidden="true">✓</span>
</li>
</ul>
</div>
<script>
document.querySelectorAll('.mavatar').forEach(function (root) {
var PEOPLE = [
{ id: 'ada', name: 'Ada Lovelace', email: 'ada@adysre.com' },
{ id: 'grace', name: 'Grace Hopper', email: 'grace@adysre.com' },
{ id: 'alan', name: 'Alan Turing', email: 'alan@adysre.com' },
{ id: 'katherine', name: 'Katherine Johnson', email: 'katherine@adysre.com' },
{ id: 'linus', name: 'Linus Chen', email: 'linus@adysre.com' },
];
var input = root.querySelector('.mavatar__input');
var listbox = root.querySelector('.mavatar__listbox');
var control = root.querySelector('.mavatar__control');
var status = root.querySelector('.mavatar__status');
var chosen = ['ada'];
var results = PEOPLE.slice();
var active = 0;
function initials(name) {
return name.split(' ').map(function (part) { return part[0]; }).join('').slice(0, 2).toUpperCase();
}
function optionId(index) { return 'mavatar-opt-' + index; }
function renderChips() {
Array.prototype.slice.call(root.querySelectorAll('.mavatar__chip')).forEach(function (c) { c.remove(); });
chosen.forEach(function (id) {
var person = PEOPLE.find(function (p) { return p.id === id; });
if (!person) return;
var chip = document.createElement('span');
chip.className = 'mavatar__chip';
chip.innerHTML =
'<span class="mavatar__chip-avatar" aria-hidden="true">' + initials(person.name) + '</span>' +
person.name;
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'mavatar__remove';
remove.setAttribute('aria-label', 'Remove ' + person.name);
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 () {
chosen = chosen.filter(function (c) { return c !== id; });
renderAll();
input.focus();
});
chip.appendChild(remove);
control.insertBefore(chip, input);
});
}
function renderList() {
var q = input.value.trim().toLowerCase();
// Name AND email: "@adysre" finds everyone, "chen" finds one person.
results = PEOPLE.filter(function (p) {
return p.name.toLowerCase().includes(q) || p.email.toLowerCase().includes(q);
});
if (active >= results.length) active = Math.max(0, results.length - 1);
listbox.innerHTML = '';
if (!results.length) {
var empty = document.createElement('li');
empty.setAttribute('role', 'presentation');
empty.className = 'mavatar__empty';
empty.textContent = 'Nobody matches “' + input.value + '”.';
listbox.appendChild(empty);
} else {
results.forEach(function (person, index) {
var li = document.createElement('li');
li.id = optionId(index);
li.className = 'mavatar__option' + (index === active ? ' mavatar__option--active' : '');
li.setAttribute('role', 'option');
li.setAttribute('aria-selected', String(chosen.includes(person.id)));
li.innerHTML =
'<span class="mavatar__marker" aria-hidden="true"></span>' +
'<span class="mavatar__avatar" aria-hidden="true">' + initials(person.name) + '</span>' +
'<span class="mavatar__person"><span class="mavatar__name">' + person.name +
'</span><span class="mavatar__email">' + person.email + '</span></span>' +
'<span class="mavatar__tick" aria-hidden="true">✓</span>';
li.addEventListener('mousedown', function (event) {
event.preventDefault();
toggle(person.id);
});
li.addEventListener('mousemove', function () { active = index; renderList(); });
listbox.appendChild(li);
});
}
listbox.setAttribute('aria-activedescendant', results.length ? optionId(active) : '');
listbox.hidden = input.getAttribute('aria-expanded') !== 'true';
status.textContent =
(results.length ? results.length + (results.length === 1 ? ' person' : ' people') + ' available.' : 'Nobody matches.') +
' ' + chosen.length + ' selected.';
}
function renderAll() { renderChips(); renderList(); }
function toggle(id) {
if (chosen.includes(id)) chosen = chosen.filter(function (c) { return c !== id; });
else chosen.push(id);
renderAll();
}
input.addEventListener('focus', function () {
input.setAttribute('aria-expanded', 'true');
renderList();
});
input.addEventListener('input', function () {
input.setAttribute('aria-expanded', 'true');
active = 0;
renderList();
});
input.addEventListener('keydown', function (event) {
var key = event.key;
if (key === 'Escape') {
event.preventDefault();
input.setAttribute('aria-expanded', 'false');
listbox.hidden = true;
return;
}
if (key === 'Backspace' && !input.value && chosen.length) {
event.preventDefault();
chosen = chosen.slice(0, -1);
renderAll();
return;
}
if (!results.length) return;
if (key === 'Enter') {
event.preventDefault();
toggle(results[active].id);
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;
});
renderAll();
});
</script>| Prop | 类型 | 默认值 | 说明 |
|---|---|---|---|
label必填 | string | - | 加载时朗读的无障碍标签。 |
items必填 | Person[] | - | 要渲染的项目数组。 |
value必填 | string[] | - | 指标的当前值,需预先格式化。 |
onSelect | (next: string[]) => void | - | 用户选择菜单项时触发,参数为所选项。 |
disabled | boolean | false | 禁止交互并将控件置灰。 |
Three parts, three rules. The avatar is decorative without exception - it sits beside the name it depicts, so any alt text at all produces "Ada Lovelace Ada Lovelace"; initials are used over photo URLs because they cannot 404, cannot leak a request to a third party, and cannot shift the layout when they load late. The email is not decoration: it is how you tell two Chens apart, so it is real text in the row and the filter searches it - "@adysre" finds every colleague. The row’s accessible name comes out as "Ada Lovelace ada@adysre.com", which is exactly what a person would say aloud to disambiguate. The email is the smallest text here and still holds 4.5:1; secondary text has no exemption.