chore: add polyfill for Firefox ElementInternals aria

PiperOrigin-RevId: 563194223
This commit is contained in:
Elizabeth Mitchell 2023-09-06 13:10:10 -07:00 committed by Copybara-Service
parent 01a99a5cc3
commit 08acc413f6
4 changed files with 417 additions and 36 deletions

View File

@ -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;
}

View File

@ -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`<slot></slot>`;
}
}
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`<slot></slot>`;
}
}
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();
});
}
});
});

View File

@ -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);
}

View File

@ -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;