Basic Input
A labelled text input with helper text wired to it via aria-describedby.
6 frameworksBeginner
Grouped checkboxes with a three-state select-all per group.
<!--
Grouped multi-select with a select-all per group. A listbox cannot do this:
"Select all" is a control, and role="option" may not contain one. Real
checkboxes in nested fieldsets can, so that is what this is.
The part everyone gets wrong is the third state. A select-all box has three,
not two - all, none, and SOME - and `indeterminate` is the only way to say
the third. It is a DOM property, not an attribute: you cannot write it in
markup, only set it from script (which is why the boxes below are wired up on
load rather than marked up as mixed).
Get it wrong and the box shows "unchecked" for a half-chosen group, so a
screen-reader user hears "not checked", ticks it expecting no change, and
silently grants four permissions they never asked for.
Every fieldset's legend names its group, so each checkbox announces as
"Billing, View invoices, checked" rather than a loose "View invoices" with no
clue which of the three sections it belongs to.
-->
<div class="mgroup">
<button class="mgroup__toggle" id="mgroup-toggle" type="button" aria-expanded="false" aria-controls="mgroup-panel">
<span class="mgroup__summary">Permissions: 3 selected</span>
<svg class="mgroup__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 class="mgroup__panel" id="mgroup-panel" hidden>
<fieldset class="mgroup__group" data-group="Billing">
<legend class="mgroup__legend">Billing</legend>
<label class="mgroup__row mgroup__row--all">
<input class="mgroup__all" type="checkbox" aria-label="Select all Billing" />
<span>Select all</span>
</label>
<label class="mgroup__row"><input class="mgroup__item" type="checkbox" value="billing:read" checked /><span>View invoices</span></label>
<label class="mgroup__row"><input class="mgroup__item" type="checkbox" value="billing:write" /><span>Edit payment methods</span></label>
<label class="mgroup__row"><input class="mgroup__item" type="checkbox" value="billing:refund" /><span>Issue refunds</span></label>
</fieldset>
<fieldset class="mgroup__group" data-group="Members">
<legend class="mgroup__legend">Members</legend>
<label class="mgroup__row mgroup__row--all">
<input class="mgroup__all" type="checkbox" aria-label="Select all Members" />
<span>Select all</span>
</label>
<label class="mgroup__row"><input class="mgroup__item" type="checkbox" value="members:read" checked /><span>View members</span></label>
<label class="mgroup__row"><input class="mgroup__item" type="checkbox" value="members:invite" checked /><span>Invite members</span></label>
<label class="mgroup__row"><input class="mgroup__item" type="checkbox" value="members:remove" /><span>Remove members</span></label>
</fieldset>
<fieldset class="mgroup__group" data-group="Projects">
<legend class="mgroup__legend">Projects</legend>
<label class="mgroup__row mgroup__row--all">
<input class="mgroup__all" type="checkbox" aria-label="Select all Projects" />
<span>Select all</span>
</label>
<label class="mgroup__row"><input class="mgroup__item" type="checkbox" value="projects:read" /><span>View projects</span></label>
<label class="mgroup__row"><input class="mgroup__item" type="checkbox" value="projects:deploy" /><span>Deploy</span></label>
</fieldset>
</div>
</div>
<script>
document.querySelectorAll('.mgroup').forEach(function (root) {
var toggle = root.querySelector('.mgroup__toggle');
var panel = root.querySelector('.mgroup__panel');
var summary = root.querySelector('.mgroup__summary');
var groups = Array.prototype.slice.call(root.querySelectorAll('.mgroup__group'));
function syncGroup(group) {
var all = group.querySelector('.mgroup__all');
var items = Array.prototype.slice.call(group.querySelectorAll('.mgroup__item'));
var checked = items.filter(function (i) { return i.checked; }).length;
all.checked = checked === items.length;
// The third state. Property only - there is no HTML attribute for it, so
// it has to be set here every time the group changes.
all.indeterminate = checked > 0 && checked < items.length;
}
function syncSummary() {
var total = Array.prototype.slice.call(root.querySelectorAll('.mgroup__item'))
.filter(function (i) { return i.checked; }).length;
summary.textContent = 'Permissions: ' + (total === 0 ? 'none selected' : total + ' selected');
}
function syncAll() {
groups.forEach(syncGroup);
syncSummary();
}
function setOpen(open) {
panel.hidden = !open;
toggle.setAttribute('aria-expanded', String(open));
}
toggle.addEventListener('click', function () {
setOpen(toggle.getAttribute('aria-expanded') !== 'true');
});
groups.forEach(function (group) {
var all = group.querySelector('.mgroup__all');
var items = Array.prototype.slice.call(group.querySelectorAll('.mgroup__item'));
all.addEventListener('change', function () {
// A half-chosen group resolves to "all", never to "none" - the user
// clicked toward completion, not away from their own work.
items.forEach(function (item) { item.checked = all.checked; });
syncAll();
});
items.forEach(function (item) {
item.addEventListener('change', syncAll);
});
});
root.addEventListener('keydown', function (event) {
if (event.key !== 'Escape') return;
setOpen(false);
toggle.focus();
});
document.addEventListener('mousedown', function (event) {
if (!root.contains(event.target)) setOpen(false);
});
syncAll();
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | - | Accessible label announced while loading. |
itemsrequired | PermissionGroup[] | - | The array of items to render. |
valuerequired | string[] | - | The metric's current value, pre-formatted. |
onSelect | (next: string[]) => void | - | Fired with the menu item the user chose. |
disabled | boolean | false | Prevents interaction and dims the control. |
A select-all box has three states, not two - all, none, and *some* - and `indeterminate` is the only way to say the third. It is a DOM property with no HTML attribute, so JSX cannot render it and it has to be written imperatively on every change. Skip that and a half-chosen group paints and announces as "not checked": a screen-reader user ticks it expecting nothing to change and silently grants every permission in the group. Note "some" resolves to *all* on click, never to none - the user clicked toward completion, not to throw away their own work. The group state is derived on every render, never cached, because a cached summary is a summary that drifts.