diff --git a/aria/aria.ts b/aria/aria.ts new file mode 100644 index 0000000000..8a3302d59f --- /dev/null +++ b/aria/aria.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Accessibility Object Model reflective aria property name types. + */ +export type ARIAProperty = Exclude; + +/** + * Accessibility Object Model reflective aria properties. + */ +export const ARIA_PROPERTIES: ARIAProperty[] = [ + 'ariaAtomic', 'ariaAutoComplete', 'ariaBusy', + 'ariaChecked', 'ariaColCount', 'ariaColIndex', + 'ariaColIndexText', 'ariaColSpan', 'ariaCurrent', + 'ariaDisabled', 'ariaExpanded', 'ariaHasPopup', + 'ariaHidden', 'ariaInvalid', 'ariaKeyShortcuts', + 'ariaLabel', 'ariaLevel', 'ariaLive', + 'ariaModal', 'ariaMultiLine', 'ariaMultiSelectable', + 'ariaOrientation', 'ariaPlaceholder', 'ariaPosInSet', + 'ariaPressed', 'ariaReadOnly', 'ariaRequired', + 'ariaRoleDescription', 'ariaRowCount', 'ariaRowIndex', + 'ariaRowIndexText', 'ariaRowSpan', 'ariaSelected', + 'ariaSetSize', 'ariaSort', 'ariaValueMax', + 'ariaValueMin', 'ariaValueNow', 'ariaValueText', +]; + +/** + * Accessibility Object Model aria attribute name types. + */ +export type ARIAAttribute = ARIAPropertyToAttribute; + +/** + * 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: string): attribute is ARIAAttribute { + 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: K) { + return property + .replace('aria', 'aria-') + // IDREF attributes also include an "Element" or "Elements" suffix + .replace(/Elements?/g, '') + .toLowerCase() as ARIAPropertyToAttribute; +} + +// Converts an `ariaFoo` string type to an `aria-foo` string type. +type ARIAPropertyToAttribute = + K extends `aria${infer Suffix}Element${infer OptS}` ? + `aria-${Lowercase < Suffix >}` : + K extends `aria${infer Suffix}` ? `aria-${Lowercase < Suffix >}` : K; diff --git a/aria/aria_test.ts b/aria/aria_test.ts new file mode 100644 index 0000000000..e7436308eb --- /dev/null +++ b/aria/aria_test.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {ARIAProperty, ariaPropertyToAttribute, isAriaAttribute} from './aria.js'; + +describe('aria', () => { + describe('isAriaAttribute()', () => { + it('should return true for aria value attributes', () => { + expect(isAriaAttribute('aria-label')) + .withContext('aria-label input') + .toBeTrue(); + }); + + it('should return true for aria idref attributes', () => { + expect(isAriaAttribute('aria-labelledby')) + .withContext('aria-labelledby input') + .toBeTrue(); + }); + + it('should return false for role', () => { + expect(isAriaAttribute('role')).withContext('role input').toBeFalse(); + }); + + it('should return false for non-aria attributes', () => { + expect(isAriaAttribute('label')).withContext('label input').toBeFalse(); + }); + }); + + describe('ariaPropertyToAttribute()', () => { + it('should convert aria value properties', () => { + expect(ariaPropertyToAttribute('ariaLabel')).toBe('aria-label'); + }); + + it('should convert aria idref properties', () => { + expect(ariaPropertyToAttribute('ariaLabelledByElements' as ARIAProperty)) + .toBe('aria-labelledby'); + }); + }); +}); diff --git a/aria/delegate.ts b/aria/delegate.ts new file mode 100644 index 0000000000..66b66dd56e --- /dev/null +++ b/aria/delegate.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ReactiveElement} from 'lit'; + +import {ARIA_PROPERTIES, ariaPropertyToAttribute} from './aria.js'; + +/** + * Sets up a `ReactiveElement` constructor to enable updates when delegating + * aria attributes. Elements may bind `this.aria*` properties to `aria-*` + * attributes in their render functions. + * + * This function will: + * - Call `requestUpdate()` when an aria attribute changes. + * - Add `role="presentation"` to the host. + * + * NOTE: The following features are not currently supported: + * - Delegating IDREF attributes (ex: `aria-labelledby`, `aria-controls`) + * - Delegating the `role` attribute + * + * @example + * class XButton extends LitElement { + * static { + * requestUpdateOnAriaChange(this); + * } + * + * override render() { + * return html` + * + * `; + * } + * } + * + * @param ctor The `ReactiveElement` constructor to patch. + */ +export function requestUpdateOnAriaChange(ctor: typeof ReactiveElement) { + for (const ariaProperty of ARIA_PROPERTIES) { + ctor.createProperty(ariaProperty, { + attribute: ariaPropertyToAttribute(ariaProperty), + reflect: true, + }); + } + + ctor.addInitializer(element => { + const controller = { + hostConnected() { + element.setAttribute('role', 'presentation'); + } + }; + + element.addController(controller); + }); +} diff --git a/aria/delegate_test.ts b/aria/delegate_test.ts new file mode 100644 index 0000000000..e36d8fcb10 --- /dev/null +++ b/aria/delegate_test.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import {html, LitElement, nothing} from 'lit'; +import {customElement, queryAsync} from 'lit/decorators.js'; + +import {Environment} from '../testing/environment.js'; + +import {requestUpdateOnAriaChange} from './delegate.js'; + +declare global { + interface HTMLElementTagNameMap { + 'test-aria-delegate': AriaDelegateElement; + } +} + +@customElement('test-aria-delegate') +class AriaDelegateElement extends LitElement { + static { + requestUpdateOnAriaChange(AriaDelegateElement); + } + + @queryAsync('button') readonly button!: Promise; + + override render() { + return html``; + } +} + +describe('aria', () => { + const env = new Environment(); + + async function setupTest({ariaLabel}: {ariaLabel?: string} = {}) { + const root = env.render(html` + + `); + + const host = root.querySelector('test-aria-delegate'); + if (!host) { + throw new Error('Could not query rendered '); + } + + await host.updateComplete; + const child = await host.button; + if (!child) { + throw new Error('Could not query rendered