mirror of
https://github.com/material-components/material-web.git
synced 2026-01-09 07:21:09 +08:00
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:
parent
352607db71
commit
5df9410e60
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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()', () => {
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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'})
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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'})
|
||||
|
||||
@ -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 = '';
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
41
migrations/v2/README.md
Normal 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.
|
||||
35
migrations/v2/query-selector-aria.ts
Normal file
35
migrations/v2/query-selector-aria.ts
Normal 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}`;
|
||||
}
|
||||
87
migrations/v2/query-selector-aria_test.ts
Normal file
87
migrations/v2/query-selector-aria_test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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`.
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
"inlineSources": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": false,
|
||||
"target": "es2020",
|
||||
"target": "es2021",
|
||||
"types": ["lit", "jasmine"]
|
||||
},
|
||||
"exclude": [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user