Press / to search

    No results

    Search failed

    npm GitHub

    <checkbox-group-elemental>

    The "select all" at the top of a list of checkboxes, per the APG Checkbox (Mixed-State) pattern: ticked when all of them are, empty when none are, and showing the dash when it is some of them. Light DOM, no shadow root, nothing moved or wrapped.

    <checkbox-group-elemental>
      <label><input type="checkbox" /> All notifications</label>
      <ul>
        <li><label><input type="checkbox" name="n" value="mentions" checked /> Mentions</label></li>
        <li><label><input type="checkbox" name="n" value="replies" /> Replies</label></li>
        <li><label><input type="checkbox" name="n" value="dm" checked /> Direct messages</label></li>
      </ul>
    </checkbox-group-elemental>

    Why this needs script at all

    The dash is HTMLInputElement.indeterminate, and MDN is explicit that it can only be set from JavaScript โ€” there is no HTML attribute for it. No server, template or static page can render a checkbox in that state, so the one line of markup that would have said it has to be script, in every project, every time. That is the gap this element fills, and the whole of it.

    It is also purely visual: what a checkbox submits is decided by checked alone, dashed or not. So the parent is a control, not a value โ€” leave it without a name and it stays out of your form data.

    The three states

    The children The parent shows The parent's properties
    all ticked a tick checked = true
    none ticked an empty box checked = false
    some ticked a dash indeterminate = true

    The element writes those two properties and data-state="all" / "some" / "none" on itself. That is everything โ€” no role, no aria-checked, because a native checkbox with indeterminate set is already announced as mixed.

    Pressing the parent

    Space and a click do the same thing, because on a checkbox Space is a click. The cycle is the APG's:

    Press The children go to
    from mixed all on
    from all on all off
    from all off back to the combination they were last mixed in

    That last step is the reason to have a tri-state parent instead of a two-state one: two ticks out of twenty are not destroyed by one press, they are one more press away from coming back. The combination is remembered every time the group becomes mixed โ€” including one the reader built by hand, and including the one the markup loaded in.

    The step is skipped when there is nothing in it worth stopping at โ€” nothing remembered, or a remembered combination that is all on, all off, or from when the group was a different size. So the cycle is three steps while there is something to go back to and two while there is not, rather than having a third step that lands where the second one did.

    <checkbox-group-elemental>
      <label><input type="checkbox" /> Select all</label>
      <ul>
        <li><label><input type="checkbox" checked /> Read</label></li>
        <li><label><input type="checkbox" /> Write</label></li>
        <li><label><input type="checkbox" /> Delete</label></li>
        <li><label><input type="checkbox" disabled /> Transfer ownership</label></li>
      </ul>
    </checkbox-group-elemental>

    A disabled checkbox is outside the set the parent speaks for: it is never moved, and it is never counted. So the tick means "everything selectable is selected", and the group above shows one the moment Read, Write and Delete are ticked โ€” Transfer ownership stays exactly as it was.

    Counting it instead is the obvious reading and it is a trap. A group holding one disabled and unticked box could never reach "all", so every press would compute "some", set everything it was allowed to, change nothing, and leave the cycle stuck on the step it was already on. A control whose state invites a press that does nothing is the worse lie.

    Usage

    The parent is the first checkbox in the element, in document order, which is where it has to be for the reader anyway. Everything after it is a child. There is no attribute to set and nothing to name:

    import 'book-of-elementals/checkbox-group';
    @use "book-of-elementals/checkbox-group/style.scss"; // structure
    @use "book-of-elementals/checkbox-group/theme.scss"; // the look, optional

    Or the single-element bundle โ€” no build step, no script to write:

    <script src="https://unpkg.com/book-of-elementals/dist/elementals/checkbox-group.min.js"></script>
    <link
      rel="stylesheet"
      href="https://unpkg.com/book-of-elementals/dist/elementals/checkbox-group.min.css"
    />
    <link
      rel="stylesheet"
      href="https://unpkg.com/book-of-elementals/dist/elementals/checkbox-group-theme.min.css"
    />

    One level, not a tree

    The pattern is a parent over a flat set of checkboxes, and that is all this is. A group nested inside another is a separate group: it keeps its own checkboxes, and neither reads nor is read by the one around it. An outer parent left with nothing of its own to coordinate does nothing at all.

    That is a refusal rather than a gap. In a tree the cycle stops being answerable โ€” restoring a branch's last mixed combination and restoring its parent's are two different restores of the same boxes, and no press can be both. Write the checkboxes flat, one group per parent.

    API

    No attributes. The element reads the checkboxes and writes what it finds:

    What Value
    data-state all, some or none โ€” what the children say
    parent.checked true when all of them are
    parent.indeterminate true when some of them are

    Properties

    Property Type Description
    parent HTMLInputElement Read-only. The select-all โ€” the first checkbox in the element.
    checkboxes HTMLInputElement[] Read-only. The ones it stands for, in document order.
    state string Read-only. all, some or none.
    apply() โ€” Re-read the checkboxes. Call it after adding or removing one.

    Events

    None of its own. The checkboxes are native, so listen for change โ€” and the parent fires one on every child it changes, exactly as clicking each of them would have:

    form.addEventListener('change', (e) => e.target.value);

    A select-all that moved twelve checkboxes silently would leave every listener downstream of it holding stale state, so it does not: one input and one change per checkbox that actually moved, and none for the ones that were already right.

    Setting .checked from script fires nothing โ€” that is the platform's rule for every form control โ€” so redraw with it:

    const group = document.querySelector('checkbox-group-elemental');
    
    group.checkboxes[0].checked = true;
    group.apply();

    Styling hooks

    checkbox-group-elemental[data-state="some"] {
    } /* the group is mixed */
    checkbox-group-elemental input:indeterminate {
    } /* the parent showing the dash โ€” a native selector, no class needed */
    checkbox-group-elemental:not(:defined) {
    } /* before upgrade */

    In a form

    Nothing to wire: the children are the form controls, so they submit, reset and restore themselves, and a <fieldset disabled> takes the whole group down.

    <form>
      <fieldset>
        <legend>Notifications</legend>
        <checkbox-group-elemental>
          <label><input type="checkbox" /> All notifications</label>
          <ul>
            <li><label><input type="checkbox" name="n" value="mentions" checked /> Mentions</label></li>
            <li><label><input type="checkbox" name="n" value="dm" /> Direct messages</label></li>
          </ul>
        </checkbox-group-elemental>
      </fieldset>
    </form>
    new FormData(form).getAll('n'); // ['mentions']

    Checkboxes sharing a name submit one entry each, and none at all when none are ticked โ€” so a server reading them has to treat "missing" as "none", the same as for a single checkbox. In PHP and Rails that name wants to be n[].

    Note

    Do not give the parent a name. It is a control over the others, not a value of its own, and indeterminate does not change what a checkbox submits โ€” a named parent would post its value whenever it happened to be ticked, which is a field your server did not ask for and cannot interpret.

    Degrading

    With no script the parent is a checkbox that ticks itself and commands nothing, which is a control lying about what it does โ€” so it is not offered at all until the element upgrades. style.scss splits the wait on @media (scripting): scripting off removes the first checkbox and its label outright, leaving the plain list of checkboxes it was standing in front of, every one of them working; scripting on holds their box invisible instead, so the list does not shift down when they land.

    That rule reaches a direct child and no further, because CSS cannot say "the first checkbox anywhere below me". Where the parent is deeper โ€” a <th> in a table header, which is where most select-alls live โ€” write hidden on it instead and the element removes the attribute when it upgrades, at any depth. The bulk actions example is that arrangement end to end.

    Missing What you get
    The script never loads The children, all working. No select-all, rather than a dead one
    The theme is not imported Native checkboxes, correctly grouped and indented

    The look

    style.scss is structure only; theme.scss is the look and is optional. What the theme adds of this element's own is one property โ€” the arrangement a lone checkbox has no use for:

    Property Default Description
    --checkbox-group-elemental-indent 1.75em How far the children sit in from the parent

    The boxes themselves are not this element's, though this element is why they exist: the third state is a dash, and accent-color recolours the browser's box while saying nothing about its size, its corners or the weight of a line through the middle of it. So they are drawn, in styles/checkbox.scss, which the theme imports for you and which any checkbox on the page can wear. The seven --checkbox-elemental-* properties, the forced-colors behaviour and how to point the same look at the rest of your form are all on the drawn checkbox.

    The Options tab below turns all eight โ€” the indent above and the seven the boxes share. Turn them until it looks the way you want, then copy the rule out of the bottom of the panel:

    <checkbox-group-elemental>
      <label><input type="checkbox" /> Turn me in the Options tab</label>
      <ul>
        <li><label><input type="checkbox" checked /> One</label></li>
        <li><label><input type="checkbox" /> Two</label></li>
      </ul>
    </checkbox-group-elemental>

    Select-all, or something else?

    Wanted Element
    One switch over a set of checkboxes this
    One setting that takes effect at once <switch-elemental>
    One choice out of a few <segmented-elemental>
    Several values out of many, searchable <combobox-elemental> with multiple

    If the list is long enough that the reader would rather search it than scroll it, a multiple combobox is the better control and its chips are the better summary.