@material/web
Version:
Material web components
239 lines • 7.42 kB
JavaScript
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { isServer } from 'lit';
/**
* Accessibility Object Model reflective aria properties.
*/
export const ARIA_PROPERTIES = [
'ariaAtomic',
'ariaAutoComplete',
'ariaBusy',
'ariaChecked',
'ariaColCount',
'ariaColIndex',
'ariaColSpan',
'ariaCurrent',
'ariaDisabled',
'ariaExpanded',
'ariaHasPopup',
'ariaHidden',
'ariaInvalid',
'ariaKeyShortcuts',
'ariaLabel',
'ariaLevel',
'ariaLive',
'ariaModal',
'ariaMultiLine',
'ariaMultiSelectable',
'ariaOrientation',
'ariaPlaceholder',
'ariaPosInSet',
'ariaPressed',
'ariaReadOnly',
'ariaRequired',
'ariaRoleDescription',
'ariaRowCount',
'ariaRowIndex',
'ariaRowSpan',
'ariaSelected',
'ariaSetSize',
'ariaSort',
'ariaValueMax',
'ariaValueMin',
'ariaValueNow',
'ariaValueText',
];
/**
* Accessibility Object Model aria attributes.
*/
export const ARIA_ATTRIBUTES = ARIA_PROPERTIES.map(ariaPropertyToAttribute);
/**
* Checks if an attribute is one of the AOM aria attributes.
*
* @example
* isAriaAttribute('aria-label'); // true
*
* @param attribute The attribute to check.
* @return True if the attribute is an aria attribute, or false if not.
*/
export function isAriaAttribute(attribute) {
return attribute.startsWith('aria-');
}
/**
* Converts an AOM aria property into its corresponding attribute.
*
* @example
* ariaPropertyToAttribute('ariaLabel'); // 'aria-label'
*
* @param property The aria property.
* @return The aria attribute.
*/
export function ariaPropertyToAttribute(property) {
return property
.replace('aria', 'aria-')
// IDREF attributes also include an "Element" or "Elements" suffix
.replace(/Elements?/g, '')
.toLowerCase();
}
/**
* 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, { focusable } = {}) {
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 });
}
/**
* 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, internals) {
if (checkIfElementInternalsSupportsAria(internals)) {
return internals;
}
if (!('role' in host)) {
throw new Error('Missing setupHostAria()');
}
let firstConnectedCallbacks = [];
let hasBeenConnected = false;
// Add support for Firefox, which has not yet implement ElementInternals aria
for (const ariaProperty of ARIA_PROPERTIES) {
let internalAriaValue = null;
Object.defineProperty(internals, ariaProperty, {
enumerable: true,
configurable: true,
get() {
return internalAriaValue;
},
set(value) {
const setValue = () => {
internalAriaValue = value;
if (!hasBeenConnected) {
firstConnectedCallbacks.push({ property: ariaProperty, callback: setValue });
return;
}
// Dynamic lookup rather than hardcoding all properties.
// tslint:disable-next-line:no-dict-access-on-struct-type
host[ariaProperty] = value;
};
setValue();
},
});
}
let internalRoleValue = null;
Object.defineProperty(internals, 'role', {
enumerable: true,
configurable: true,
get() {
return internalRoleValue;
},
set(value) {
const setRole = () => {
internalRoleValue = value;
if (!hasBeenConnected) {
firstConnectedCallbacks.push({
property: 'role',
callback: setRole,
});
return;
}
if (value === null) {
host.removeAttribute('role');
}
else {
host.setAttribute('role', value);
}
};
setRole();
},
});
host.addController({
hostConnected() {
if (hasBeenConnected) {
return;
}
hasBeenConnected = true;
const propertiesSetByUser = new Set();
// See which properties were set by the user on host before we apply
// internals values as attributes to host. Needs to be done in another
// for loop because the callbacks set these attributes on host.
for (const { property } of firstConnectedCallbacks) {
const wasSetByUser = host.getAttribute(ariaPropertyToAttribute(property)) !== null ||
// Dynamic lookup rather than hardcoding all properties.
// tslint:disable-next-line:no-dict-access-on-struct-type
host[property] !== undefined;
if (wasSetByUser) {
propertiesSetByUser.add(property);
}
}
for (const { property, callback } of firstConnectedCallbacks) {
// If the user has set the attribute or property, do not override the
// user's value
if (propertiesSetByUser.has(property)) {
continue;
}
callback();
}
// Remove strong callback references
firstConnectedCallbacks = [];
}
});
return internals;
}
// Separate function so that typescript doesn't complain about internals being
// "never".
function checkIfElementInternalsSupportsAria(internals) {
return 'role' in internals;
}
//# sourceMappingURL=aria.js.map