-
Notifications
You must be signed in to change notification settings - Fork 904
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
5 changed files
with
290 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters