Skip to content

Commit

Permalink
feat(aria): add aria delegation
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 526474126
  • Loading branch information
asyncLiz authored and copybara-github committed Apr 23, 2023
1 parent 72b48da commit e0bbe38
Show file tree
Hide file tree
Showing 5 changed files with 290 additions and 17 deletions.
76 changes: 76 additions & 0 deletions aria/aria.ts
Original file line number Diff line number Diff line change
@@ -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<keyof ARIAMixin, 'role'>;

/**
* 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<ARIAProperty>;

/**
* 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<K extends ARIAProperty|'role'>(
property: K) {
return property
.replace('aria', 'aria-')
// IDREF attributes also include an "Element" or "Elements" suffix
.replace(/Elements?/g, '')
.toLowerCase() as ARIAPropertyToAttribute<K>;
}

// Converts an `ariaFoo` string type to an `aria-foo` string type.
type ARIAPropertyToAttribute<K extends string> =
K extends `aria${infer Suffix}Element${infer OptS}` ?
`aria-${Lowercase < Suffix >}` :
K extends `aria${infer Suffix}` ? `aria-${Lowercase < Suffix >}` : K;
44 changes: 44 additions & 0 deletions aria/aria_test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
58 changes: 58 additions & 0 deletions aria/delegate.ts
Original file line number Diff line number Diff line change
@@ -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`
* <button aria-label=${this.ariaLabel || nothing}>
* <slot></slot>
* </button>
* `;
* }
* }
*
* @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);
});
}
107 changes: 107 additions & 0 deletions aria/delegate_test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement|null>;

override render() {
return html`<button aria-label=${this.ariaLabel || nothing}>Label</button>`;
}
}

describe('aria', () => {
const env = new Environment();

async function setupTest({ariaLabel}: {ariaLabel?: string} = {}) {
const root = env.render(html`
<test-aria-delegate aria-label=${
ariaLabel || nothing}></test-aria-delegate>
`);

const host = root.querySelector('test-aria-delegate');
if (!host) {
throw new Error('Could not query rendered <test-aria-delegate>');
}

await host.updateComplete;
const child = await host.button;
if (!child) {
throw new Error('Could not query rendered <button>');
}

return {host, child};
}

describe('requestUpdateOnAriaChange()', () => {
it('should add role="presentation" to the host', async () => {
const {host} = await setupTest();

expect(host.getAttribute('role'))
.withContext('host role')
.toEqual('presentation');
});

it('should not change or remove host aria attributes', async () => {
const ariaLabel = 'Descriptive label';
const {host} = await setupTest({ariaLabel});

expect(host.getAttribute('aria-label'))
.withContext('host aria-label')
.toEqual(ariaLabel);
});

it('should delegate aria attributes to child element', async () => {
const ariaLabel = 'Descriptive label';
const {child} = await setupTest({ariaLabel});

expect(child.getAttribute('aria-label'))
.withContext('child aria-label')
.toEqual(ariaLabel);
});

it('should update delegated aria attributes when host attribute changes',
async () => {
const {host, child} = await setupTest({ariaLabel: 'First aria label'});

host.setAttribute('aria-label', 'Second aria label');
await env.waitForStability();
expect(child.getAttribute('aria-label'))
.withContext('child aria-label')
.toEqual('Second aria label');
});

it('should remove delegated aria attributes when host attribute is removed',
async () => {
const {host, child} = await setupTest({ariaLabel: 'First aria label'});

host.removeAttribute('aria-label');
await env.waitForStability();
expect(child.hasAttribute('aria-label'))
.withContext('child has aria-label')
.toBeFalse();
});
});
});
22 changes: 5 additions & 17 deletions button/lib/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/

// This is required for @ariaProperty
// tslint:disable:no-new-decorators

import '../../focus/focus-ring.js';
import '../../ripple/ripple.js';

Expand All @@ -16,34 +13,25 @@ import {ClassInfo, classMap} from 'lit/directives/class-map.js';
import {when} from 'lit/directives/when.js';
import {html as staticHtml, literal} from 'lit/static-html.js';

import {requestUpdateOnAriaChange} from '../../aria/delegate.js';
import {dispatchActivationClick, isActivationClick} from '../../controller/events.js';
import {ariaProperty} from '../../decorators/aria-property.js';
import {pointerPress, shouldShowStrongFocus} from '../../focus/strong-focus.js';
import {ripple} from '../../ripple/directive.js';
import {MdRipple} from '../../ripple/ripple.js';
import {ARIAExpanded, ARIAHasPopup} from '../../types/aria.js';

import {ButtonState} from './state.js';

/**
* A button component.
*/
export abstract class Button extends LitElement implements ButtonState {
static {
requestUpdateOnAriaChange(this);
}

static override shadowRootOptions:
ShadowRootInit = {mode: 'open', delegatesFocus: true};

@property({attribute: 'data-aria-expanded', noAccessor: true})
@ariaProperty
override ariaExpanded!: ARIAExpanded;

@property({attribute: 'data-aria-has-popup', noAccessor: true})
@ariaProperty
override ariaHasPopup!: ARIAHasPopup;

@property({attribute: 'data-aria-label', noAccessor: true})
@ariaProperty
override ariaLabel!: string;

/**
* Whether or not the button is disabled.
*/
Expand Down

0 comments on commit e0bbe38

Please sign in to comment.