Mike Schreiber



Link Role

Component

A <link-role> is another element decorator. An element decorator augments the behavior of the element it decorates without interfering with styles or layout by using display: contents.

<link-role> ensures the decorated element implements the link role spec, which includes use of ARIA attributes, focus behavior, and keyboard interaction. This can be useful for SEO purposes if you want content to behave as a link without it being possibly seen or indexed by search engines.

The attribute interface for <link-role> is identical to that of an <a>, including href and target.

The implementation of this component is heavily dependent on the fact that elements with display: contents; are not focusable. This means the component itself cannot fully implement the link ARIA role, and instead implements it on the decorated element. Consequently, if the component content is strictly text, the text will get wrapped in a <span> first.

const KEYCODE = { ENTER: 13, } class LinkRole extends HTMLElement { #wrappedElement #mutationObserver constructor() { super(); this.style.display = 'contents'; } connectedCallback() { if (this.firstElementChild) { this.#wrapElement(this.firstElementChild); } else if (this.textContent) { this.#convertTextContentToSpan(); this.#wrapElement(this.firstElementChild); } this.#mutationObserver = new MutationObserver(this.#onMutation); this.#mutationObserver.observe(this, {childList: true}) } disconnectedCallback() { this.#mutationObserver.disconnect(); } #wrapElement(el) { el.setAttribute('role', 'link'); if (!el.hasAttribute('tabindex')) { el.setAttribute('tabindex', '0'); } el.addEventListener('click', this.#onClick); el.addEventListener('keydown', this.#onKeyDown); this.#wrappedElement = el; } #unwrapElement() { this.#wrappedElement.removeEventListener('click', this.#onClick); this.#wrappedElement.removeEventListener('keydown', this.#onKeyDown); this.#wrappedElement = null; } #onMutation = () => { if (this.firstElementChild !== this.#wrappedElement) { this.#unwrapElement(); if (this.firstElementChild) { this.#wrapElement(this.firstElementChild); } } } #onClick = () => { this.#doLinkAction(); } #onKeyDown = (event) => { switch (event.keyCode) { case KEYCODE.ENTER: this.#doLinkAction(); break; } } #doLinkAction() { const attrs = this .getAttributeNames() .reduce((attrs, attrName) => { attrs[attrName] = this.getAttribute(attrName); return attrs; }, {}); const a = document.createElement('a'); for (const attrName in attrs) { a.setAttribute(attrName, attrs[attrName]); } a.click(); } #convertTextContentToSpan() { const text = this.textContent; this.textContent = ''; const span = document.createElement('span'); span.textContent = text; this.appendChild(span); } } if (!window.customElements.get('link-role')) window.customElements.define('link-role', LinkRole);

Demo

<link-role href="/">A link of only text</link-role> A link of only text <link-role href="/" target="_blank"> <button>A link as a button</button> </link-role>