Invokers (Explainer)
- Authors: Keith Cirkel
Invoker Buttons
Pitch in Code
<button invoketarget="my-dialog">This opens a dialog</button>
<dialog id="my-dialog">This is the dialog</dialog>
Introduction
Adding invoketarget
and invokeaction
attributes to <button>
and <input type="button">
/ <input type="reset">
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 invoketarget
will - when clicked, touched, or enacted via keypress - dispatch an InvokeEvent
on the element referenced by invoketarget
, 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"
.
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 theSpace
orEnter
(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 aninvoketarget
attribute set. - Invokee: An element which is referenced to by an Invoker, via the
invoketarget
attribute.
Proposed Plan
In the style of popovertarget
, this document proposes we add invoketarget
, and invokeaction
as available attributes to <button>
, <input type="button">
and <input type="reset">
elements.
interface mixin InvokerElement {
[CEReactions] attribute Element? invokeTargetElement;
[CEReactions] attribute DOMString invokeAction;
};
HTMLButtonElement extends InvokerElement
HTMLInputElement extends InvokerElement
The invoketarget
value should be an IDREF pointing to an element within the document. .invokeTargetElement
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 invokeaction
(and the .invokeAction
reflected property) is a freeform hint to the Invokee. InvokeAction can be a “built-in” action or a “custom” action. If invokeaction
is a falsey value (''
, null
, etc.) then it will default to 'auto'
.
Custom values must contain a -
. They will never trigger a browser default behaviour, aside from dispatching an InvokeEvent
. 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 invokeaction
. The built-in names must not contain a -
. An invokeaction
without a dash that is not a built-in is considered invalid, and will not dispatch an InvokeEvent.
Valid invokeactions (that is: custom invokeactions or a valid built-in) will dispatch InvokeEvents, allowing custom code to take control of invocations (for example calling .preventDefault()
or executing custom side-effects).
Elements with invoketarget
set will dispatch an InvokeEvent
on the Invokee (the element referenced by invoketarget
) when the element is Invoked. The InvokeEvent
’s type
is always invoke
. The event also contains an invoker
property that will reference the Invoker element. InvokeEvents
are always non-bubbling, composed, trusted, cancellable events.
[Exposed=Window]
interface InvokeEvent : Event {
constructor(InvokeEventInit invokeEventInit);
readonly attribute Element invoker;
readonly attribute DOMString type = "invoke";
readonly attribute DOMString action;
};
dictionary InvokeEventInit : EventInit {
DOMString action = "";
Element invoker;
};
If an element also has both a popovertarget
and invoketarget
attribute, then popovertarget
must be ignored: invoketarget
takes precedence.
If a <button>
is a form participant, or has type=submit
, then invoketarget
must be ignored.
If an <input>
is a form participant, or has a type
other than reset
or button
, then invoketarget
and interesttarget
must be ignored.
Example Code
Popovers
When pointing to a popover
, invoketarget
acts much like popovertarget
, allowing the toggling of popovers.
<button invoketarget="my-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>
, invoketarget
can toggle a <dialog>
’s openness.
<button invoketarget="my-dialog">Open Dialog</button>
<dialog id="my-dialog">
Hello world!
<button invoketarget="my-dialog" invokeaction="close">Close</button>
</dialog>
Details
Note: Invokers targeting a
<details>
element has been deferred from the initial release.
When pointing to a <details>
, invoketarget
can toggle a <details>
’ openness.
<button invoketarget="my-details">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 an invoketarget
to an <input type="file">
acts 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 invoketarget="my-file" invokeaction="showPicker">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 invokeaction
shines, allowing multiple buttons to handle different interactions with the video player.
<button invoketarget="my-video" invokeaction="playpause">Play/Pause</button>
<button invoketarget="my-video" invokeaction="toggleMuted">Mute/Unmute</button>
<video id="my-video"></video>
Custom behaviour
Invokers will dispatch events on the Invokee element. Using a dash in the invokeaction allows for custom JavaScript to be triggered without having to wire up manual event handlers to the Invokers.
<button invoketarget="my-custom">Invoke a div... to do something?</button>
<!-- Custom invokeactions must contain a dash! -->
<button invoketarget="my-custom" invokeaction="my-frobulate">Frobulate</button>
<div id="my-custom"></div>
<script>
document.getElementById("my-custom").addEventListener("invoke", (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 invoketarget
, invoking the button will trigger additional behaviours alongside the event dispatch. In addition the following table represents how built-in invocations on specific element types are handled. Note that this list is ordered and higher rules take precedence:
When the invokeaction
attribute is missing it will default to an auto state.
Invokee Element | action hint | Behaviour |
---|---|---|
<* popover> | (auto) | Same as 'togglePopover' |
<* popover> | 'togglePopover' | Call .togglePopover() on the invokee |
<* popover> | 'hidePopover' | Call .hidePopover() on the invokee |
<* popover> | 'showPopover' | Call .showPopover() on the invokee |
<dialog> | (auto) | If the <dialog> is not open , call showModal() , otherwise close and use the button value for returnValue |
<dialog> | 'showModal' | If the <dialog> is not open , call showModal() |
<dialog> | 'close' | If the <dialog> is open , close and use the button value for returnValue |
Further behaviours have been designed and may ship after the initial release of Invokers, the names and exact semantics may be subject to change:
Invokee Element | action hint | Behaviour |
---|---|---|
<details> | (auto) | Same as 'toggle' |
<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 call showModal() |
<dialog> | 'cancel' | If the <dialog> is open , cancel the dialog |
<select> | 'showPicker' | Call .showPicker() on the invokee |
<input> | 'showPicker' | Call .showPicker() on the invokee |
<video> | 'playpause' | Toggle the .playing value |
<video> | 'pause' | If .playing is true , set it to false |
<video> | 'play' | If .playing is false , set it to true |
<video> | 'toggleMuted' | Toggle the .muted value |
<audio> | 'playpause' | Toggle the .playing value |
<audio> | 'pause' | If .playing is true , set it to false |
<audio> | 'play' | If .playing is false , set it to true |
<audio> | 'toggleMuted' | Toggle the .muted value |
<*> | 'toggleFullscreen' | If the element is fullscreen, then exit, otherwise request to enter |
<*> | 'requestFullscreen' | Request the element to enter into ‘fullscreen’ mode |
<*> | 'exitFullscreen' | Request the element to exit ‘fullscreen’ mode |
<input type=number> | 'stepUp' | Call .stepUp() on the invokee |
<input type=number> | 'stepDown' | 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!
Invokers and Custom Elements
As the underlying system for invoke/interest elements uses event dispatch, Custom Elements can make use of InvokeEvent
/InterestEvent
s for their own behaviours. Consider the following:
<button invoketarget="my-element" invokeaction="spin-widget">
Spin the widget
</button>
<spin-widget id="my-element"></spin-widget>
<script>
customElements.define(
"spin-widget",
class extends HTMLElement {
connectedCallback() {
this.addEventListener("invoke", (e) => {
if (e.action === "spin-widget") {
this.spin();
}
});
}
},
);
</script>
PAQ (Potentially Asked Questions)
Why the name invoker
? Why not click
?
While click
is a fairly well established name in the world of the web, it is quite specific to certain types of HID and is not a term which encompasses all viable methods of interaction. In addition a clickaction
attribute is deemed to be a little too ambiguous as it conflates existing concepts. Given the opportunity to supply a new name, invoke
was settled on.
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 invoketarget
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 invoketarget
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" invoketarget="my-dialog" value="Cancel" />
</form>
</dialog>
What does this mean for popovertarget
?
Whilst invoketarget
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 invoketarget
will replace popovertarget
leading to popovertarget
’s eventual deprecation.
InvokeTarget seems limited, what if I wanted to add arguments?
Custom invokeaction
s 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 invoketarget="my-counter" invokeaction="add-1">Add 1</button>
<button invoketarget="my-counter" invokeaction="add-2">Add 2</button>
<button invoketarget="my-counter" invokeaction="add-10">Add 10</button>
<input readonly id="my-counter" value="0" />
<script>
const counter = document.getElementById("my-counter");
counter.addEventListener("invoke", (e) => {
let addMatch = /^add-(\d+)$/.match(e.action);
if (addMatch) {
counter.value = Number(counter.value) + Number(addMatch[1]);
}
});
</script>