fix!: aria-labels announcing twice with "group" on components

BREAKING CHANGE: `querySelector` for `[role]` and `[aria-*]` attributes may no longer work. See `@material/web/migrations/v2/README.md` and `@material/web/migrations/v2/query-selector-aria.ts`.

Browser/SR test results (go/mwc-double-aria-test-results)
  -  VoiceOver on Chrome
  -  VoiceOver on iOS Safari
  -  TalkBack on Chrome
  -  ChromeVox on Chrome
  -  NVDA on Chrome
  -  NVDA on Firefox
  -  JAWS on Chrome
  -  JAWS on Firefox
  (Optional)
  -  VoiceOver on Safari
  -  VoiceOver on Firefox

PiperOrigin-RevId: 648859827
This commit is contained in:
Elizabeth Mitchell 2024-07-02 15:21:31 -07:00 committed by Copybara-Service
parent 352607db71
commit 5df9410e60
28 changed files with 821 additions and 209 deletions

View File

@ -11,7 +11,7 @@ import {html, isServer, LitElement, nothing} from 'lit';
import {property, query, queryAssignedElements} from 'lit/decorators.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../internal/aria/delegate.js';
import {
FormSubmitter,
setupFormSubmitter,
@ -27,14 +27,13 @@ import {
} from '../../labs/behaviors/element-internals.js';
// Separate variable needed for closure.
const buttonBaseClass = mixinElementInternals(LitElement);
const buttonBaseClass = mixinDelegatesAria(mixinElementInternals(LitElement));
/**
* A button component.
*/
export abstract class Button extends buttonBaseClass implements FormSubmitter {
static {
requestUpdateOnAriaChange(Button);
setupFormSubmitter(Button);
}

View File

@ -12,7 +12,7 @@ import {property, query, state} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../internal/aria/delegate.js';
import {
dispatchActivationClick,
isActivationClick,
@ -32,8 +32,10 @@ import {
import {CheckboxValidator} from '../../labs/behaviors/validators/checkbox-validator.js';
// Separate variable needed for closure.
const checkboxBaseClass = mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
const checkboxBaseClass = mixinDelegatesAria(
mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
),
);
/**
@ -48,10 +50,6 @@ const checkboxBaseClass = mixinConstraintValidation(
* --bubbles --composed
*/
export class Checkbox extends checkboxBaseClass {
static {
requestUpdateOnAriaChange(Checkbox);
}
/** @nocollapse */
static override shadowRootOptions = {
...LitElement.shadowRootOptions,

View File

@ -11,18 +11,17 @@ import {html, LitElement, PropertyValues, TemplateResult} from 'lit';
import {property} from 'lit/decorators.js';
import {ClassInfo, classMap} from 'lit/directives/class-map.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../internal/aria/delegate.js';
// Separate variable needed for closure.
const chipBaseClass = mixinDelegatesAria(LitElement);
/**
* A chip component.
*
* @fires update-focus {Event} Dispatched when `disabled` is toggled. --bubbles
*/
export abstract class Chip extends LitElement {
static {
requestUpdateOnAriaChange(Chip);
}
export abstract class Chip extends chipBaseClass {
/** @nocollapse */
static override shadowRootOptions = {
...LitElement.shadowRootOptions,

View File

@ -11,7 +11,7 @@ import {property, query, state} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../internal/aria/delegate.js';
import {redispatchEvent} from '../../internal/events/redispatch-event.js';
import {
@ -21,6 +21,9 @@ import {
DialogAnimationArgs,
} from './animations.js';
// Separate variable needed for closure.
const dialogBaseClass = mixinDelegatesAria(LitElement);
/**
* A dialog component.
*
@ -31,11 +34,7 @@ import {
* @fires cancel {Event} Dispatched when the dialog has been canceled by clicking
* on the scrim or pressing Escape.
*/
export class Dialog extends LitElement {
static {
requestUpdateOnAriaChange(Dialog);
}
export class Dialog extends dialogBaseClass {
// We do not use `delegatesFocus: true` due to a Chromium bug with
// selecting text.
// See https://bugs.chromium.org/p/chromium/issues/detail?id=950357

View File

@ -13,19 +13,18 @@ import {property} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../internal/aria/delegate.js';
/**
* Sizes variants available to non-extended FABs.
*/
export type FabSize = 'medium' | 'small' | 'large';
// tslint:disable-next-line:enforce-comments-on-exported-symbols
export abstract class SharedFab extends LitElement {
static {
requestUpdateOnAriaChange(SharedFab);
}
// Separate variable needed for closure.
const fabBaseClass = mixinDelegatesAria(LitElement);
// tslint:disable-next-line:enforce-comments-on-exported-symbols
export abstract class SharedFab extends fabBaseClass {
/** @nocollapse */
static override shadowRootOptions: ShadowRootInit = {
mode: 'open' as const,

View File

@ -13,7 +13,7 @@ import {classMap} from 'lit/directives/class-map.js';
import {literal, html as staticHtml} from 'lit/static-html.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../internal/aria/delegate.js';
import {
FormSubmitter,
setupFormSubmitter,
@ -28,7 +28,9 @@ import {
type LinkTarget = '_blank' | '_parent' | '_self' | '_top';
// Separate variable needed for closure.
const iconButtonBaseClass = mixinElementInternals(LitElement);
const iconButtonBaseClass = mixinDelegatesAria(
mixinElementInternals(LitElement),
);
/**
* A button for rendering icons.
@ -39,7 +41,6 @@ const iconButtonBaseClass = mixinElementInternals(LitElement);
*/
export class IconButton extends iconButtonBaseClass implements FormSubmitter {
static {
requestUpdateOnAriaChange(IconButton);
setupFormSubmitter(IconButton);
}

View File

@ -73,7 +73,7 @@ export const ARIA_ATTRIBUTES = ARIA_PROPERTIES.map(ariaPropertyToAttribute);
* @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-') || attribute === 'role';
return ARIA_ATTRIBUTES.includes(attribute as ARIAAttribute);
}
/**

View File

@ -16,10 +16,10 @@ describe('aria', () => {
.toBeTrue();
});
it('should return true for aria idref attributes', () => {
it('should return false for aria idref attributes', () => {
expect(isAriaAttribute('aria-labelledby'))
.withContext('aria-labelledby input')
.toBeTrue();
.toBeFalse();
});
it('should return true for role', () => {
@ -29,6 +29,12 @@ describe('aria', () => {
it('should return false for non-aria attributes', () => {
expect(isAriaAttribute('label')).withContext('label input').toBeFalse();
});
it('should return false for custom aria-* attributes', () => {
expect(isAriaAttribute('aria-label-custom'))
.withContext('aria-label-custom input')
.toBeFalse();
});
});
describe('ariaPropertyToAttribute()', () => {

View File

@ -4,30 +4,36 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {ReactiveElement} from 'lit';
import {LitElement, ReactiveElement, isServer} from 'lit';
import {ARIA_PROPERTIES, ariaPropertyToAttribute} from './aria.js';
import {MixinBase, MixinReturn} from '../../labs/behaviors/mixin.js';
import {
ARIA_PROPERTIES,
ariaPropertyToAttribute,
isAriaAttribute,
} from './aria.js';
// Private symbols
const privateIgnoreAttributeChangesFor = Symbol(
'privateIgnoreAttributeChangesFor',
);
/**
* 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.
* Mixes in aria delegation for elements that delegate focus and aria to inner
* shadow root elements.
*
* This function will:
* - Call `requestUpdate()` when an aria attribute changes.
* - Add `role="presentation"` to the host.
* This mixin fixes invalid aria announcements with shadow roots, caused by
* duplicate aria attributes on both the host and the inner shadow root element.
*
* NOTE: The following features are not currently supported:
* - Delegating IDREF attributes (ex: `aria-labelledby`, `aria-controls`)
* - Delegating the `role` attribute
* Note: this mixin **does not yet support** ID reference attributes, such as
* `aria-labelledby` or `aria-controls`.
*
* @example
* class XButton extends LitElement {
* static {
* requestUpdateOnAriaChange(XButton);
* }
* ```ts
* class MyButton extends mixinDelegatesAria(LitElement) {
* static shadowRootOptions = {mode: 'open', delegatesFocus: true};
*
* protected override render() {
* render() {
* return html`
* <button aria-label=${this.ariaLabel || nothing}>
* <slot></slot>
@ -35,24 +41,166 @@ import {ARIA_PROPERTIES, ariaPropertyToAttribute} from './aria.js';
* `;
* }
* }
* ```
* ```html
* <my-button aria-label="Plus one">+1</my-button>
* ```
*
* Use `ARIAMixinStrict` for lit analyzer strict types, such as the "role"
* attribute.
*
* @example
* ```ts
* return html`
* <button role=${(this as ARIAMixinStrict).role || nothing}>
* <slot></slot>
* </button>
* `;
* ```
*
* In the future, updates to the Accessibility Object Model (AOM) will provide
* built-in aria delegation features that will replace this mixin.
*
* @param base The class to mix functionality into.
* @return The provided class with aria delegation mixed in.
*/
export function mixinDelegatesAria<T extends MixinBase<LitElement>>(
base: T,
): MixinReturn<T> {
if (isServer) {
// Don't shift attributes when running with lit-ssr. The SSR renderer
// implements a subset of DOM APIs, including the methods this mixin
// overrides, causing errors. We don't need to shift on the server anyway
// since elements will shift attributes immediately once they hydrate.
return base;
}
abstract class WithDelegatesAriaElement extends base {
[privateIgnoreAttributeChangesFor] = new Set();
override attributeChangedCallback(
name: string,
oldValue: string | null,
newValue: string | null,
) {
if (!isAriaAttribute(name)) {
super.attributeChangedCallback(name, oldValue, newValue);
return;
}
if (this[privateIgnoreAttributeChangesFor].has(name)) {
return;
}
// Don't trigger another `attributeChangedCallback` once we remove the
// aria attribute from the host. We check the explicit name of the
// attribute to ignore since `attributeChangedCallback` can be called
// multiple times out of an expected order when hydrating an element with
// multiple attributes.
this[privateIgnoreAttributeChangesFor].add(name);
this.removeAttribute(name);
this[privateIgnoreAttributeChangesFor].delete(name);
const dataProperty = ariaAttributeToDataProperty(name);
if (newValue === null) {
delete this.dataset[dataProperty];
} else {
this.dataset[dataProperty] = newValue;
}
this.requestUpdate(ariaAttributeToDataProperty(name), oldValue);
}
override getAttribute(name: string) {
if (isAriaAttribute(name)) {
return super.getAttribute(ariaAttributeToDataAttribute(name));
}
return super.getAttribute(name);
}
override removeAttribute(name: string) {
super.removeAttribute(name);
if (isAriaAttribute(name)) {
super.removeAttribute(ariaAttributeToDataAttribute(name));
// Since `aria-*` attributes are already removed`, we need to request
// an update because `attributeChangedCallback` will not be called.
this.requestUpdate();
}
}
}
setupDelegatesAriaProperties(
WithDelegatesAriaElement as unknown as typeof ReactiveElement,
);
return WithDelegatesAriaElement;
}
/**
* Overrides the constructor's native `ARIAMixin` properties to ensure that
* aria properties reflect the values that were shifted to a data attribute.
*
* @param ctor The `ReactiveElement` constructor to patch.
*/
export function requestUpdateOnAriaChange(ctor: typeof ReactiveElement) {
function setupDelegatesAriaProperties(ctor: typeof ReactiveElement) {
for (const ariaProperty of ARIA_PROPERTIES) {
// The casing between ariaProperty and the dataProperty may be different.
// ex: aria-haspopup -> ariaHasPopup
const ariaAttribute = ariaPropertyToAttribute(ariaProperty);
// ex: aria-haspopup -> data-aria-haspopup
const dataAttribute = ariaAttributeToDataAttribute(ariaAttribute);
// ex: aria-haspopup -> dataset.ariaHaspopup
const dataProperty = ariaAttributeToDataProperty(ariaAttribute);
// Call `ReactiveElement.createProperty()` so that the `aria-*` and `data-*`
// attributes are added to the `static observedAttributes` array. This
// triggers `attributeChangedCallback` for the delegates aria mixin to
// handle.
ctor.createProperty(ariaProperty, {
attribute: ariaPropertyToAttribute(ariaProperty),
reflect: true,
attribute: ariaAttribute,
noAccessor: true,
});
ctor.createProperty(Symbol(dataAttribute), {
attribute: dataAttribute,
noAccessor: true,
});
// Re-define the `ARIAMixin` properties to handle data attribute shifting.
// It is safe to use `Object.defineProperty` here because the properties
// are native and not renamed.
// tslint:disable-next-line:ban-unsafe-reflection
Object.defineProperty(ctor.prototype, ariaProperty, {
configurable: true,
enumerable: true,
get(this: ReactiveElement): string | null {
return this.dataset[dataProperty] ?? null;
},
set(this: ReactiveElement, value: string | null): void {
const prevValue = this.dataset[dataProperty] ?? null;
if (value === prevValue) {
return;
}
if (value === null) {
delete this.dataset[dataProperty];
} else {
this.dataset[dataProperty] = value;
}
this.requestUpdate(ariaProperty, prevValue);
},
});
}
ctor.addInitializer((element) => {
const controller = {
hostConnected() {
element.setAttribute('role', 'presentation');
},
};
element.addController(controller);
});
}
function ariaAttributeToDataAttribute(ariaAttribute: string) {
// aria-haspopup -> data-aria-haspopup
return `data-${ariaAttribute}`;
}
function ariaAttributeToDataProperty(ariaAttribute: string) {
// aria-haspopup -> dataset.ariaHaspopup
return ariaAttribute.replace(/-\w/, (dashLetter) =>
dashLetter[1].toUpperCase(),
);
}

View File

@ -6,100 +6,409 @@
// import 'jasmine'; (google3-only)
import {html, LitElement, nothing} from 'lit';
import {customElement, queryAsync} from 'lit/decorators.js';
import {html, LitElement, nothing, TemplateResult} from 'lit';
import {customElement, property, queryAsync} from 'lit/decorators.js';
import {Environment} from '../../testing/environment.js';
import {requestUpdateOnAriaChange} from './delegate.js';
import {ARIAMixinStrict} from './aria.js';
import {mixinDelegatesAria} from './delegate.js';
declare global {
interface HTMLElementTagNameMap {
'test-aria-delegate': AriaDelegateElement;
'test-delegates-aria': DelegatesAriaElement;
}
}
@customElement('test-aria-delegate')
class AriaDelegateElement extends LitElement {
static {
requestUpdateOnAriaChange(AriaDelegateElement);
}
// Separate variable needed for closure.
const delegatesAriaElementBaseClass = mixinDelegatesAria(LitElement);
@queryAsync('button') readonly button!: Promise<HTMLButtonElement | null>;
@customElement('test-delegates-aria')
class DelegatesAriaElement extends delegatesAriaElementBaseClass {
@queryAsync('button') readonly delegate!: Promise<HTMLElement | null>;
@property({attribute: 'lit-attribute'}) litAttribute = '';
protected override render() {
return html`<button aria-label=${this.ariaLabel || nothing}>Label</button>`;
return html`<button
role=${(this as ARIAMixinStrict).role || nothing}
aria-label=${this.ariaLabel || nothing}
aria-haspopup=${(this as ARIAMixinStrict).ariaHasPopup || nothing}
lit-attribute=${this.litAttribute}>
Label
</button>`;
}
}
describe('aria', () => {
describe('mixinDelegatesAria()', () => {
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>
`);
// `mixinDelegatesAria()` patches `element.getAttribute()`, which makes it
// unreliable when testing what the screen reader sees. This function returns
// the "real" attribute value as read from the element's `outerHTML`,
// bypassing any patched methods or properties.
function getOuterHTMLAttribute(
element: Element,
attribute: string,
): string | null {
const match = element.outerHTML.match(
new RegExp(`\\s${attribute}="([^"]*)"`),
);
return match ? match[1] : null;
}
const host = root.querySelector('test-aria-delegate');
async function setupTest(templateWithTestAriaDelegate: TemplateResult) {
const root = env.render(templateWithTestAriaDelegate);
const host = root.querySelector('test-delegates-aria');
if (!host) {
throw new Error('Could not query rendered <test-aria-delegate>');
throw new Error('Could not query rendered <test-delegates-aria>.');
}
await host.updateComplete;
const child = await host.button;
if (!child) {
throw new Error('Could not query rendered <button>');
const delegate = await host.delegate;
if (!delegate) {
throw new Error(
"Could not query <test-delegates-aria>'s rendered delegate element.",
);
}
return {host, child};
return {host, delegate};
}
describe('requestUpdateOnAriaChange()', () => {
it('should add role="presentation" to the host', async () => {
const {host} = await setupTest();
// We test two attributes: 'aria-label' and 'role'. We explicitly test 'role'
// to include test cases that are not prefixed with 'aria-'.
expect(host.getAttribute('role'))
.withContext('host role')
.toEqual('presentation');
});
describe('sets and does not repeat aria attributes when: ', () => {
it('rendering aria-label attribute', async () => {
// Arrange
// Act
const {host, delegate} = await setupTest(
html`<test-delegates-aria aria-label="foo"></test-delegates-aria>`,
);
it('should not change or remove host aria attributes', async () => {
const ariaLabel = 'Descriptive label';
const {host} = await setupTest({ariaLabel});
expect(host.getAttribute('aria-label'))
// Assert
expect(getOuterHTMLAttribute(host, 'aria-label'))
.withContext('host aria-label')
.toEqual(ariaLabel);
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'aria-label'))
.withContext('delegate aria-label')
.toBe('foo');
});
it('should delegate aria attributes to child element', async () => {
const ariaLabel = 'Descriptive label';
const {child} = await setupTest({ariaLabel});
it('rendering role attribute', async () => {
// Arrange
// Act
const {host, delegate} = await setupTest(
html`<test-delegates-aria role="link"></test-delegates-aria>`,
);
expect(child.getAttribute('aria-label'))
.withContext('child aria-label')
.toEqual(ariaLabel);
// Assert
expect(getOuterHTMLAttribute(host, 'role'))
.withContext('host role')
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'role'))
.withContext('delegate role')
.toBe('link');
});
it('should update delegated aria attributes when host attribute changes', async () => {
const {host, child} = await setupTest({ariaLabel: 'First aria label'});
// Test rendering multiple attributes to stress test the logic in
// attributeChangedCallback, which may be called out of order while shifting
// attributes.
it('rendering aria and non-aria attributes at the same time', async () => {
// Arrange
// Act
const {host, delegate} = await setupTest(
html`<test-delegates-aria
aria-label="foo"
lit-attribute="bar"></test-delegates-aria>`,
);
host.setAttribute('aria-label', 'Second aria label');
await env.waitForStability();
expect(child.getAttribute('aria-label'))
.withContext('child aria-label')
.toEqual('Second aria label');
// Assert
expect(getOuterHTMLAttribute(host, 'aria-label'))
.withContext('host aria-label')
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'aria-label'))
.withContext('delegate aria-label')
.toBe('foo');
expect(getOuterHTMLAttribute(delegate, 'lit-attribute'))
.withContext('delegate lit-attribute')
.toBe('bar');
});
it('should remove delegated aria attributes when host attribute is removed', async () => {
const {host, child} = await setupTest({ariaLabel: 'First aria label'});
it('rendering multiple aria attributes at the same time', async () => {
// Arrange
// Act
const {host, delegate} = await setupTest(
html`<test-delegates-aria
aria-label="foo"
aria-haspopup="true"></test-delegates-aria>`,
);
host.removeAttribute('aria-label');
await env.waitForStability();
expect(child.hasAttribute('aria-label'))
.withContext('child has aria-label')
.toBeFalse();
// Assert
expect(getOuterHTMLAttribute(host, 'aria-label'))
.withContext('host aria-label')
.toBeNull();
expect(getOuterHTMLAttribute(host, 'aria-haspopup'))
.withContext('host aria-haspopup')
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'aria-label'))
.withContext('delegate aria-label')
.toBe('foo');
expect(getOuterHTMLAttribute(delegate, 'aria-haspopup'))
.withContext('delegate aria-haspopup')
.toBe('true');
});
it("calling host.setAttribute('aria-label')", async () => {
// Arrange
const {host, delegate} = await setupTest(
html`<test-delegates-aria></test-delegates-aria>`,
);
// Act
host.setAttribute('aria-label', 'foo');
await host.updateComplete;
// Assert
expect(getOuterHTMLAttribute(host, 'aria-label'))
.withContext('host aria-label')
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'aria-label'))
.withContext('delegate aria-label')
.toBe('foo');
});
it("calling host.setAttribute('role')", async () => {
// Arrange
const {host, delegate} = await setupTest(
html`<test-delegates-aria></test-delegates-aria>`,
);
// Act
host.setAttribute('role', 'link');
await host.updateComplete;
// Assert
expect(getOuterHTMLAttribute(host, 'role'))
.withContext('host role')
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'role'))
.withContext('delegate role')
.toBe('link');
});
it('setting host.ariaLabel to a string', async () => {
// Arrange
const {host, delegate} = await setupTest(
html`<test-delegates-aria></test-delegates-aria>`,
);
// Act
host.ariaLabel = 'foo';
await host.updateComplete;
// Assert
expect(getOuterHTMLAttribute(host, 'aria-label'))
.withContext('host aria-label')
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'aria-label'))
.withContext('delegate aria-label')
.toBe('foo');
});
it('setting host.role to a string', async () => {
// Arrange
const {host, delegate} = await setupTest(
html`<test-delegates-aria></test-delegates-aria>`,
);
// Act
host.role = 'link';
await host.updateComplete;
// Assert
expect(getOuterHTMLAttribute(host, 'role'))
.withContext('host role')
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'role'))
.withContext('delegate role')
.toBe('link');
});
});
describe('returns the correct aria attribute value when: ', () => {
it('calling host.getAttribute("aria-label")', async () => {
// Arrange
const {host} = await setupTest(
html`<test-delegates-aria aria-label="foo"></test-delegates-aria>`,
);
// Act
const getAttributeResult = host.getAttribute('aria-label');
// Assert
expect(getAttributeResult)
.withContext('host.getAttribute() value')
.toEqual('foo');
});
it('calling host.getAttribute("role")', async () => {
// Arrange
const {host} = await setupTest(
html`<test-delegates-aria role="link"></test-delegates-aria>`,
);
// Act
const getAttributeResult = host.getAttribute('role');
// Assert
expect(getAttributeResult)
.withContext('host.getAttribute() value')
.toEqual('link');
});
it('getting host.ariaLabel', async () => {
// Arrange
const {host} = await setupTest(
html`<test-delegates-aria aria-label="foo"></test-delegates-aria>`,
);
// Act
const hostAriaLabel = host.ariaLabel;
// Assert
expect(hostAriaLabel).withContext('host.ariaLabel value').toEqual('foo');
});
it('getting host.role', async () => {
// Arrange
const {host} = await setupTest(
html`<test-delegates-aria role="link"></test-delegates-aria>`,
);
// Act
const hostRole = host.role;
// Assert
expect(hostRole).withContext('host.role value').toEqual('link');
});
});
describe('removes the delegated aria attribute when: ', () => {
it("calling host.removeAttribute('aria-label')", async () => {
// Arrange
const {host, delegate} = await setupTest(
html`<test-delegates-aria aria-label="foo"></test-delegates-aria>`,
);
// Act
host.removeAttribute('aria-label');
await host.updateComplete;
// Assert
expect(getOuterHTMLAttribute(host, 'aria-label'))
.withContext('host aria-label')
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'aria-label'))
.withContext('delegate aria-label')
.toBeNull();
});
it("calling host.removeAttribute('role')", async () => {
// Arrange
const {host, delegate} = await setupTest(
html`<test-delegates-aria role="link"></test-delegates-aria>`,
);
// Act
host.removeAttribute('role');
await host.updateComplete;
// Assert
expect(getOuterHTMLAttribute(host, 'role'))
.withContext('host role')
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'role'))
.withContext('delegate role')
.toBeNull();
});
it('setting host.ariaLabel to null', async () => {
// Arrange
const {host, delegate} = await setupTest(
html`<test-delegates-aria aria-label="foo"></test-delegates-aria>`,
);
// Act
host.ariaLabel = null;
await host.updateComplete;
// Assert
expect(getOuterHTMLAttribute(host, 'aria-label'))
.withContext('host aria-label')
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'aria-label'))
.withContext('delegate aria-label')
.toBeNull();
});
it('setting host.role to null', async () => {
// Arrange
const {host, delegate} = await setupTest(
html`<test-delegates-aria role="link"></test-delegates-aria>`,
);
// Act
host.role = null;
await host.updateComplete;
// Assert
expect(getOuterHTMLAttribute(host, 'role'))
.withContext('host role')
.toBeNull();
expect(getOuterHTMLAttribute(delegate, 'role'))
.withContext('delegate role')
.toBeNull();
});
});
it('does not change behavior of setting non-aria attributes', async () => {
// Arrange
const {host} = await setupTest(
html`<test-delegates-aria aria-label="foo"></test-delegates-aria>`,
);
// Act
host.setAttribute('foo', 'bar');
// Assert
const realFooAttribute = getOuterHTMLAttribute(host, 'foo');
expect(realFooAttribute)
.withContext('real "foo" attribute as read from outerHTML')
.toEqual('bar');
expect(host.getAttribute('foo'))
.withContext("host.getAttribute('foo')")
.toEqual(realFooAttribute);
});
it('does not change behavior of LitElement @property() attributes', async () => {
// Arrange
const {host, delegate} = await setupTest(
html`<test-delegates-aria aria-label="foo"></test-delegates-aria>`,
);
// Act
host.setAttribute('lit-attribute', 'bar');
await host.updateComplete;
// Assert
expect(host.litAttribute)
.withContext('host.litAttribute property updates from attribute change')
.toEqual('bar');
expect(getOuterHTMLAttribute(host, 'lit-attribute'))
.withContext('host has "lit-attribute" as read from outerHTML')
.toEqual('bar');
expect(getOuterHTMLAttribute(delegate, 'lit-attribute'))
.withContext('LitElement updated "lit-attribute" in the template')
.toEqual('bar');
});
});

View File

@ -10,24 +10,26 @@ import {html, LitElement, nothing, PropertyValues} from 'lit';
import {property, queryAssignedElements} from 'lit/decorators.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../../internal/aria/delegate.js';
import {isRtl} from '../../../internal/controller/is-rtl.js';
import {NavigationTab} from '../../navigationtab/internal/navigation-tab.js';
import {NavigationTabInteractionEvent} from './constants.js';
import {NavigationBarState} from './state.js';
// Separate variable needed for closure.
const navigationBarBaseClass = mixinDelegatesAria(LitElement);
/**
* b/265346501 - add docs
*
* @fires navigation-bar-activated {CustomEvent<tab: NavigationTab, activeIndex: number>}
* Dispatched whenever the `activeIndex` changes. --bubbles --composed
*/
export class NavigationBar extends LitElement implements NavigationBarState {
static {
requestUpdateOnAriaChange(NavigationBar);
}
export class NavigationBar
extends navigationBarBaseClass
implements NavigationBarState
{
@property({type: Number, attribute: 'active-index'}) activeIndex = 0;
@property({type: Boolean, attribute: 'hide-inactive-labels'})

View File

@ -9,7 +9,10 @@ import {property} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../../internal/aria/delegate.js';
// Separate variable needed for closure.
const navigationDrawerModalBaseClass = mixinDelegatesAria(LitElement);
/**
* b/265346501 - add docs
@ -17,11 +20,7 @@ import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
* @fires navigation-drawer-changed {CustomEvent<{opened: boolean}>}
* Dispatched whenever the drawer opens or closes --bubbles --composed
*/
export class NavigationDrawerModal extends LitElement {
static {
requestUpdateOnAriaChange(NavigationDrawerModal);
}
export class NavigationDrawerModal extends navigationDrawerModalBaseClass {
@property({type: Boolean}) opened = false;
@property() pivot: 'start' | 'end' = 'end';

View File

@ -11,7 +11,10 @@ import {property} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../../internal/aria/delegate.js';
// Separate variable needed for closure.
const navigationDrawerBaseClass = mixinDelegatesAria(LitElement);
/**
* b/265346501 - add docs
@ -19,11 +22,7 @@ import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
* @fires navigation-drawer-changed {CustomEvent<{opened: boolean}>}
* Dispatched whenever the drawer opens or closes --bubbles --composed
*/
export class NavigationDrawer extends LitElement {
static {
requestUpdateOnAriaChange(NavigationDrawer);
}
export class NavigationDrawer extends navigationDrawerBaseClass {
@property({type: Boolean}) opened = false;
@property() pivot: 'start' | 'end' = 'end';

View File

@ -13,10 +13,13 @@ import {property, query} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../../internal/aria/delegate.js';
import {NavigationTabState} from './state.js';
// Separate variable needed for closure.
const navigationTabBaseClass = mixinDelegatesAria(LitElement);
/**
* b/265346501 - add docs
*
@ -26,11 +29,10 @@ import {NavigationTabState} from './state.js';
* @fires navigation-tab-interaction {CustomEvent<{state: MdNavigationTab}>}
* Dispatched when the navigation tab has been clicked. --bubbles --composed
*/
export class NavigationTab extends LitElement implements NavigationTabState {
static {
requestUpdateOnAriaChange(NavigationTab);
}
export class NavigationTab
extends navigationTabBaseClass
implements NavigationTabState
{
@property({type: Boolean}) disabled = false;
@property({type: Boolean, reflect: true}) active = false;
@property({type: Boolean, attribute: 'hide-inactive-label'})

View File

@ -12,7 +12,10 @@ import {property, queryAssignedElements, state} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../../internal/aria/delegate.js';
// Separate variable needed for closure.
const segmentedButtonBaseClass = mixinDelegatesAria(LitElement);
/**
* SegmentedButton is a web component implementation of the Material Design
@ -23,11 +26,7 @@ import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
* @fires segmented-button-interaction {Event} Dispatched whenever a button is
* clicked. --bubbles --composed
*/
export class SegmentedButton extends LitElement {
static {
requestUpdateOnAriaChange(SegmentedButton);
}
export class SegmentedButton extends segmentedButtonBaseClass {
@property({type: Boolean}) disabled = false;
@property({type: Boolean}) selected = false;
@property() label = '';

View File

@ -8,9 +8,12 @@ import {html, LitElement, nothing} from 'lit';
import {property, queryAssignedElements} from 'lit/decorators.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../../internal/aria/delegate.js';
import {SegmentedButton} from '../../segmentedbutton/internal/segmented-button.js';
// Separate variable needed for closure.
const segmentedButtonSetBaseClass = mixinDelegatesAria(LitElement);
/**
* SegmentedButtonSet is the parent component for two or more
* `SegmentedButton` components. **Only** `SegmentedButton` components may be
@ -21,11 +24,7 @@ import {SegmentedButton} from '../../segmentedbutton/internal/segmented-button.j
* `setButtonSelected` or the `toggleSelection` methods as well as on user
* interaction. --bubbles --composed
*/
export class SegmentedButtonSet extends LitElement {
static {
requestUpdateOnAriaChange(SegmentedButtonSet);
}
export class SegmentedButtonSet extends segmentedButtonSetBaseClass {
@property({type: Boolean}) multiselect = false;
@queryAssignedElements({flatten: true}) buttons!: SegmentedButton[];

View File

@ -14,7 +14,7 @@ import {ClassInfo, classMap} from 'lit/directives/class-map.js';
import {literal, html as staticHtml, StaticValue} from 'lit/static-html.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../../internal/aria/delegate.js';
import {
createRequestActivationEvent,
ListItem,
@ -25,15 +25,14 @@ import {
*/
export type ListItemType = 'text' | 'button' | 'link';
// Separate variable needed for closure.
const listItemBaseClass = mixinDelegatesAria(LitElement);
/**
* @fires request-activation {Event} Requests the list to set `tabindex=0` on
* the item and focus it. --bubbles --composed
*/
export class ListItemEl extends LitElement implements ListItem {
static {
requestUpdateOnAriaChange(ListItemEl);
}
export class ListItemEl extends listItemBaseClass implements ListItem {
/** @nocollapse */
static override shadowRootOptions = {
...LitElement.shadowRootOptions,

View File

@ -19,22 +19,21 @@ import {ClassInfo, classMap} from 'lit/directives/class-map.js';
import {literal, html as staticHtml, StaticValue} from 'lit/static-html.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../../internal/aria/delegate.js';
import {
MenuItem,
MenuItemController,
type MenuItemType,
} from '../controllers/menuItemController.js';
// Separate variable needed for closure.
const menuItemBaseClass = mixinDelegatesAria(LitElement);
/**
* @fires close-menu {CustomEvent<{initiator: SelectOption, reason: Reason, itemPath: SelectOption[]}>}
* Closes the encapsulating menu on closable interaction. --bubbles --composed
*/
export class MenuItemEl extends LitElement implements MenuItem {
static {
requestUpdateOnAriaChange(MenuItemEl);
}
export class MenuItemEl extends menuItemBaseClass implements MenuItem {
/** @nocollapse */
static override shadowRootOptions = {
...LitElement.shadowRootOptions,

41
migrations/v2/README.md Normal file
View File

@ -0,0 +1,41 @@
# Breaking changes from v1 to v2
<!-- go/mwc-migrations-v2 -->
## ARIA attribute querySelector
**What changed?**
`role` and `aria-*` attributes now shift at runtime to `data-role` and
`data-aria-*` attributes. This fixes a screen reader bug around labels
announcing more than once.
**What broke?**
Using `querySelector` or `querySelectorAll` with `[role]` or `[aria-*]`
attribute selectors.
```html
<md-checkbox aria-label="Agree"></md-checkbox>
<script>
const agreeCheckbox = document.querySelector(
'md-checkbox[aria-label="Agree"]'
);
// `agreeCheckbox` is null!
</script>
```
**How to fix?**
Provide selector strings to `ariaSelector()` before querying.
```ts
import {ariaSelector} from '@material/web/migrations/v2/query-selector-aria';
const agreeCheckbox = document.querySelector(
ariaSelector('md-checkbox[aria-label="Agree"]')
);
```
Note: Element APIs, such as `element.getAttribute('role')` and
`element.ariaLabel` will continue to work as expected.

View File

@ -0,0 +1,35 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const HAS_ARIA_ATTRIBUTE_REGEX = /\[(aria-|role)/g;
/**
* Patches a CSS selector string to include `data-*` shifting `role` and
* `aria-*` attributes. Use this with `querySelector()` and `querySelectorAll()`
* for MWC elements.
*
* @example
* ```ts
* const agreeCheckbox = document.querySelector(
* ariaSelector('md-checkbox[aria-label="Agree"]')
* );
* ```
*
* @param selector A CSS selector string.
* @return A CSS selector string that includes `data-*` shifting aria
* attributes.
*/
export function ariaSelector(selector: string) {
if (!HAS_ARIA_ATTRIBUTE_REGEX.test(selector)) {
return selector;
}
const selectorWithDataShifted = selector.replaceAll(
HAS_ARIA_ATTRIBUTE_REGEX,
'[data-$1',
);
return `${selector},${selectorWithDataShifted}`;
}

View File

@ -0,0 +1,87 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '@material/web/checkbox/checkbox.js';
import {html} from 'lit';
import {Environment} from '../../testing/environment.js';
import {ariaSelector} from './query-selector-aria.js';
describe('query-selector-aria', () => {
const env = new Environment();
it('is needed when querying by aria attribute selectors fails', () => {
// Arrange
const root = env.render(
html`<md-checkbox aria-label="Agree"></md-checkbox>`,
);
// Act
const checkbox = root.querySelector('[aria-label="Agree"]');
// Assert
expect(checkbox)
.withContext('aria attribute query expected to fail without patches')
.toBeNull();
});
describe('ariaSelector()', () => {
it('returns the element with an aria attribute query', () => {
// Arrange
const root = env.render(
html`<md-checkbox aria-label="Agree"></md-checkbox>`,
);
// Act
const element = root.querySelector(ariaSelector('[aria-label="Agree"]'));
// Assert
expect(element).withContext('queried element').not.toBeNull();
});
it('returns the element with a role attribute query', () => {
// Arrange
const root = env.render(html`<md-checkbox role="radio"></md-checkbox>`);
// Act
const element = root.querySelector(ariaSelector('[role="radio"]'));
// Assert
expect(element).withContext('queried element').not.toBeNull();
});
it('returns the element when combined with other selectors', () => {
// Arrange
const root = env.render(html`
<md-checkbox
class="checkbox"
aria-label="Agree"
aria-haspopup="true"></md-checkbox>
`);
// Act
const element = root.querySelector(
ariaSelector('.checkbox[aria-label="Agree"][aria-haspopup="true"]'),
);
// Assert
expect(element).withContext('queried element').not.toBeNull();
});
it('returns expected elements when not using aria attribute selectors', () => {
// Arrange
const root = env.render(html`
<md-checkbox class="checkbox"></md-checkbox>
`);
// Act
const element = root.querySelector(ariaSelector('.checkbox'));
// Assert
expect(element).withContext('queried element').not.toBeNull();
});
});
});

View File

@ -9,16 +9,15 @@ import {property} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../internal/aria/delegate.js';
// Separate variable needed for closure.
const progressBaseClass = mixinDelegatesAria(LitElement);
/**
* A progress component.
*/
export abstract class Progress extends LitElement {
static {
requestUpdateOnAriaChange(Progress);
}
export abstract class Progress extends progressBaseClass {
/**
* Progress to display, a fraction between 0 and `max`.
*/

View File

@ -14,7 +14,7 @@ import {html as staticHtml, StaticValue} from 'lit/static-html.js';
import {Field} from '../../field/internal/field.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../internal/aria/delegate.js';
import {redispatchEvent} from '../../internal/events/redispatch-event.js';
import {
createValidator,
@ -50,9 +50,11 @@ import {getSelectedItems, SelectOptionRecord} from './shared.js';
const VALUE = Symbol('value');
// Separate variable needed for closure.
const selectBaseClass = mixinOnReportValidity(
mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
const selectBaseClass = mixinDelegatesAria(
mixinOnReportValidity(
mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
),
),
);
@ -71,10 +73,6 @@ const selectBaseClass = mixinOnReportValidity(
* and closed.
*/
export abstract class Select extends selectBaseClass {
static {
requestUpdateOnAriaChange(Select);
}
/** @nocollapse */
static override shadowRootOptions = {
...LitElement.shadowRootOptions,

View File

@ -18,7 +18,7 @@ import {
import {ClassInfo, classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../../internal/aria/delegate.js';
import {MenuItem} from '../../../menu/internal/controllers/menuItemController.js';
import {SelectOptionController} from './selectOptionController.js';
@ -49,6 +49,9 @@ interface SelectOptionSelf {
*/
export type SelectOption = SelectOptionSelf & MenuItem;
// Separate variable needed for closure.
const selectOptionBaseClass = mixinDelegatesAria(LitElement);
/**
* @fires close-menu {CustomEvent<{initiator: SelectOption, reason: Reason, itemPath: SelectOption[]}>}
* Closes the encapsulating menu on closable interaction. --bubbles --composed
@ -58,11 +61,10 @@ export type SelectOption = SelectOptionSelf & MenuItem;
* @fires request-deselection {Event} Requests the parent md-select to deselect
* this element when `selected` changed to `false`. --bubbles --composed
*/
export class SelectOptionEl extends LitElement implements SelectOption {
static {
requestUpdateOnAriaChange(SelectOptionEl);
}
export class SelectOptionEl
extends selectOptionBaseClass
implements SelectOption
{
/** @nocollapse */
static override shadowRootOptions = {
...LitElement.shadowRootOptions,

View File

@ -15,7 +15,7 @@ import {styleMap} from 'lit/directives/style-map.js';
import {when} from 'lit/directives/when.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../internal/aria/delegate.js';
import {
dispatchActivationClick,
isActivationClick,
@ -32,7 +32,9 @@ import {MdRipple} from '../../ripple/ripple.js';
// tslint:disable:no-implicit-dictionary-conversion
// Separate variable needed for closure.
const sliderBaseClass = mixinFormAssociated(mixinElementInternals(LitElement));
const sliderBaseClass = mixinDelegatesAria(
mixinFormAssociated(mixinElementInternals(LitElement)),
);
/**
* Slider component.
@ -46,10 +48,6 @@ const sliderBaseClass = mixinFormAssociated(mixinElementInternals(LitElement));
* --bubbles --composed
*/
export class Slider extends sliderBaseClass {
static {
requestUpdateOnAriaChange(Slider);
}
/** @nocollapse */
static override shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions,

View File

@ -11,7 +11,7 @@ import {html, isServer, LitElement, nothing, TemplateResult} from 'lit';
import {property, query} from 'lit/decorators.js';
import {ClassInfo, classMap} from 'lit/directives/class-map.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../internal/aria/delegate.js';
import {
afterDispatch,
setupDispatchHooks,
@ -35,8 +35,10 @@ import {
import {CheckboxValidator} from '../../labs/behaviors/validators/checkbox-validator.js';
// Separate variable needed for closure.
const switchBaseClass = mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
const switchBaseClass = mixinDelegatesAria(
mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
),
);
/**
@ -46,10 +48,6 @@ const switchBaseClass = mixinConstraintValidation(
* interaction (bubbles).
*/
export class Switch extends switchBaseClass {
static {
requestUpdateOnAriaChange(Switch);
}
/** @nocollapse */
static override shadowRootOptions: ShadowRootInit = {
mode: 'open',

View File

@ -13,7 +13,7 @@ import {StaticValue, html as staticHtml} from 'lit/static-html.js';
import {Field} from '../../field/internal/field.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {mixinDelegatesAria} from '../../internal/aria/delegate.js';
import {stringConverter} from '../../internal/controller/string-converter.js';
import {redispatchEvent} from '../../internal/events/redispatch-event.js';
import {
@ -72,9 +72,11 @@ export type InvalidTextFieldType =
| 'submit';
// Separate variable needed for closure.
const textFieldBaseClass = mixinOnReportValidity(
mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
const textFieldBaseClass = mixinDelegatesAria(
mixinOnReportValidity(
mixinConstraintValidation(
mixinFormAssociated(mixinElementInternals(LitElement)),
),
),
);
@ -92,10 +94,6 @@ const textFieldBaseClass = mixinOnReportValidity(
* --bubbles --composed
*/
export abstract class TextField extends textFieldBaseClass {
static {
requestUpdateOnAriaChange(TextField);
}
/** @nocollapse */
static override shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions,

View File

@ -23,7 +23,7 @@
"inlineSources": true,
"strict": true,
"strictNullChecks": false,
"target": "es2020",
"target": "es2021",
"types": ["lit", "jasmine"]
},
"exclude": [