InvokerCommands
I love the OpenUI Community Group. So much web goodness has come out of that group. I was recently working on my very slow-going effort to create a proper design system when I found myself with the desire to create a standard uniform way of interacting with things like popovers, modals, and the like. In my research I was reminded of Invoker Commands which I had read about some time ago on OpenUI's site but had since forgotten because it was only an idea at the time. Well it's since become a standard and has some support in Chrome and soon to be Safari. The idea is essentially to take the popover API and generalize it. It would allow a button to become an invoker for any native1 interaction without the need for any JavaScript. Let me show you what I mean.
Native Commands
Suppose you have a dialog
element and a button
meant to open it as a modal dialog. Today, the only way to that is to use JavaScript to invoke the dialog's showModal
method when the button is clicked. The Invoker Command API gives us a declarative, HTML only alternative.
<div>
<button command="show-modal" commandfor="my-modal">
Click to open Modal
</button>
<dialog id="my-modal">
<p>Look ma! No JS!</p>
<button command="close" commandfor="my-modal">Close</button>
</dialog>
</div>
The API is pretty straight forward. The button
element now takes a command
and commandfor
attribute. The commandfor
attribute contains the id
of the element who's command is meant to be invoked. In this case, it is the #my-modal
element. The command
attribute value is the command to invoke. In this case, show-modal
.
The command
attribute currently accepts one of six values:
show-menu
close
request-close
show-popover
hide-popover
toggle-popover
The first three correspond to the dialog methods showMenu
, close
, and requestClose
. The last three correspond to the popover methods showPopover
, hidePopover
, and togglePopover
. If the command is not recognized, or not relevant to the target, the action does nothing.
Custom commands
The command
attribute can also take a custom value, but as with CSS custom properties, the value must start with a double dash --
. This distinguishes between intentionally custom commands and misspelled, or unsupported native commands. By doing this, the spec leaves room for additional commands to be added later without worrying about name clashes with custom commands.
Since these are custom, the browser doesn't know what do do with them. Instead, the browser fires off a CommandEvent. You will need to listen for a CommandEvent on your target element and handle it as you seem fit.
<button command="--random-number" commandfor="my-output">
Give me a random number
</button>
<output id="my-output">
3.14159
</output>
document.querySelector('#my-output')
.addEventListener('command', (commandEvent) => {
if (commandEvent.command !== '--random-number') return;
commandEvent.target.innerText = Math.random();
})
Justification
Why is this better than just adding a click event handler on the button and updating the output then? For one, with the invoker command API, the target of the command can react to commands from any source, not just button clicks, but button keyboard events, and non-user initiated events such as intervals and timeouts. It orients the action around the target element, rather than the triggering element. This is a much better separation of concerns. It also means less code, and less coupling. You don't need to gain or maintain access to the target element reference from within the triggering element's event handler. The event handler lives with the target.
Design Systems
Invoker commands, particularly custom invoker commands, gives us a standardized way to set up a common action language in our design systems. One could delineate these commands by urgency, creating custom --show-toast-urgent
, and --show-toast-success
. Your button components need not know anything about these as they are applied by the consumer as attributes. The logic for what these mean can live entirely in your toast
component.
Support
I'm excited to see where others take this, but I have specific plans. Unfortunately, Invoker commands is not universally implmented yet. At the time of writing this, Chromium is the only browser that supports it. Safari has it in their next technical preview, and Firefox has not implemented it at all.
Polyfill
No matter. Being the impatient person that I am, I decided to create a polyfill2 for it. I leave it here for your enjoyment, and hopefully for the betterment of your code. If you, like me, feel much more comfortable owning as much of the code that executes on your sites as possible, feel free to copy and paste this into your project. Tweak it as you see fit, or don't. What ever. This blog is mostly for me anyway and I needed a place to conviently store this implementation.
export default () => {
if ('CommandEvent' in globalThis) return;
class CommandEvent extends Event {
command;
source;
constructor(type = 'command', options) {
super(type, options);
this.command = options.command;
this.source = options.source;
}
}
document.documentElement.addEventListener('click', (event) => {
const composedPath = event.composedPath();
const commander = composedPath.find(el => el.matches?.('button[command]'));
const context = composedPath.find(el => !!el.host) || document.documentElement;
if (!commander) return;
const commandFor = commander.getAttribute('commandfor');
const commandTarget = context.querySelector(`#${commandFor}`);
if (!commandTarget) return
const command = commander.getAttribute('command');
if (command.startsWith('--')) return commandTarget.dispatchEvent(new CommandEvent('command', { command, target: commandTarget, source: commander }));
switch (command) {
case 'close': return commandTarget.close?.();
case 'hide-popover': return commandTarget.hidePopover?.();
case 'request-close': return commandTarget.requestClose?.();
case 'show-modal': return commandTarget.showModal?.();
case 'show-popover': return commandTarget.showPopover?.();
case 'toggle-popover': return commandTarget.togglePopover?.();
default: console.warn(`Unknown command: ${command}. Custom commands must start with '--'.`);
}
})
console.log('Invoker Commands API polyfilled')
}
To use it, simply import it and call the default export.
import commandPolyfill from './invoker-command-polyfill.js';
commandPolyfill();
The polyfill checks for whether the invoker command API exists before trying to polyfill it, so it shouldn't interfere with anything thing. It will also automatically polyfill custom-elements with shadowDom's as long as the are in the open
mode. Let me know your thoughts or suggestions.
[1] Invoker commands also allow for custom commands, but those do require some JavaScript to listen for the CommandEvent and react accordingly.
[2] I'm quite sure other polyfills exist for this, it wasn't hard to do. But I do try to keep my reliance on 3rd party code to a minimum, so I decided to write my own.