Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
{{on "click" ...}} vs {{invoke ...}} in regards to a11y

{{on "click" ...}} vs {{invoke ...}}

... in regards to A11y

In the early days of the web, there was the "click" event which - if the element had a default action - will invoke it with keyboard controls. Back in the days, there was no such things as apps running in the browser, just HTML documents.

Today, the "click" event works as back in the days and this "when an element has a default action" will invoke it via keyboard is becoming an uncertainty, making the "click" event pretty much unpredictable. At which point will it invoke with keyboard at which point without? Here are some examples (Imagine all of them have a click event attached):

  • <a id="...">Just an anchor</a> - Does not have keyboard support
  • <a href="#foo">Go there</a> - Has keyboard support
  • <button>Foo</button> - Has keyboard support
  • <button disabled>Nope</button> - No keyboard support
  • <div tabindex="0">mew</div> - Can be focussed with keyboard, doesn't have support for invocation via keyboard

Results: https://codepen.io/gossi/pen/bGNdqOw

Given the combination of element + attribute the element will behave in a predictable way or not. If we can control the element plus attributes we are usually in good company and we can preditably do what we want. Hence when working with components we usually don't know what element is being used over there, which can end up in suprise boxes, eg:

<SomeComponent {{on "click" this.doSomething}}/>

This is a very unpredictable setup, we don't know what invocation methods are supported. Hence we at some point just want user to invoke our component no matter what and it doesn't matter to us what kind they prefer: mouse, tapping or keyboard. We want them to be able to do that. This is why I'm asking if the {{invoke}} modifier is the right way to do it (from a11y perspective) - see code below.

At some point we are unable to control the element of the underlying component. What we can do, is put aria- (and related) attributes on those elements to overwrite their initial behavior and modify it to our needs.

As an example. We might have a <Card> component and we either use it for informative purpose as well as for interactive parts. From design perspective they both look the same (and even may have the same base-component to that). From that perspective it's even unnecessary to recreate the two components and give them different root elements with that. Instead:

  • <Card {{invoke this.doSomething}}/> - Interactive card: When tabindex="0" is present can be styled differently, just using the selector
  • <Card> - Informative card: Just show the content yielded into it :)

Question

Does it make sense to use such a modifier or are there some shortcomings with it, that I don't see right now - that might be killer-arguments to that approach?

import Modifier from 'ember-modifier';
import { action } from '@ember/object';
interface InvokeArgs {
positional: [() => void],
named: {}
}
/**
* Invoke modifier:
*
* ```hbs
* <div {{invoke this.interactWithDiv}}
* ```
*
* "click" is not reliable, given on which element it is attached and what kind
* of attributes are available it behaves totally different.
*
* What we want is a reliable way to activate a UI element in an a11y fashion
*
* @see https://www.w3.org/TR/WCAG20-TECHS/SCR35.html
*/
export default class InvokeModifier extends Modifier<InvokeArgs> {
private mouseEvent?: MouseEvent;
private touchEvent?: TouchEvent;
get action() {
return this.args.positional[0];
}
didInstall() {
if (this.element) {
window.addEventListener('mousedown', this.handleMouseStart, false);
this.element.addEventListener('mouseup', this.handleMouseEnd, false);
window.addEventListener('touchstart', this.handleTouchStart, false);
this.element.addEventListener('touchend', this.handleTouchEnd, false);
this.element.addEventListener('keydown', this.handleKeyboard, false);
this.element.setAttribute('tabindex', '0');
}
}
willInstall() {
if (this.element) {
window.removeEventListener('mousedown', this.handleMouseStart);
this.element.removeEventListener('mouseup', this.handleMouseEnd);
window.removeEventListener('touchstart', this.handleTouchStart);
this.element.removeEventListener('touchend', this.handleTouchEnd);
this.element.removeEventListener('keydown', this.handleKeyboard);
}
}
@action
handleMouseStart(event: MouseEvent) {
this.mouseEvent = event;
}
@action
handleMouseEnd(event: MouseEvent) {
this.finishEvent(this.mouseEvent, event);
}
@action
handleTouchStart(event: TouchEvent) {
this.touchEvent = event;
}
@action
handleTouchEnd(event: TouchEvent) {
this.finishEvent(this.touchEvent, event);
}
private finishEvent(originalEvent: Event | undefined, event: Event) {
if (originalEvent && originalEvent.target === event.target) {
this.invoke();
}
}
@action
handleKeyboard(event: KeyboardEvent) {
console.log('handlekeyboard', event);
if (event.key === 'Enter' || event.key === ' ') {
this.invoke();
}
}
private invoke() {
if (this.action) {
this.action();
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment