mirror of
https://github.com/material-components/material-web.git
synced 2024-08-17 01:20:23 +03:00
chore: add polyfill for Firefox ElementInternals aria
PiperOrigin-RevId: 563194223
This commit is contained in:
parent
01a99a5cc3
commit
08acc413f6
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user