I'm trying to figure out the most accessible and semantic way to allow users to select one or more options from a hierarchical list. Think a taxonomy of categories and subcategories.
I came across a Bootstrap Multiselect plugin that emulates a <select>
with clickable <optgroup>
elements, but the way it's implemented doesn't seem very accessible—so I'm trying to create my own approach.
Things that are important:
- The state of a parent option should correctly reflect the state of its children
- Toggling a parent should toggle all children
- It should be easily understandable and navigable by users with screen readers
- Users with screen readers should be able to easily understand when options are children of other options
- It should properly rely on native HTML semantics, and when necessary, ARIA attributes
This is what I have currently:
const setInputState = (el, state) => {
if (state === 'indeterminate') {
el.indeterminate = true
} else {
el.indeterminate = false
el.checked = state
}
}
const updateOwned = (el) => {
if (el.hasAttribute('aria-owns')) {
let state = el.checked
el.getAttribute('aria-owns').split(' ').forEach(id => {
let owned = document.getElementById(id)
setInputState(owned, state)
updateOwned(owned)
})
}
}
const updateOwner = (el) => {
if (el.hasAttribute('data-ownedby')) {
let owner = document.getElementById(el.getAttribute('data-ownedby'))
let states = []
let collectiveState
owner.getAttribute('aria-owns').split(' ').every(id => {
let owned = document.getElementById(id)
let state = owned.indeterminate === true ? 'indeterminate' : owned.checked
if (states.length > 0 && states.indexOf(state) === -1) {
collectiveState = 'indeterminate'
return false
} else {
states.push(state)
return true
}
})
collectiveState = collectiveState || states[0]
setInputState(owner, collectiveState)
updateOwner(owner)
}
}
document.querySelectorAll('.nested-multiselect').forEach(multiselect => {
multiselect.querySelectorAll('input[type="checkbox"][aria-owns], input[type="checkbox"][data-ownedby]').forEach(input => {
input.addEventListener('change', event => {
updateOwned(event.currentTarget)
updateOwner(event.currentTarget)
})
})
})
body {
padding: 1rem;
}
ul {
margin: 0;
padding: 0;
}
ul ul {
padding: 0 0 0 1rem;
}
li {
list-style: none;
}
<form>
<fieldset>
<legend id="legend">Categories</legend>
<ul class="nested-multiselect">
<li>
<label id="a-label">
<input id="a" type="checkbox" name="option" value="a"/>
<span>a</span>
</label>
</li>
<li>
<label id="b-label">
<input id="b" type="checkbox" name="option" value="b" aria-owns="b1 b2 b3"/>
<span>b</span>
</label>
<ul role="group" aria-labelledby="b-label">
<li>
<label id="b1-label">
<input id="b1" type="checkbox" name="option" value="b1" data-ownedby="b"/>
<span>b1</span>
</label>
</li>
<li>
<label id="b2-label">
<input id="b2" type="checkbox" name="option" value="b2" data-ownedby="b" aria-owns="b2a b2b"/>
<span>b2</span>
</label>
<ul role="group" aria-labelledby="b2-label">
<li>
<label id="b2a-label">
<input id="b2a" type="checkbox" name="option" value="b2a" data-ownedby="b2"/>
<span>b2a</span>
</label>
</li>
<li>
<label id="b2b-label">
<input id="b2b" type="checkbox" name="option" value="b2b" data-ownedby="b2"/>
<span>b2b</span>
</label>
</li>
</ul>
</li>
<li>
<label id="b3-label">
<input id="b3" type="checkbox" name="option" value="b3" data-ownedby="b"/>
<span>b3</span>
</label>
</li>
</ul>
</li>
<li>
<label id="c-label">
<input id="c" type="checkbox" name="option" value="c"/>
<span>c</span>
</label>
</li>
</ul>
</fieldset>
</form>
Questions that I have:
- Are nested unordered lists the best way to semantically communicate hierarchy?
- Is using
aria-labelledby
on subgroup<ul>
elements referencing the parent option helpful for understanding the relationship between parent and child options? - Is using
aria-owns
on the parent option referencing the child options helpful for understanding the relationship between parent and child options? - Are any of the above methods ineffective or harmfully redundant?
- What else would you change?
- Is there an altogether better approach?