mirror of
https://github.com/material-components/material-web.git
synced 2024-09-21 10:38:19 +03:00
feat(decorators): add @ariaProperty
PiperOrigin-RevId: 416884698
This commit is contained in:
parent
5835a43585
commit
e0482119d2
@ -8,12 +8,12 @@ import '@material/mwc-icon/mwc-icon';
|
||||
import '../../focus/focus-ring';
|
||||
import '../../ripple/mwc-ripple';
|
||||
|
||||
import {AriaHasPopup, ariaProperty} from '@material/mwc-base/aria-property';
|
||||
import {html, LitElement, TemplateResult} from 'lit';
|
||||
import {eventOptions, property, query, queryAssignedNodes, queryAsync, state} from 'lit/decorators';
|
||||
import {ClassInfo, classMap} from 'lit/directives/class-map';
|
||||
import {ifDefined} from 'lit/directives/if-defined';
|
||||
|
||||
import {ariaProperty} from '../../decorators/aria-property';
|
||||
import {pointerPress, shouldShowStrongFocus} from '../../focus/strong-focus';
|
||||
import {Ripple} from '../../ripple/mwc-ripple';
|
||||
import {RippleHandlers} from '../../ripple/ripple-handlers';
|
||||
@ -25,10 +25,12 @@ export abstract class Button extends LitElement implements ButtonState {
|
||||
static override shadowRootOptions:
|
||||
ShadowRootInit = {mode: 'open', delegatesFocus: true};
|
||||
|
||||
/** @soyPrefixAttribute */
|
||||
// TODO(b/210730484): replace with @soyParam annotation
|
||||
@property({type: String, attribute: 'data-aria-has-popup', noAccessor: true})
|
||||
@ariaProperty
|
||||
@property({type: String, attribute: 'aria-haspopup'})
|
||||
override ariaHasPopup!: AriaHasPopup;
|
||||
// TODO(b/210675600): change to shared type
|
||||
override ariaHasPopup!: 'false'|'true'|'menu'|'listbox'|'tree'|'grid'|
|
||||
'dialog';
|
||||
|
||||
@property({type: Boolean, reflect: true}) disabled = false;
|
||||
|
||||
@ -38,7 +40,10 @@ export abstract class Button extends LitElement implements ButtonState {
|
||||
|
||||
@property({type: String}) label = '';
|
||||
|
||||
@property({type: String}) override ariaLabel!: string;
|
||||
// TODO(b/210730484): replace with @soyParam annotation
|
||||
@property({type: String, attribute: 'data-aria-label', noAccessor: true})
|
||||
@ariaProperty
|
||||
override ariaLabel!: string;
|
||||
|
||||
@property({type: Boolean}) hasIcon = false;
|
||||
|
||||
@ -176,6 +181,7 @@ export abstract class Button extends LitElement implements ButtonState {
|
||||
this.hasIcon = !!this.iconElement && this.iconElement.length > 0;
|
||||
}
|
||||
|
||||
// TODO(b/210731759): remove once internal tooling delegates focus
|
||||
override focus() {
|
||||
const buttonElement = this.buttonElement;
|
||||
if (buttonElement) {
|
||||
@ -183,6 +189,7 @@ export abstract class Button extends LitElement implements ButtonState {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(b/210731759): remove once internal tooling delegates focus
|
||||
override blur() {
|
||||
const buttonElement = this.buttonElement;
|
||||
if (buttonElement) {
|
||||
|
@ -7,6 +7,5 @@
|
||||
export interface ButtonState {
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
trailingIcon: boolean;
|
||||
}
|
||||
|
106
components/decorators/aria-property.ts
Normal file
106
components/decorators/aria-property.ts
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2021 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {ReactiveElement} from 'lit';
|
||||
|
||||
/**
|
||||
* A property decorator that helps proxy an aria attribute to an internal node.
|
||||
*
|
||||
* This decorator is only intended for use with ARIAMixin properties,
|
||||
* such as `ariaLabel`, to help with screen readers.
|
||||
*
|
||||
* This decorator will remove the host `aria-*` attribute at runtime and add it
|
||||
* to a `data-aria-*` attribute to avoid screenreader conflicts between the
|
||||
* host and internal node.
|
||||
*
|
||||
* `@ariaProperty` decorated properties should sync with LitElement to the
|
||||
* `data-aria-*` attribute, not the native `aria-*` attribute.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* class MyElement extends LitElement {
|
||||
* \@ariaProperty
|
||||
* // TODO(b/210730484): replace with @soyParam annotation
|
||||
* \@property({ type: String, attribute: 'data-aria-label', noAccessor: true})
|
||||
* ariaLabel!: string;
|
||||
* }
|
||||
* ```
|
||||
* @category Decorator
|
||||
* @ExportDecoratedItems
|
||||
*/
|
||||
export function ariaProperty(
|
||||
prototype: ReactiveElement, property: keyof ARIAMixin) {
|
||||
// Replace the ARIAMixin property with data-* attribute syncing instead of
|
||||
// using the native aria-* attribute reflection. This preserves the attribute
|
||||
// for SSR and avoids screenreader conflicts after delegating the attribute
|
||||
// to a child node.
|
||||
Object.defineProperty(prototype, property, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get(this: ReactiveElement) {
|
||||
return this.dataset[property] ?? '';
|
||||
},
|
||||
set(this: ReactiveElement, value: unknown) {
|
||||
// Coerce non-string values to a string
|
||||
const strValue = String(value ?? '');
|
||||
const oldValue = this.dataset[property];
|
||||
if (strValue === oldValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (strValue) {
|
||||
this.dataset[property] = strValue;
|
||||
} else {
|
||||
delete this.dataset[property];
|
||||
}
|
||||
|
||||
this.requestUpdate(property, oldValue);
|
||||
}
|
||||
});
|
||||
|
||||
// Define an internal property that syncs from the `aria-*` attribute with lit
|
||||
// and delegates to the real ARIAMixin property, which renders an update.
|
||||
// This property will immediately remove the `aria-*` attribute, which doesn't
|
||||
// work well with SSR (which is why there's a separate synced property).
|
||||
const internalAriaProperty = Symbol(property);
|
||||
// "ariaLabel" -> "aria-label" / "ariaLabelledBy" -> "aria-labelledby"
|
||||
const ariaAttribute = property.replace('aria', 'aria-').toLowerCase();
|
||||
const constructor = (prototype.constructor as typeof ReactiveElement);
|
||||
let removingAttribute = false;
|
||||
Object.defineProperty(prototype, internalAriaProperty, {
|
||||
get(this: ReactiveElement) {
|
||||
// tslint is failing here, but the types are correct (ARIAMixin
|
||||
// properties do not obfuscate with closure)
|
||||
// tslint:disable-next-line:no-dict-access-on-struct-type
|
||||
return this[property];
|
||||
},
|
||||
set(this: ReactiveElement, value: string) {
|
||||
if (removingAttribute) {
|
||||
// Ignore this update, which is triggered below
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the ARIAMixin property, which will sync the `data-*` attribute
|
||||
// and trigger rendering if the value changed.
|
||||
// tslint is failing here, but the types are correct (ARIAMixin
|
||||
// properties do not obfuscate with closure)
|
||||
// tslint:disable-next-line:no-dict-access-on-struct-type
|
||||
this[property] = value;
|
||||
// Remove the `aria-*` attribute, which will call this setter again with
|
||||
// the incorrect value. Ignore these updates.
|
||||
removingAttribute = true;
|
||||
this.removeAttribute(ariaAttribute);
|
||||
removingAttribute = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Tell lit to observe the `aria-*` attribute and set the internal property,
|
||||
// which acts as a "aria-* attribute changed" observer.
|
||||
constructor.createProperty(internalAriaProperty, {
|
||||
attribute: ariaAttribute,
|
||||
noAccessor: true,
|
||||
});
|
||||
}
|
114
components/decorators/test/aria-property.test.ts
Normal file
114
components/decorators/test/aria-property.test.ts
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2021 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import 'jasmine';
|
||||
|
||||
import {html, LitElement} from 'lit';
|
||||
import {customElement, property, query} from 'lit/decorators';
|
||||
import {ifDefined} from 'lit/directives/if-defined';
|
||||
|
||||
import {Environment} from '../../testing/environment';
|
||||
import {ariaProperty} from '../aria-property';
|
||||
|
||||
describe('@ariaProperty', () => {
|
||||
const env = new Environment();
|
||||
|
||||
@customElement('my-element')
|
||||
class MyElement extends LitElement {
|
||||
// TODO(b/210730484): replace with @soyParam annotation
|
||||
@property({type: String, attribute: 'data-aria-label', noAccessor: true})
|
||||
@ariaProperty
|
||||
override ariaLabel!: string;
|
||||
|
||||
@query('.root') labelledElement!: HTMLElement;
|
||||
|
||||
override render() {
|
||||
return html`<div class="root"
|
||||
aria-label=${ifDefined(this.ariaLabel)}></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
let element: MyElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
const root = env.render(html`<my-element></my-element>`);
|
||||
await env.waitForStability();
|
||||
element = root.querySelector('my-element') as MyElement;
|
||||
});
|
||||
|
||||
it('should set `ariaX` from `data-*` attribute', () => {
|
||||
const value = 'Aria label';
|
||||
element.setAttribute('data-aria-label', value);
|
||||
expect(element.ariaLabel).toBe(value);
|
||||
});
|
||||
|
||||
it('should set `data-*` attribute from `ariaX`', () => {
|
||||
const value = 'Aria label';
|
||||
element.ariaLabel = value;
|
||||
expect(element.getAttribute('data-aria-label')).toBe(value);
|
||||
});
|
||||
|
||||
it('should remove `data-*` attribute when set to an empty string',
|
||||
async () => {
|
||||
element.ariaLabel = 'Aria label';
|
||||
element.ariaLabel = '';
|
||||
expect(element.hasAttribute('data-aria-label'))
|
||||
.withContext('should not have data-aria-label attribute')
|
||||
.toBeFalse();
|
||||
});
|
||||
|
||||
it('should set `ariaX` from `aria-*` attribute', () => {
|
||||
const value = 'Aria label';
|
||||
element.setAttribute('aria-label', value);
|
||||
expect(element.ariaLabel).toBe(value);
|
||||
});
|
||||
|
||||
it('should remove `aria-*` attribute when set and keep `ariaX` value', () => {
|
||||
const value = 'Aria label';
|
||||
element.setAttribute('aria-label', value);
|
||||
expect(element.hasAttribute('aria-label'))
|
||||
.withContext('should not have aria-label attribute')
|
||||
.toBeFalse();
|
||||
expect(element.ariaLabel).toBe(value);
|
||||
});
|
||||
|
||||
it('should delegate to rendered elements after updateComplete', async () => {
|
||||
const value = 'Aria label';
|
||||
element.ariaLabel = value;
|
||||
await element.updateComplete;
|
||||
expect(element.labelledElement.getAttribute('aria-label')).toBe(value);
|
||||
});
|
||||
|
||||
it('`ariaX` should coerce non-string values to strings', () => {
|
||||
(element as any).ariaLabel = null;
|
||||
expect(element.ariaLabel).withContext('null should coerce to ""').toBe('');
|
||||
|
||||
(element as any).ariaLabel = undefined;
|
||||
expect(element.ariaLabel)
|
||||
.withContext('undefined should coerce to ""')
|
||||
.toBe('');
|
||||
|
||||
(element as any).ariaLabel = 42;
|
||||
expect(element.ariaLabel)
|
||||
.withContext('number should coerce to string')
|
||||
.toBe('42');
|
||||
|
||||
(element as any).ariaLabel = true;
|
||||
expect(element.ariaLabel)
|
||||
.withContext('boolean should coerce to string')
|
||||
.toBe('true');
|
||||
});
|
||||
|
||||
it('should not request an update if the value stays the same', async () => {
|
||||
const value = 'Aria label';
|
||||
element.ariaLabel = value;
|
||||
await element.updateComplete;
|
||||
element.ariaLabel = value;
|
||||
expect(element.isUpdatePending)
|
||||
.withContext('there should not be an update pending')
|
||||
.toBeFalse();
|
||||
});
|
||||
});
|
@ -6,7 +6,7 @@
|
||||
|
||||
import '@material/mwc-ripple/mwc-ripple';
|
||||
|
||||
import {ariaProperty} from '@material/mwc-base/aria-property';
|
||||
import {ariaProperty as legacyAriaProperty} from '@material/mwc-base/aria-property';
|
||||
import {FormElement} from '@material/mwc-base/form-element';
|
||||
import {Ripple} from '@material/mwc-ripple/mwc-ripple';
|
||||
import {RippleHandlers} from '@material/mwc-ripple/ripple-handlers';
|
||||
@ -15,6 +15,8 @@ import {eventOptions, property, query, queryAsync, state} from 'lit/decorators';
|
||||
import {ClassInfo, classMap} from 'lit/directives/class-map';
|
||||
import {ifDefined} from 'lit/directives/if-defined';
|
||||
|
||||
import {ariaProperty} from '../../decorators/aria-property';
|
||||
|
||||
import {MDCSwitchFoundation} from './foundation';
|
||||
import {MDCSwitchAdapter, MDCSwitchState} from './state';
|
||||
|
||||
@ -26,13 +28,14 @@ export class Switch extends FormElement implements MDCSwitchState {
|
||||
@property({type: Boolean}) selected = false;
|
||||
|
||||
// Aria
|
||||
/** @soyPrefixAttribute */
|
||||
@ariaProperty
|
||||
@property({type: String, attribute: 'aria-label'})
|
||||
override ariaLabel = '';
|
||||
// TODO(b/210730484): replace with @soyParam annotation
|
||||
@property({type: String, attribute: 'data-aria-label', noAccessor: true})
|
||||
override ariaLabel!: string;
|
||||
|
||||
// TODO: Add support in @ariaProperty for idref aria attributes
|
||||
/** @soyPrefixAttribute */
|
||||
@ariaProperty
|
||||
@legacyAriaProperty
|
||||
@property({type: String, attribute: 'aria-labelledby'})
|
||||
ariaLabelledBy = '';
|
||||
|
||||
@ -79,7 +82,7 @@ export class Switch extends FormElement implements MDCSwitchState {
|
||||
class="mdc-switch ${classMap(this.getRenderClasses())}"
|
||||
role="switch"
|
||||
aria-checked="${this.selected}"
|
||||
aria-label="${ifDefined(this.ariaLabel || undefined)}"
|
||||
aria-label="${ifDefined(this.ariaLabel)}"
|
||||
aria-labelledby="${ifDefined(this.ariaLabelledBy || undefined)}"
|
||||
.disabled=${this.disabled}
|
||||
@click=${this.handleClick}
|
||||
|
@ -72,9 +72,12 @@ export class Environment {
|
||||
* Render a Lit template in the environment's root container.
|
||||
*
|
||||
* @param template a Lit `TemplateResult` to render.
|
||||
* @return The root container the template was rendered to.
|
||||
*/
|
||||
render(template: TemplateResult) {
|
||||
litRender(template, this.createNewRoot());
|
||||
const root = this.createNewRoot();
|
||||
litRender(template, root);
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
Reference in New Issue
Block a user