Skip to content
Open UI

Invoker Commands (Explainer)

Invoker Buttons

Pitch in Code

<button command="show-modal" commandfor="my-dialog">This opens a dialog</button>

<dialog id="my-dialog">This is the dialog</dialog>

Introduction

Adding commandfor and command attributes to <button> and elements would allow authors to assign behaviour to buttons in a more accessible and declarative way, while reducing bugs and simplifying the amount of JavaScript pages are required to ship for interactivity. Buttons with command will - when clicked, touched, or enacted via keypress - dispatch a CommandEvent on the element referenced by commandfor, with some default behaviours.

Background

All elements within the DOM are capable of having interactions added to them. A long while ago this took the form of adding inline JavaScript to an event attribute, such as <button onclick="other.open()"></button>. Inline JavaScript has (rightly so) fallen out of favour due to the security and maintainability concerns. Newer pages may instead introduce more JavaScript to imperatively discover elements and call addEventListener('click', ...) to invoke the same behaviour. These patterns reduce developer experience and introduce more boilerplate and friction, while remediating security and maintainability concerns. Some frameworks attempt to reintroduce the developer experience of inline handlers by introducing new JavaScript or HTML shorthands, such as React’s onClick={...}, Vue’s @click=".." or HTMX’s hx-trigger="click".

Handling clicking is only half of the problem though, and this proposal doesn’t seek to re-invent click handlers. Dispatching an action to a related element on the click can also become chore-like depending on the framework of choice. Many frameworks recommend associating a click event with a state change and having the element to be invoked react to that state change. For example the React docs on Adding Interactivity attach click handlers which set state.

There has also been a rise in the desire to customise controls for components. Many sites, for example, introduce custom controls for File Uploads or dropdown menus. These often require a large amount of work to reintroduce the built in functionality of those controls, and often unintentionally sacrifice accessibility in doing so.

With the new popover attribute, we saw a straightforward declarative way to tell the DOM that a button was interested in being a participant of the popover interaction. popovertarget would indicate to a browser that if that button was interacted with, it should open the element the popovertarget value pointed to. This allows for popovers to be created and interacted with - in an accessible and reliable way - without writing any additional JavaScript, which is a very welcome addition. While popovertarget sufficiently captured the use case for popovers, it fell short of providing the same developer & user experience for other interactive elements such as <dialog>, <details>, <video>, <input type="file">, and so on. This proposal attempts to redress the balance.

Terms

  • Invoke/Invoked/Invoking: The action of Invoking refers to a complete press action of a button, using a Human Input Device (HID). If the HID is a mouse, this would be a click event. If the HID is a touch screen, this would be a press using a stylus or finger. If the HID is a keyboard this would be the Space or Enter (Carriage Return) key on the keyboard. For other HIDs such as eye tracking or pedals or game controllers, the equivalent expected “click” action should be used to invoke the element.
  • Invoker: An invoker is a button element (that is a <button>, <input type="button">, or <input type="reset">) that has an commandfor and command attribute set.
  • Invoker Target: An element which is referenced to by an Invoker, via the commandfor attribute.

Proposed Plan

In the style of popovertarget, this document proposes we add commandForElement, and command as available attributes to <button>.

interface mixin CommandElement {
  [CEReactions] attribute Element? commandForElement;
  [CEReactions] attribute DOMString command;
};

HTMLButtonElement extends InvokerElement

The commandfor value should be an IDREF pointing to an element within the document. .commandForElement also exists on the element to imperatively assign a node to be the invoker target, allowing for cross-root invokers (in some cases, see the popovertarget attr-asociated element steps for more).

The command attribute (and the .command reflected property) is a freeform hint to the invoker target. Command can be a “built-in” action or a “custom” action. If command is not supplied then it is considered invalid and no action will take place, and no event will be fired.

Custom command values must start with a double dash (--), similar to the CSS “dashed-ident” syntax. This is a safe namespace to use, and browsers guarantee to never have built-in values starting with --. The only browser behaviour for those values will be dispatching a CommandEvent on the invokee. This allows web developers to create custom Invoke actions for their components.

Built-in interactive elements have built-in behaviours (detailed below) which are determined by the command attribue/property. The built-in names must not start with a -.

Valid commands (that is: custom commands or a valid built-in) will dispatch CommandEvents, allowing custom code to take control of invocations (for example calling .preventDefault() or executing custom side-effects).

Invokers will dispatch an CommandEvent on the Invokee (the element referenced by commandfor) when the element is Invoked. The CommandEvent’s type is always command. The event also contains an source property that will reference button that was enacted causing the CommandEvent to fire. The dispatched CommandEvents are always non-bubbling, composed, trusted, cancellable events.

[Exposed=Window]
interface CommandEvent : Event {
  constructor(CommandEventInit invokeEventInit);
  readonly attribute Element source;
  readonly attribute DOMString type = "command";
  readonly attribute DOMString command;
};
dictionary CommandEventInit : EventInit {
  DOMString action = "";
  Element source;
};

If an element also has both a popovertarget and the commandfor/command attributes set, then popovertarget must be ignored: command takes precedence.

If a <button> is a form participant, or has type=submit, then command must be ignored. It also must not submit the form it is attached to. Buttons like this are “author errors”, and browsers may issue warnings in the developer console explaining to the developer why this button doesn’t work.

This means a <button> with command/commandfor that is inside a form must also have type=button.

Example Code

Popovers

When pointing to a popover, commandfor acts much like popovertarget, allowing the toggling of popovers.

<button commandfor="my-popover" command="toggle-popover">Open Popover</button>
<!-- Effectively the same as popovertarget="my-popover" -->

<div id="my-popover" popover="auto">Hello world</div>

Dialogs

When pointing to a <dialog>, command can toggle a <dialog>’s openness.

<button commandfor="my-dialog" command="show-modal">Open Dialog</button>

<dialog id="my-dialog">
  Hello world!

  <button commandfor="my-dialog" command="close">Close</button>
</dialog>

Details

Note: Invokers targeting a <details> element has been deferred from the initial release.

When pointing to a <details>, commandfor can toggle a <details>’ openness.

<button commandfor="my-details" command="open">Open Details</button>
<!-- Can be used to replicate the `<summary>` interaction -->

<details id="my-details">
  <summary>Summary...</summary>
  Hello world!
</details>

Customizing input type=file

Note: Invokers targeting a <input> element has been deferred from the initial release.

Pointing commandfor to an <input type="file"> can act the same as the rendered button within the input; and can be used to declare a customised alternative button to the input’s button.

<button commandfor="my-file" command="show-picker">Pick a file...</button>

<input id="my-file" type="file" />

Customizing video/audio controls

Note: Invokers targeting <audio> and <video> elements has been deferred from the initial release.

The <video> and <audio> tags have many interactions, here commandfor shines, allowing multiple buttons to handle different interactions with the video player.

<button commandfor="my-video" command="play-pause">Play/Pause</button>
<button commandfor="my-video" command="toggle-muted">Mute/Unmute</button>

<video id="my-video"></video>

Custom behaviour

Invokers will dispatch events on the Invokee element. Using a dash in the command allows for custom JavaScript to be triggered without having to wire up manual event handlers to the Invokers.

<!-- Custom commands must contain a double dash! -->
<button commandfor="my-custom" command="--my-frobulate">Frobulate</button>

<div id="my-custom"></div>

<script>
  document.getElementById("my-custom").addEventListener("command", (e) => {
    if (e.action === "--my-frobulate") {
      alert("Successfully frobulated the div");
    }
  });
</script>

Accessibility

Warning: This section is incomplete PRs welcome.

The Invoker implicitly receives aria-controls=IDREF or aria-details=IDREF (tbd) to expose the Invoker controls another element (the Invokee) for instances where the invokee is not a sibling to the invoker element.

If the Invokee has the popover attribute, the Invoker implicitly receives an aria-expanded state, as well as an aria-details association (for instances where the elements are not DOM siblings) which will match the state of the popover’s openness. It will be aria-expanded=true when the popover is :popover-open and aria-expanded=false otherwise.

If the Invokee is a <details> element the Invoker implicitly receives an aria-expanded state which will match the state of the Invokee’s openness. It will be aria-expanded=true when the Invokee is open and aria-expanded=false otherwise.

If the Invokee is a <dialog> element the Invoker implicitly receives an aria-expanded state which will match the state of the Invokee’s openness. It will be aria-expanded=true when the Invokee is open and aria-expanded=false otherwise.

Defaults

Depending on the target set by commandfor, invoking the button will trigger additional behaviours alongside the event dispatch, depending on the value of command. The following table represents how built-in invocations on specific element types are handled.

When the command attribute is missing it will default to an invalid state.

Invokee Elementaction hintBehaviour
<* popover>'toggle-popover'Shows the popover if closed, otherwise hides. Similar to .togglePopover()
<* popover>'hide-popover'Hides the popover if open, otherwise does nothing. Similar to .hidePopover()
<* popover>'show-popover'Shows the popover if closes, otherwise does nothing. Similar to .showPopover()
<dialog>'show-modal'If the <dialog> is not open, shows it as modal. Similar to .showModal()
<dialog>'close'If the <dialog> is open, close and use the button value for returnValue. Similar to `.close(value)

Further behaviours have been proposed, but need further design on exactly how they will behave based on implications such as accessibility, security, interactivity, and how the button may need to respond to such actions.

Invokee Elementaction hintBehaviour
<details>'toggle'If the <details> is open, then close it, otherwise open it
<details>'open'If the <details> is not open, then open it
<details>'close'If the <details> is open, then close it
<dialog>'toggle'If the <dialog> is open, then close it and use the button value for returnValue, otherwise open as modal
<dialog>'cancel'If the <dialog> is open, cancel the dialog
<select>'show-picker'Shows the native picker. Similar to .showPicker() on the invokee
<input>'show-picker'Shows the native picker. Similar to .showPicker() on the invokee
<video>'play-pause'If the video is not playing, plays the video. Otherwise pauses it. Similar to el.playing = !el.playing
<video>'pause'If the video is playing, pause the video. Similar .playing = false
<video>'play'If the video is not playing, play the video. Similar to .playing = true
<video>'toggle-muted'If the video is muted, it unmutes the video, otherwise it mutes it. Similar to el.muted = !el.muted
<audio>'play-pause'If the audio is not playing, plays the audio. Otherwise pauses it. Similar to el.playing = !el.playing
<audio>'pause'If the audio is playing, pause the audio. Similar .playing = false
<audio>'play'If the audio is not playing, play the audio. Similar to .playing = true
<audio>'toggle-muted'If the audio is muted, it unmutes the audio, otherwise it mutes it. Similar to el.muted = !el.muted
<*>'toggle-fullscreen'If the element is fullscreen, then exit, otherwise request to enter
<*>'request-fullscreen'Request the element to enter into ‘fullscreen’ mode
<*>'exit-fullscreen'If the element is fullscreen, then exit
<input type=number>'step-up'Call .stepUp() on the invokee
<input type=number>'step-down'Call .stepDown() on the invokee

Note: The above tables are an attempt at wide coverage, but ideas are welcome. Please submit a PR if you have one!

Further to the initial ship we’re also exploring implicit command or implicit commandfor values where the value can easily be inferred.

Invokers and Custom Elements

As the underlying system for invoke elements uses event dispatch, Custom Elements can make use of CommandEvent for their own behaviours. Consider the following:

<button commandfor="my-element" command="--spin-widget">
  Spin the widget
</button>

<spin-widget id="my-element"></spin-widget>
<script>
  customElements.define(
    "spin-widget",
    class extends HTMLElement {
      connectedCallback() {
        this.addEventListener("command", (e) => {
          if (e.action === "--spin-widget") {
            this.spin();
          }
        });
      }
    },
  );
</script>

PAQ (Potentially Asked Questions)

Why the name command? Why not click?

While click is a fairly well established name in the world of the web, it is quite specific to the action from a user gesture, whereas invoke events notify a related element that an interactive control is requesting action. In other words, a click happens on the element being interacted with - the invoker, while an invoke happens on the element the interactive control points to - the invokee.

If the invokee were to recieve click events from the interactive control, this would make it difficult to differentiate user interaction on the element vs on invoking elements. Take for example a video player; clicking on the area which renders the video may play/pause the video, but clicking on a video control may cause different behaviours on the video. The extra controls invoke an action on the video but the user gesture was performed on the controls themselves.

Invokers rely on click events for their behavior to work, and they are not intended to replace them. Instead, invokers enhance click events to drive behaviour on a different element. Consequently they need a new conceptual name for this. Given the opportunity to supply a new name, invoke was settled on.

Given the new conceptual model of “command” it stands to reason that all related concepts share the same name, as such commandfor and command were chosen rather than, say, clickfor/clickaction. Similarly the event class is named CommandEvent and the event name command.

Didn’t this used to be called invoke? What happened?

The original proposal for Invoke Commands was to use the term invoke exhaustively through the API, and so the attributes were originally named invoketarget/invokeaction and the event named InvokeEvent with a type of invoke. This was deemed too abstract, and standards groups and web developers desired a simpler name. For those interested in the history, some conversation that lead to changing invoke to command starts around here: https://github.com/whatwg/html/issues/9625#issuecomment-2115718679

Do we have to always supply both? Can’t we make command or commandfor implicit?

The original proposal had the concept of an “auto” command value which would determine an exlicit command based on various heuristics, such as the target element. This has been deferred for the initial ship, but may be explored further. This is considred out of scope for the initial ship, however.

We may also explore the possibility of making commandfor implicit, for example if a button is a descendant of a dialog, omitting commandfor may make sense. This is also considered out of scope for the initial ship.

What about adding Invoker defaults for <form>?

Defaults for <form> are intentionally omitted as this proposal does not aim to replace Reset or Submit buttons. If you want to control forms, use those.

What about adding Invoker defaults for <a>?

Defaults for <a> are intentionally omitted as this proposal does not aim to replace anchors. If you intend to produce a page navigation, use an <a> tag.

Why is command/commandfor limited to buttons?

This is by design, to allow for a “pit of success”; invoking actions on non-button elements such as <div>s or <a>s creates many problems, especially for non-interactive elements. While <a>s are interactive, they should only be used for page navigation and not for invoking other behaviours, and so command/commandfor should not be allowed.

Why isn’t input[type=submit] included?

This is not added by design. Submit inputs already have a default action: submitting forms. If you want a button to control the submission of a form, use input[type=submit], if you want a button to control invocation of something other than a form then you should use input[type=button].

Why is input[type=reset] included?

It may stand to reason that if input[type=submit] is excluded then so should input[type=reset], however, there are valid use cases to resetting a form at the same time as some other action, for example closing the dialog that contains a form:

<dialog id="my-dialog">
  <form>
    <input type="text" />
    <!-- This button closes the dialog _and_ resets the form -->
    <input type="reset" commandfor="my-dialog" command="close" value="Cancel" />
  </form>
</dialog>

What does this mean for popovertarget?

Whilst command/commandfor does replicate popovertarget’s functionality, the two will co-exist side by side for some while to enable a smooth transition.

It is, however, intended that commandfor will replace popovertarget leading to popovertarget’s eventual deprecation.

Why doesn’t command just call the JS API method?

HTML attribute values have different casing to method names, so for example an attribute value of show-modal does not match the method name showModal(). This requires manual mapping to the respective methods, but browsers - under the hood - effectively map from these values to the respective methods. Similarly, custom commands must be prefixed with a double dash (--). It is possible to convert the dashed value to camel case, and use computed properties to call methods, if that’s desired:

<button commandfor="my-element" command="--spin-widget">
  Spin the widget
</button>

<spin-widget id="my-element"></spin-widget>
<script>
  customElements.define(
    "spin-widget",
    class extends HTMLElement {
      connectedCallback() {
        this.addEventListener("command", (e) => {
          const method = e.command
            .slice(2) // remove the double dash
            .replace(/(-\w)/g, c => c[1].toUpperCase()); // dash-to-camel case
          if (method in this) {
            this[method](event);
          }
        });
      }
      spinWidget(event) {
        // ...
      }
    },
  );
</script>

Command seems limited, what if I wanted to add arguments?

Custom commands can be used in a freeform way, for your own elements. If you feel it necessary you can invent your own DSLs for passing extra data using this hint. For example:

<button commandfor="my-counter" command="--add-1">Add 1</button>
<button commandfor="my-counter" command="--add-2">Add 2</button>
<button commandfor="my-counter" command="--add-10">Add 10</button>

<input readonly id="my-counter" value="0" />

<script>
  const counter = document.getElementById("my-counter");
  counter.addEventListener("command", (e) => {
    let addMatch = /^--add-(\d+)$/.match(e.action);
    if (addMatch) {
      counter.value = Number(counter.value) + Number(addMatch[1]);
    }
  });
</script>

You can also leverage the buttons value attribute to effectively parameterise certain commands:

<button commandfor="my-counter" command="--add-num" value="1">Add 1</button>
<button commandfor="my-counter" command="--add-num" value="2">Add 2</button>
<button commandfor="my-counter" command="--add-num" value="10">Add 10</button>

<input readonly id="my-counter" value="0" />

<script>
  const counter = document.getElementById("my-counter");
  counter.addEventListener("command", (e) => {
    if (e.command == '--add-num') {
      counter.value = Number(counter.value) + Number(e.invoker.value);
    }
  });
</script>