From 08acc413f62e2955ef90b9353f2867421eb607fa Mon Sep 17 00:00:00 2001 From: Elizabeth Mitchell Date: Wed, 6 Sep 2023 13:10:10 -0700 Subject: [PATCH] chore: add polyfill for Firefox ElementInternals aria PiperOrigin-RevId: 563194223 --- internal/aria/aria.ts | 184 +++++++++++++++++++++++++++++++ internal/aria/aria_test.ts | 217 ++++++++++++++++++++++++++++++++++++- radio/internal/radio.ts | 29 ++--- tabs/internal/tabs.ts | 23 ++-- 4 files changed, 417 insertions(+), 36 deletions(-) diff --git a/internal/aria/aria.ts b/internal/aria/aria.ts index b3cf5f6c8..e23a61f62 100644 --- a/internal/aria/aria.ts +++ b/internal/aria/aria.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {isServer, ReactiveElement} from 'lit'; + /** * Accessibility Object Model reflective aria property name types. */ @@ -178,3 +180,185 @@ export type ARIARole = 'doc-glossary'|'doc-glossref'|'doc-index'|'doc-introduction'|'doc-noteref'| 'doc-notice'|'doc-pagebreak'|'doc-pagelist'|'doc-part'|'doc-preface'| 'doc-prologue'|'doc-pullquote'|'doc-qna'|'doc-subtitle'|'doc-tip'|'doc-toc'; + +/** + * Enables a host custom element to be the target for aria roles and attributes. + * Components should set the `elementInternals.role` property. + * + * By default, aria components are tab focusable. Provide a `focusable: false` + * option for components that should not be tab focusable, such as + * `role="listbox"`. + * + * This function will also polyfill aria `ElementInternals` properties for + * Firefox. + * + * @param ctor The `ReactiveElement` constructor to set up. + * @param options Options to configure the element's host aria. + */ +export function setupHostAria( + ctor: typeof ReactiveElement, {focusable}: SetupHostAriaOptions = {}) { + if (focusable !== false) { + ctor.addInitializer(host => { + host.addController({ + hostConnected() { + if (host.hasAttribute('tabindex')) { + return; + } + + host.tabIndex = 0; + } + }); + }); + } + + if (isServer || 'role' in Element.prototype) { + return; + } + + // Polyfill reflective aria properties for Firefox + for (const ariaProperty of ARIA_PROPERTIES) { + ctor.createProperty(ariaProperty, { + attribute: ariaPropertyToAttribute(ariaProperty), + reflect: true, + }); + } + + ctor.createProperty('role', {reflect: true}); +} + +/** + * Options for setting up a host element as an aria target. + */ +export interface SetupHostAriaOptions { + /** + * Whether or not the element can be focused with the tab key. Defaults to + * true. + * + * Set this to false for aria roles that should not be tab focusable, such as + * `role="listbox"`. + */ + focusable?: boolean; +} + +/** + * Polyfills an element and its `ElementInternals` to support `ARIAMixin` + * properties on internals. This is needed for Firefox. + * + * `setupHostAria()` must be called for the element class. + * + * @example + * class XButton extends LitElement { + * static { + * setupHostAria(XButton); + * } + * + * private internals = + * polyfillElementInternalsAria(this, this.attachInternals()); + * + * constructor() { + * super(); + * this.internals.role = 'button'; + * } + * } + */ +export function polyfillElementInternalsAria( + host: ReactiveElement, internals: ElementInternals) { + if (checkIfElementInternalsSupportsAria(internals)) { + return internals; + } + + if (!('role' in host)) { + throw new Error('Missing setupHostAria()'); + } + + let firstConnectedCallbacks: Array<() => void> = []; + let hasBeenConnected = false; + + // Add support for Firefox, which has not yet implement ElementInternals aria + for (const ariaProperty of ARIA_PROPERTIES) { + let ariaValueBeforeConnected: string|null = null; + Object.defineProperty(internals, ariaProperty, { + enumerable: true, + configurable: true, + get() { + if (!hasBeenConnected) { + return ariaValueBeforeConnected; + } + + // Dynamic lookup rather than hardcoding all properties. + // tslint:disable-next-line:no-dict-access-on-struct-type + return host[ariaProperty]; + }, + set(value: string|null) { + const setValue = () => { + // Dynamic lookup rather than hardcoding all properties. + // tslint:disable-next-line:no-dict-access-on-struct-type + host[ariaProperty] = value; + }; + + if (!hasBeenConnected) { + ariaValueBeforeConnected = value; + firstConnectedCallbacks.push(setValue); + return; + } + + setValue(); + }, + }); + } + + let roleValueBeforeConnected: string|null = null; + Object.defineProperty(internals, 'role', { + enumerable: true, + configurable: true, + get() { + if (!hasBeenConnected) { + return roleValueBeforeConnected; + } + + return host.getAttribute('role'); + }, + set(value: string|null) { + const setRole = () => { + if (value === null) { + host.removeAttribute('role'); + } else { + host.setAttribute('role', value); + } + }; + + if (!hasBeenConnected) { + roleValueBeforeConnected = value; + firstConnectedCallbacks.push(setRole); + return; + } + + setRole(); + }, + }); + + host.addController({ + hostConnected() { + if (hasBeenConnected) { + return; + } + + hasBeenConnected = true; + for (const callback of firstConnectedCallbacks) { + callback(); + } + + // Remove strong callback references + firstConnectedCallbacks = []; + } + }); + + return internals; +} + + +// Separate function so that typescript doesn't complain about internals being +// "never". +function checkIfElementInternalsSupportsAria(internals: ElementInternals) { + return 'role' in internals; +} diff --git a/internal/aria/aria_test.ts b/internal/aria/aria_test.ts index e7436308e..8fccc375d 100644 --- a/internal/aria/aria_test.ts +++ b/internal/aria/aria_test.ts @@ -6,7 +6,10 @@ // import 'jasmine'; (google3-only) -import {ARIAProperty, ariaPropertyToAttribute, isAriaAttribute} from './aria.js'; +import {html, LitElement} from 'lit'; +import {customElement} from 'lit/decorators.js'; + +import {ARIAProperty, ariaPropertyToAttribute, isAriaAttribute, polyfillElementInternalsAria, setupHostAria} from './aria.js'; describe('aria', () => { describe('isAriaAttribute()', () => { @@ -41,4 +44,216 @@ describe('aria', () => { .toBe('aria-labelledby'); }); }); + + describe('setupHostAria()', () => { + @customElement('test-setup-aria-host') + class TestElement extends LitElement { + static { + setupHostAria(TestElement); + } + + override render() { + return html``; + } + } + + it('should not hydrate tabindex attribute on creation', () => { + const element = new TestElement(); + expect(element.hasAttribute('tabindex')) + .withContext('has tabindex attribute') + .toBeFalse(); + }); + + it('should set tabindex="0" on element once connected', () => { + const element = new TestElement(); + document.body.appendChild(element); + expect(element.getAttribute('tabindex')) + .withContext('tabindex attribute value') + .toEqual('0'); + + element.remove(); + }); + + it('should not set tabindex on connected if one already exists', () => { + const element = new TestElement(); + element.tabIndex = -1; + document.body.appendChild(element); + expect(element.getAttribute('tabindex')) + .withContext('tabindex attribute value') + .toEqual('-1'); + + element.remove(); + }); + + it('should not change tabindex if disconnected and reconnected', () => { + const element = new TestElement(); + document.body.appendChild(element); + element.tabIndex = -1; + element.remove(); + document.body.appendChild(element); + expect(element.getAttribute('tabindex')) + .withContext('tabindex attribute value') + .toEqual('-1'); + }); + + if (!('role' in Element.prototype)) { + describe('polyfill', () => { + it('should hydrate aria attributes when ARIAMixin is not supported', + async () => { + const element = new TestElement(); + document.body.appendChild(element); + element.role = 'button'; + await element.updateComplete; + expect(element.getAttribute('role')) + .withContext('role attribute value') + .toEqual('button'); + + element.remove(); + }); + }); + } + }); + + describe('polyfillElementInternalsAria()', () => { + @customElement('test-polyfill-element-internals-aria') + class TestElement extends LitElement { + static { + setupHostAria(TestElement); + } + + internals = polyfillElementInternalsAria(this, this.attachInternals()); + + constructor() { + super(); + this.internals.role = 'button'; + } + + override render() { + return html``; + } + } + + if ('role' in ElementInternals.prototype) { + it('should not hydrate attributes when role set', () => { + const element = new TestElement(); + document.body.appendChild(element); + expect(element.hasAttribute('role')) + .withContext('has role attribute') + .toBeFalse(); + + element.remove(); + }); + } else { + it('should preserve role values when set before connected', () => { + const element = new TestElement(); + // TestElement() sets role in constructor + expect(element.internals.role) + .withContext('ElementInternals.role') + .toEqual('button'); + }); + + it('should preserve aria values when set before connected', () => { + const element = new TestElement(); + element.internals.ariaLabel = 'Foo'; + expect(element.internals.ariaLabel) + .withContext('ElementInternals.ariaLabel') + .toEqual('Foo'); + }); + + it('should hydrate role attributes when set before connection', + async () => { + const element = new TestElement(); + // TestElement() sets role in constructor + document.body.appendChild(element); + await element.updateComplete; + expect(element.getAttribute('role')) + .withContext('role attribute value') + .toEqual('button'); + + element.remove(); + }); + + it('should hydrate aria attributes when set before connection', + async () => { + const element = new TestElement(); + element.internals.ariaLabel = 'Foo'; + document.body.appendChild(element); + await element.updateComplete; + expect(element.getAttribute('aria-label')) + .withContext('aria-label attribute value') + .toEqual('Foo'); + + element.remove(); + }); + + it('should set aria attributes when set after connection', async () => { + const element = new TestElement(); + document.body.appendChild(element); + element.internals.ariaLabel = 'Value after construction'; + await element.updateComplete; + expect(element.getAttribute('aria-label')) + .withContext('aria-label attribute value') + .toEqual('Value after construction'); + + element.remove(); + }); + + it('should handle setting role multiple times before connection', + async () => { + const element = new TestElement(); + element.internals.role = 'button'; + element.internals.role = 'checkbox'; + + expect(element.internals.role) + .withContext('internals.role before connection') + .toEqual('checkbox'); + document.body.appendChild(element); + await element.updateComplete; + expect(element.internals.role) + .withContext('internals.role after connection') + .toEqual('checkbox'); + + element.remove(); + }); + + it('should handle setting aria properties multiple times before connection', + async () => { + const element = new TestElement(); + element.internals.ariaLabel = 'First'; + element.internals.ariaLabel = 'Second'; + + expect(element.internals.ariaLabel) + .withContext('internals.ariaLabel before connection') + .toEqual('Second'); + document.body.appendChild(element); + await element.updateComplete; + expect(element.internals.ariaLabel) + .withContext('internals.ariaLabel after connection') + .toEqual('Second'); + + element.remove(); + }); + + it('should handle setting role after first connection while disconnected', + async () => { + const element = new TestElement(); + element.internals.role = 'button'; + document.body.appendChild(element); + await element.updateComplete; + + element.remove(); + element.internals.role = 'checkbox'; + expect(element.internals.role) + .withContext('internals.role after connected and disconnected') + .toEqual('checkbox'); + document.body.appendChild(element); + await element.updateComplete; + expect(element.internals.role) + .withContext('internals.role after reconnected') + .toEqual('checkbox'); + + element.remove(); + }); + } + }); }); diff --git a/radio/internal/radio.ts b/radio/internal/radio.ts index 59b691746..3a85bd5ad 100644 --- a/radio/internal/radio.ts +++ b/radio/internal/radio.ts @@ -11,6 +11,7 @@ import {html, isServer, LitElement} from 'lit'; import {property} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; +import {polyfillElementInternalsAria, setupHostAria} from '../../internal/aria/aria.js'; import {isActivationClick} from '../../internal/controller/events.js'; import {SingleSelectionController} from './single-selection-controller.js'; @@ -22,6 +23,10 @@ let maskId = 0; * A radio component. */ export class Radio extends LitElement { + static { + setupHostAria(Radio); + } + /** @nocollapse */ static readonly formAssociated = true; @@ -86,30 +91,19 @@ export class Radio extends LitElement { } private readonly selectionController = new SingleSelectionController(this); - private readonly internals = - (this as HTMLElement /* needed for closure */).attachInternals(); + private readonly internals = polyfillElementInternalsAria( + this, (this as HTMLElement /* needed for closure */).attachInternals()); constructor() { super(); this.addController(this.selectionController); if (!isServer) { + this.internals.role = 'radio'; this.addEventListener('click', this.handleClick.bind(this)); this.addEventListener('keydown', this.handleKeydown.bind(this)); } } - override connectedCallback() { - super.connectedCallback(); - // Firefox does not support ElementInternals aria yet, so we need to hydrate - // an attribute. - if (!('role' in this.internals)) { - this.setAttribute('role', 'radio'); - return; - } - - this.internals.role = 'radio'; - } - protected override render() { const classes = {checked: this.checked}; return html` @@ -140,13 +134,6 @@ export class Radio extends LitElement { } protected override updated() { - // Firefox does not support ElementInternals aria yet, so we need to hydrate - // an attribute. - if (!('ariaChecked' in this.internals)) { - this.setAttribute('aria-checked', String(this.checked)); - return; - } - this.internals.ariaChecked = String(this.checked); } diff --git a/tabs/internal/tabs.ts b/tabs/internal/tabs.ts index 70716fd45..46e159fd8 100644 --- a/tabs/internal/tabs.ts +++ b/tabs/internal/tabs.ts @@ -9,6 +9,8 @@ import '../../divider/divider.js'; import {html, isServer, LitElement, PropertyValues} from 'lit'; import {property, queryAssignedElements, state} from 'lit/decorators.js'; +import {polyfillElementInternalsAria, setupHostAria} from '../../internal/aria/aria.js'; + import {Tab} from './tab.js'; const NAVIGATION_KEYS = new Map([ @@ -41,6 +43,10 @@ const NAVIGATION_KEYS = new Map([ * */ export class Tabs extends LitElement { + static { + setupHostAria(Tabs, {focusable: false}); + } + /** * Index of the selected item. */ @@ -86,30 +92,19 @@ export class Tabs extends LitElement { return this.items.find((el: HTMLElement) => el.matches(':focus-within')); } - private readonly internals = - (this as HTMLElement /* needed for closure */).attachInternals(); + private readonly internals = polyfillElementInternalsAria( + this, (this as HTMLElement /* needed for closure */).attachInternals()); constructor() { super(); if (!isServer) { + this.internals.role = 'tablist'; this.addEventListener('keydown', this.handleKeydown); this.addEventListener('keyup', this.handleKeyup); this.addEventListener('focusout', this.handleFocusout); } } - override connectedCallback() { - super.connectedCallback(); - // Firefox does not support ElementInternals aria yet, so we need to hydrate - // an attribute. - if (!('role' in this.internals)) { - this.setAttribute('role', 'tablist'); - return; - } - - this.internals.role = 'tablist'; - } - // focus item on keydown and optionally select it private readonly handleKeydown = async (event: KeyboardEvent) => { const {key} = event;