2
\$\begingroup\$

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?
\$\endgroup\$
1
  • \$\begingroup\$ This seems reasonable and clear... but I'm not an accessibility expert. \$\endgroup\$
    – ndp
    Commented Feb 7, 2022 at 23:51

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.