Skip to content

Instantly share code, notes, and snippets.

@renoirb
Last active March 11, 2022 01:00
Show Gist options
  • Save renoirb/c14050700e634099646823abead68c8f to your computer and use it in GitHub Desktop.
Save renoirb/c14050700e634099646823abead68c8f to your computer and use it in GitHub Desktop.
aria utilities
//
// Creates a menu button that opens a menu of links
//
// TODO continue refactoring WAI's example for reusability
//
// Source: https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html
//
export interface IFocusBy<T> {
readonly firstChars: ReadonlySet<string>
setFocus(by: T)
}
export const isPrintableCharacter = (str: string): boolean => {
const isTextualString = str.match(/\S/) !== null
return str.length === 1 && isTextualString;
}
export class FocusByKey implements IFocusBy<string> {
private _host: HTMLElement
get firstChars(): ReadonlySet<string> {
const data: ReadonlySet<string> = new Set([...this._firstChars.keys()])
return data
}
private _firstChars = new Set<string>()
constructor(domNode: HTMLElement) {
this._host = domNode
var nodes: NodeListOf<HTMLElement> = domNode.querySelectorAll('[role="menuitem"]');
for (var i = 0; i < nodes.length; i++) {
var menuitem = nodes[i];
const fc = menuitem.textContent.trim()[0].toLowerCase()
this._firstChars.add(fc);
menuitem.dataset.focusKey = fc
}
}
setFocus(by: string) {
if (this.firstChars.has(by)) {
const detail = {
eventName: 'key',
data: by,
}
const event = new CustomEvent('focus-by', { bubbles: true, composed: true, cancelable: true, detail })
this._host.dispatchEvent(event)
}
}
}
//
// Creates a menu button that opens a menu of links
//
// TODO continue refactoring WAI's example for reusability
//
// Source: https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html
//
export interface IFocusManageable {
readonly domNode: HTMLElement
}
export class SubMenuLike implements IFocusManageable {
readonly domNode: HTMLElement
constructor(domNode: HTMLElement) {
this.domNode = domNode;
this.buttonNode = domNode.querySelector('button');
this.menuNode = domNode.querySelector('[role="menu"]');
this.menuitemNodes = [];
this.firstMenuitem = false;
this.lastMenuitem = false;
this.firstChars = [];
this.buttonNode.addEventListener(
'keydown',
this.onButtonKeydown.bind(this)
);
this.buttonNode.addEventListener('click', this.onButtonClick.bind(this));
var nodes = domNode.querySelectorAll('[role="menuitem"]');
for (var i = 0; i < nodes.length; i++) {
var menuitem = nodes[i];
this.menuitemNodes.push(menuitem);
menuitem.tabIndex = -1;
this.firstChars.push(menuitem.textContent.trim()[0].toLowerCase());
menuitem.addEventListener('keydown', this.onMenuitemKeydown.bind(this));
menuitem.addEventListener(
'mouseover',
this.onMenuitemMouseover.bind(this)
);
if (!this.firstMenuitem) {
this.firstMenuitem = menuitem;
}
this.lastMenuitem = menuitem;
}
domNode.addEventListener('focusin', this.onFocusin.bind(this));
domNode.addEventListener('focusout', this.onFocusout.bind(this));
window.addEventListener(
'mousedown',
this.onBackgroundMousedown.bind(this),
true
);
}
setFocusToMenuitem(newMenuitem) {
this.menuitemNodes.forEach(function (item) {
if (item === newMenuitem) {
item.tabIndex = 0;
newMenuitem.focus();
} else {
item.tabIndex = -1;
}
});
}
setFocusToFirstMenuitem() {
this.setFocusToMenuitem(this.firstMenuitem);
}
setFocusToLastMenuitem() {
this.setFocusToMenuitem(this.lastMenuitem);
}
setFocusToPreviousMenuitem(currentMenuitem) {
var newMenuitem, index;
if (currentMenuitem === this.firstMenuitem) {
newMenuitem = this.lastMenuitem;
} else {
index = this.menuitemNodes.indexOf(currentMenuitem);
newMenuitem = this.menuitemNodes[index - 1];
}
this.setFocusToMenuitem(newMenuitem);
return newMenuitem;
}
setFocusToNextMenuitem(currentMenuitem) {
var newMenuitem, index;
if (currentMenuitem === this.lastMenuitem) {
newMenuitem = this.firstMenuitem;
} else {
index = this.menuitemNodes.indexOf(currentMenuitem);
newMenuitem = this.menuitemNodes[index + 1];
}
this.setFocusToMenuitem(newMenuitem);
return newMenuitem;
}
setFocusByFirstCharacter(currentMenuitem, char) {
var start, index;
if (char.length > 1) {
return;
}
char = char.toLowerCase();
// Get start index for search based on position of currentItem
start = this.menuitemNodes.indexOf(currentMenuitem) + 1;
if (start >= this.menuitemNodes.length) {
start = 0;
}
// Check remaining slots in the menu
index = this.firstChars.indexOf(char, start);
// If not found in remaining slots, check from beginning
if (index === -1) {
index = this.firstChars.indexOf(char, 0);
}
// If match was found...
if (index > -1) {
this.setFocusToMenuitem(this.menuitemNodes[index]);
}
}
// Utilities
getIndexFirstChars(startIndex, char) {
for (var i = startIndex; i < this.firstChars.length; i++) {
if (char === this.firstChars[i]) {
return i;
}
}
return -1;
}
// Popup menu methods
openPopup() {
this.menuNode.style.display = 'block';
this.buttonNode.setAttribute('aria-expanded', 'true');
}
closePopup() {
if (this.isOpen()) {
this.buttonNode.removeAttribute('aria-expanded');
this.menuNode.style.display = 'none';
}
}
isOpen() {
return this.buttonNode.getAttribute('aria-expanded') === 'true';
}
// Menu event handlers
onFocusin() {
this.domNode.classList.add('focus');
}
onFocusout() {
this.domNode.classList.remove('focus');
}
onButtonKeydown(event) {
var key = event.key,
flag = false;
switch (key) {
case ' ':
case 'Enter':
case 'ArrowDown':
case 'Down':
this.openPopup();
this.setFocusToFirstMenuitem();
flag = true;
break;
case 'Esc':
case 'Escape':
this.closePopup();
this.buttonNode.focus();
flag = true;
break;
case 'Up':
case 'ArrowUp':
this.openPopup();
this.setFocusToLastMenuitem();
flag = true;
break;
default:
break;
}
if (flag) {
event.stopPropagation();
event.preventDefault();
}
}
onButtonClick(event) {
if (this.isOpen()) {
this.closePopup();
this.buttonNode.focus();
} else {
this.openPopup();
this.setFocusToFirstMenuitem();
}
event.stopPropagation();
event.preventDefault();
}
onMenuitemKeydown(event) {
var tgt = event.currentTarget,
key = event.key,
flag = false;
function isPrintableCharacter(str) {
return str.length === 1 && str.match(/\S/);
}
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
if (event.shiftKey) {
if (isPrintableCharacter(key)) {
this.setFocusByFirstCharacter(tgt, key);
flag = true;
}
if (event.key === 'Tab') {
this.buttonNode.focus();
this.closePopup();
flag = true;
}
} else {
switch (key) {
case ' ':
window.location.href = tgt.href;
break;
case 'Esc':
case 'Escape':
this.closePopup();
this.buttonNode.focus();
flag = true;
break;
case 'Up':
case 'ArrowUp':
this.setFocusToPreviousMenuitem(tgt);
flag = true;
break;
case 'ArrowDown':
case 'Down':
this.setFocusToNextMenuitem(tgt);
flag = true;
break;
case 'Home':
case 'PageUp':
this.setFocusToFirstMenuitem();
flag = true;
break;
case 'End':
case 'PageDown':
this.setFocusToLastMenuitem();
flag = true;
break;
case 'Tab':
this.closePopup();
break;
default:
if (isPrintableCharacter(key)) {
this.setFocusByFirstCharacter(tgt, key);
flag = true;
}
break;
}
}
if (flag) {
event.stopPropagation();
event.preventDefault();
}
}
onMenuitemMouseover(event) {
var tgt = event.currentTarget;
tgt.focus();
}
onBackgroundMousedown(event) {
if (!this.domNode.contains(event.target)) {
if (this.isOpen()) {
this.closePopup();
this.buttonNode.focus();
}
}
}
}
@renoirb
Copy link
Author

renoirb commented Mar 11, 2022

Screen Reader Only

import { html, LitElement, css, property } from 'lit-element'
import { classMap } from 'lit-html/directives/class-map'
import { customElement } from '../custom-element'
import { t } from '../directives/translate'

const TAG_NAME = 'screen-reader-only-span'

@customElement(TAG_NAME)
export class ScreenReaderOnlySpan extends LitElement {
  /**
   * Bookmark:
   * - https://gist.github.com/ffoodd/000b59f431e3e64e4ce1a24d5bb36034
   */
  static styles = [
    /**
     *  Improved screen reader only CSS class
     *  @author Gaël Poupard
     *  @note Based on Yahoo!'s technique
     *  @author Thierry Koblentz
     *  @see https://developer.yahoo.com/blogs/ydn/clip-hidden-content-better-accessibility-53456.html
     *  * 1.
     *  @note `clip` is deprecated but works everywhere
     *  @see https://developer.mozilla.org/en-US/docs/Web/CSS/clip
     *  * 2.
     *  @note `clip-path` is the future-proof version, but not very well supported yet
     *  @see https://developer.mozilla.org/en-US/docs/Web/CSS/clip-path
     *  @see http://caniuse.com/#search=clip-path
     *  @author Yvain Liechti
     *  @see https://twitter.com/ryuran78/status/778943389819604992
     *  * 3.
     *  @note preventing text to be condensed
     *  author J. Renée Beach
     *  @see https://medium.com/@jessebeach/beware-smushed-off-screen-accessible-text-5952a4c2cbfe
     *  @note Drupal 8 goes with word-wrap: normal instead
     *  @see https://www.drupal.org/node/2045151
     *  @see http://cgit.drupalcode.org/drupal/commit/?id=5b847ea
     *  * 4.
     *  @note !important is important
     *  @note Obviously you wanna hide something
     *  @author Harry Roberts
     *  @see https://csswizardry.com/2016/05/the-importance-of-important/
     **/
    css`
      .sr-only {
        border: 0 !important;
        clip: rect(1px, 1px, 1px, 1px) !important; /* 1 */
        -webkit-clip-path: inset(50%) !important;
        clip-path: inset(50%) !important; /* 2 */
        height: 1px !important;
        margin: -1px !important;
        overflow: hidden !important;
        padding: 0 !important;
        position: absolute !important;
        width: 1px !important;
        white-space: nowrap !important; /* 3 */
      }
    `,
    /**
     * Use in conjunction with .sr-only to only display content when it's focused.
     * @note Useful for skip links
     * @see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1
     * @note Based on a HTML5 Boilerplate technique, included in Bootstrap
     * @note Fixed a bug with position: static on iOS 10.0.2 + VoiceOver
     * @author Sylvain Pigeard
     * @see https://github.com/twbs/bootstrap/issues/20732
     */
    css`
      .sr-only-focusable:focus,
      .sr-only-focusable:active {
        clip: auto !important;
        -webkit-clip-path: none !important;
        clip-path: none !important;
        height: auto !important;
        margin: auto !important;
        overflow: visible !important;
        width: auto !important;
        white-space: normal !important;
      }
    `,
  ]

  @property({ type: String, attribute: 'data-translate-from' }) translateFrom = ''
  @property({ type: Boolean, attribute: 'data-focusable' }) isFocusable = false

  render() {
    return html`<!-- -->
      <span
        class=${classMap({
          'sr-only': true,
          'sr-only-focusable': this.isFocusable,
        })}
      >
        ${t(this.translateFrom)}
      </span>
      <!-- -->`
  }
}

declare global {
  interface HTMLElementTagNameMap {
    readonly [TAG_NAME]: ScreenReaderOnlySpan
  }
}

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