UNPKG

@material/web

Version:
239 lines 7.42 kB
/** * @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