Search code examples
htmlreactjssemantic-markup

How to semantically mark up multilevel checkbox and radio groups?


I will have 2 lists: with radio buttons and with checkboxes (multi-level) My current html is:

<label>
<input type="radio" name="group" checked={checked} onChange={()=>callback(value)}/>
{label}
</label>

<label>
<input type="radio" name="subgroup" checked={checked} onChange={()=>callback(value)}/>
{label}
</label>
<label>
<input type="radio" name="subgroup" checked={checked} onChange={()=>callback(value)}/>
{label}
</label>

Is it fine that I don't have my subgroup inside of first label? I've seen many different examples, but I was wondering which one is semantically correct?


Solution

  • To answer your first question, <label> elements can only label one form element at a time, so you shouldn't put more than one form element inside a <label>.

    For the radio group, since you can only select one sub-item out of all of the sub-items, the most semantic and accessible way to present this would be as a <select> element with <optgroup> elements for each top-level grouping and <option> elements for each sub-item. That's what this element is for.

    <label for="dino-select">Choose a dinosaur:</label>
    <select id="dino-select">
        <optgroup label="Theropods">
            <option>Tyrannosaurus</option>
            <option>Velociraptor</option>
            <option>Deinonychus</option>
        </optgroup>
        <optgroup label="Sauropods">
            <option>Diplodocus</option>
            <option>Saltasaurus</option>
            <option>Apatosaurus</option>
        </optgroup>
    </select>

    For the case where you need to be able to select multiple things, you can add the multiple attribute:

    <label for="dino-select">Choose one or more dinosaurs:</label>
    <select id="dino-select" multiple>
        <optgroup label="Theropods">
            <option>Tyrannosaurus</option>
            <option>Velociraptor</option>
            <option>Deinonychus</option>
        </optgroup>
        <optgroup label="Sauropods">
            <option>Diplodocus</option>
            <option>Saltasaurus</option>
            <option>Apatosaurus</option>
        </optgroup>
    </select>

    If you don't like using a <select multiple /> element (some users don't know how to use them because they're uncommon) you could use a nested list of checkboxes.

    Be sure to semantically denote which items are owned by other items in an accessible way, and include javascript to add the necessary functionality.

    Notes on expected behavior:

    • If a parent item is selected, all children should become selected
    • If all children items become selected, the parent should become selected
    • If all children items become deselected, the parent should become deselected
    • If some children of a parent item are selected but others are not, the parent item should be in an indeterminate state

    Here is one approach to that:

    const setInputState = (el, state) => {
      if (state === 'indeterminate') {
        el.indeterminate = true
      } else {
        el.indeterminate = false
        el.checked = state 
      }
    }
    
    const updateOwned = (el) => {
      if (el.hasAttribute('data-children')) {
        let state = el.checked
        el.getAttribute('data-children').split(' ').forEach(id => {
          let owned = document.getElementById(id)
          setInputState(owned, state)
          updateOwned(owned)
        })
      }
    }
    
    const updateOwner = (el) => {
      if (el.hasAttribute('data-parent')) {
        let owner = document.getElementById(el.getAttribute('data-parent'))
        let states = []
        let collectiveState
        owner.getAttribute('data-children').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"][data-children], input[type="checkbox"][data-parent]').forEach(input => {
        input.addEventListener('change', event => {
          updateOwned(event.currentTarget)
          updateOwner(event.currentTarget)
        })
      })
    })
    body {
      padding: 2rem;
    }
    label {
      display: block;
    }
    label span:before {
      content: ' ';
    }
    fieldset fieldset {
      border: none;
      padding: 0 0 0 1ch;
    }
    <fieldset class="nested-multiselect">
      <legend>Categories </legend>
      <label id="label-fruit">
        <input id="fruit" type="checkbox" name="categories" value="fruit" aria-owns="subcategories-fruit" data-children="apple orange banana"/><span>fruit</span>
      </label>
      <fieldset id="subcategories-fruit" aria-label="fruit subcategories">
        <label id="label-apple">
          <input id="apple" type="checkbox" name="categories" value="apple" aria-owns="subcategories-apple" data-parent="fruit" data-children="gala macintosh honeycrisp"/><span>apple</span>
        </label>
        <fieldset id="subcategories-apple" aria-label="apple subcategories">
          <label id="label-gala">
            <input id="gala" type="checkbox" name="categories" value="gala" data-parent="apple"/><span>gala</span>
          </label>
          <label id="label-macintosh">
            <input id="macintosh" type="checkbox" name="categories" value="macintosh" data-parent="apple"/><span>macintosh</span>
          </label>
          <label id="label-honeycrisp">
            <input id="honeycrisp" type="checkbox" name="categories" value="honeycrisp" data-parent="apple"/><span>honeycrisp</span>
          </label>
        </fieldset>
        <label id="label-orange">
          <input id="orange" type="checkbox" name="categories" value="orange" data-parent="fruit"/><span>orange</span>
        </label>
        <label id="label-banana">
          <input id="banana" type="checkbox" name="categories" value="banana" data-parent="fruit"/><span>banana</span>
        </label>
      </fieldset>
      <label id="label-vegetables">
        <input id="vegetables" type="checkbox" name="categories" value="vegetables" aria-owns="subcategories-vegetables" data-children="squash peas leek"/><span>vegetables</span>
      </label>
      <fieldset id="subcategories-vegetables" aria-label="vegetables subcategories">
        <label id="label-squash">
          <input id="squash" type="checkbox" name="categories" value="squash" data-parent="vegetables"/><span>squash</span>
        </label>
        <label id="label-peas">
          <input id="peas" type="checkbox" name="categories" value="peas" data-parent="vegetables"/><span>peas</span>
        </label>
        <label id="label-leek">
          <input id="leek" type="checkbox" name="categories" value="leek" data-parent="vegetables"/><span>leek</span>
        </label>
      </fieldset>
    </fieldset>