Skip to content

Instantly share code, notes, and snippets.

@NickGard
Last active July 26, 2024 14:33
Show Gist options
  • Save NickGard/b53eff441faba8486b0767aaa91093c6 to your computer and use it in GitHub Desktop.
Save NickGard/b53eff441faba8486b0767aaa91093c6 to your computer and use it in GitHub Desktop.
State sequence switching in HTML

State switching in HTML

HTML is where mature JavaScript patterns graduate to, removing the need for scripting for common, well-established user interactions. Having a button switch some state on click is a common pattern that needs to move into a declarative HTML-only setup.

Problem statement:

Button-type buttons (<button type="button"></button>) have no inherent behavior. Currently, they require scripting to do anything. A common behavior added is to switch some state:

  • Menu button toggles the state of a menu between "open" and "closed"
  • Toggle button switches a site setting, e.g. between "dark mode," "light mode," and "match OS"/"auto"
  • Tab/accordion button switches the state of the tabset or accordion to one particular section (alternatively, it sets the state for the designated section to "active/open/visible" and the states for all other sections to "inactive/closed/hidden")

Description

Statefulness has long been a part of web pages, with browsers doing extra work to try to restore the state of a page when navigating back to it. Consider filled out form fields and scroll position, both of which the browser attempts to restore when possible. CSS enables reading and reacting to statefulness with pseudo-selectors like :checked, :hover, :invalid, :disabled, etc. Custom web components have a dedicated API for introducing, modifying, and reading state: CustomStateSet and :state().

This proposal attempts to generalize the markup for creating, changing, and reading state without requiring scripting. The CustomStateSet API should be made available on stateful elements so that states can be managed via Javascript as well.

Consider checkboxes, where the attribute checked conveys only the initial state and can't be changed to be unchecked (it must be removed to uncheck), and needs several pseudo-selectors to read state :indeterminate, :checked, and :unchecked. Additionally, if the DOM property for the input is assigned anything (e.b. el.checked = false;), attribute reflection is broken and adding or removing the checked attribute no longer has an effect. Alternatively, this could be achieved with

<style>
button[role="switch"] {
  /* basic styles */
  
  &:state(unchecked) {
    /* unchecked styles */
  }
  
  &:state(checked) {
    /* checked styles */
  }
}
</style>
<button
  type="button"
  states="checked unchecked"
  role="switch"
  aria-checked="fromstate(checked as true, unchecked as false)">
</button>

Button-target mappings

This proposal allows for many-to-many mappings between buttons and stateful element targets. Each button can set (the same) state for multiple stateful elements by listing their IDs in the statefor attribute. Each stateful element can have multiple buttons targeting it, each with its own set of states to transition the element into.

Many-to-one example

<style>
  #many-to-one {
    &:state(red) { color: red; }
    &:state(blue) { color: blue; }
    &:state(green) { color: green; }
    &:state(yellow) { color: yelow; }
  }
</style>
<div id="many-to-one">Lots of buttons can switch my state</div>
<button type="button" statefor="many-to-one" states="blue red">Blue or red</button>
<button type="button" statefor="many-to-one" states="green yellow">Green or yellow</button>
<button type="button" statefor="many-to-one" states="blue green">Blue or green</button>

One-to-many example

<style>
  #one, #two, #three {
    &:state(open) { height: auto; }
    &:state(closed) { height: 0; }
  }
</style>
<div id="one">One</div>
<div id="two">Two</div>
<div id="three">Three</div>
<button type="button" statefor="one two three" states="open close">Open or close all</button>

Many-to-many example

<style>
  #one {
    /* defaults to closed */
    > .content { height: 0; }
  
    &:state(one) > .content { height: auto; }
  }
  #two {
    /* defaults to closed */
    > .content { height: 0; }
  
    &:state(two) > .content { height: auto; }
  }
  #three {
    /* defaults to closed */
    > .content { height: 0; }
  
    &:state(three) > .content { height: auto; }
  }
</style>
<div id="one">
  <span>One<span><button type="button" statefor="one two three" states="one">See more</button>
  <div class="content">
    Content that is hidden when closed
    <button type="button" statefor="one" states="close">See less</button>
  </div>
</div>
<div id="two">
  <span>Two<span><button type="button" statefor="one two three" states="two">See more</button>
  <div class="content">
    Content that is hidden when closed
    <button type="button" statefor="two" states="close">See less</button>
  </div>
</div>
<div id="three">
  <span>Three<span><button type="button" statefor="one two three" states="three">See more</button>
  <div class="content">
    Content that is hidden when closed
    <button type="button" statefor="three" states="close">See less</button>
  </div>
</div>
<button type="button" statefor="one two three" states="close">Close all</button>

API

statefor: an id or space-separated list of ids.

  • Only valid (for designating stateful element targets) on button elements with type="button"
  • If not present, any states are applied to the button itself

states: a space-separated list of possible states.

  • When applied to a button, it indicates which states it cycles through. If the stateful element is not currently in one of the states listed, clicking the button will set the element to the first state. If the stateful element is in one of the states listed, it will transistion to the next in the list, or the first if the element is in the last state listed.
  • When applied to a stateful element (which could be the button itself), it creates an explicit sequence of states that can be used to inform what the "next" state is, and it sets the initial state to the first in the sequence. This explicit list does not restrict possible states. The element can be put into a state not listed (the "next" state would then be the first state listed). For example, in the following setup, the div is in the initial state of 3 because that is the first state listed and when the "Next even" button is clicked, it will transition to 4 rather than 2 (which would be the case if the div didn't have an explicit state list). When the "Zero" button is clicked, the div is transistioned to the 0 state, and a subsequent click of "Next even" would transistion it to 2 because 0 isn't in the explicit states list and 2 is the first state in the "Next even" button's state list.
<div id="count" states="3 4 5 6 7 8 1 2"></div>
<button type="button" statefor="count" states="1 3 5 7">Next odd</button>
<button type="button" statefor="count" states="2 4 6 8">Next even</button>
<button type="button" statefor="count" states="0">Zero</button>

fromstate(): a function for reading state and applying it to a property in HTML. Can have zero or more arguments.

  • Zero arguments applies the current state as a string to the property, e.g. <input type="fromstate()"><button type="button" states="text number date">change input type</button>
  • Arguments are reassignments of states to strings, in the format <state> as <string>. Any states not explicitly listed in the arguments return themselves as strings, like they do when no arguments are passed. The order of the arguments does not affect the state switching sequence.
<input id="Img" type="file" accept="image/*" capture="fromstate(forward as environment, selfie as user)">
<button type="button" states="selfie forward" statefor="Img">switch camera</button>

statechange event: An event dispatched on the stateful element when its state changes. Bubbles and is not cancelable. Dispatches after the click event, unless that event is canceled. (If the click event is canceled, the stateful element is not transistioned to a new state.) Has the following properties:

  • relatedTarget: the button element that triggered the state change or null if it was triggered via script
  • prevState: the element's previous state or null if the element didn't have one.
  • state: the element's current state

onstatechange attribute: An inline handler for statechange events. This could be used to simulate a simplified version of the invoker commands proposal:

<dialog id="info" onstatechange="this.states.has(open) ? this.showModal() : this.close()">
  <p>some content</p>
  <button type="button" statefor="info" states="close">Close</button>
</dialog>
<button type="button" statefor="info" states="open">Open</button>

Accessibility

It will be up to the browser to expose states to the Accessibility Object Model (AOM) and assistive tech (e.g. screen readers) to incorporate a stateful element's state. The proposed statefor attribute on buttons could be used in place of aria-controls. A set of "well-known" states could be used in place of aria-checked/current/disabled/busy/hidden/invalid/pressed/selected and any other states currently declared via ARIA attributes.

Where ARIA attributes currently apply, authors can use the fromstate() value function to add dynamic values that change with the element's state.

Open questions

  • Should other elements be allowed to be state-setting elements?
    • <button> (no type, defaults to "submit")
    • <button type="submit|reset">
    • <input type="button|submit|reset|radio|checkbox">
  • Should fromstate() allow for a default mapping, so unknown states will get a certain string? Like fromState(someState as someString, @else as defaultString)
  • Should fromstate() allow for removing boolean attributes? Like open="fromstate(closed as remove())" for dialog elements.
  • Should buttons targeting multiple stateful elements be allowed to set different states per element? Or, if we allow for multiple states to co-occur on a single element, can a single button set multiple states on the same element?
  • Since the CustomStateSet accounts for multiple co-occuring states, should multiple states be allowed? Something like
<style>
  #X {
    &:state(color:red) { color: red; }
    &:state(color:blue) { color: blue; }
    &:state(color:green) { color: green; }
  
    &:state(size:small) { width: 5em; }
    &:state(size:medium) { width: 10em; }
    &:state(size:large) { width: 15em; }
  
    /* combo state */
    &:state(color:red):state(size:large) {
      border: 3px dashed orange;
    }
  }
</style>
<div id="X">target</div>
<button type="button" statefor="X" states="color: red green blue">set color</button>
<button type="button" statefor="X" states="size: small medium large">set size</button>

Non-goals

  • Reading state from arbitrary elements in HTML, e.g. prop="fromstate(#some-id, state1, state2)". (Reading state from other elements is possible in CSS with the :has() structure selector.)
  • Setting sub-states. An element may be in a single state (in this proposal). For example, consider an element that can have the states car, and truck, as well as blue, and red. Sub-states would allow some dynamic permutation of this (e.g. setstate="car" preserving the color state, or :state(red) matching both truck and car states). This proposal requires that the buttons setting the states provide the whole state (blue-car, blue-truck, red-car, and red-truck) and the CSS state selector reads the same whole state.
  • Setting infinite state. For example, a counter with no cap.

Comparison to other proposals

CSS Toggles

This proposal is abandoned

https://tabatkins.github.io/css-toggle/

This proposal constructed the entirety of the state declaration and switching behavior in CSS only. The HTML would have no knowledge of the states or the connection between stateful elements and state-setting buttons (called triggers in this proposal). It defaults to allowing an infinite list of states (1, 2, 3, ...) but allows a list of enums too. It allows for transistioning between states in both directions, forward and backward. It allows for different cycling behavior, sticking to the last state, or starting over at the first state.

Invoker commands

https://open-ui.org/components/invokers.explainer/

This proposal is heavily reliant on JavaScript. It introduces two new attributes for button elements, command and commandfor, and their DOM API counterparts, command and commandForElement respectively. It enforces a 1-to-1 or a many-to-1 mapping between buttons and targets (multiple buttons can have the same target, but a single button cannot have multiple targets). It relies on built-in methods present on the target elements, e.g. showModal() on a dialog element. This proposal calls for the dispatching of a cancelable command event on the target element. If JavaScript is disabled, these attributes will have no effect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment