Skip to content

Commit

Permalink
feat: add ARIA attributes to popover overlay and target (#7446)
Browse files Browse the repository at this point in the history
  • Loading branch information
web-padawan authored May 28, 2024
1 parent c57bc19 commit bdabeea
Show file tree
Hide file tree
Showing 4 changed files with 199 additions and 0 deletions.
21 changes: 21 additions & 0 deletions packages/popover/src/vaadin-popover.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,20 @@ export type PopoverEventMap = HTMLElementEventMap & PopoverCustomEventMap;
declare class Popover extends PopoverPositionMixin(
PopoverTargetMixin(OverlayClassMixin(ThemePropertyMixin(ElementMixin(HTMLElement)))),
) {
/**
* String used to label the overlay to screen reader users.
*
* @attr {string} accessible-name
*/
accessibleName: string | null | undefined;

/**
* Id of the element used as label of the overlay to screen reader users.
*
* @attr {string} accessible-name-ref
*/
accessibleNameRef: string | null | undefined;

/**
* Height to be set on the overlay content.
*
Expand All @@ -57,6 +71,13 @@ declare class Popover extends PopoverPositionMixin(
*/
opened: boolean;

/**
* The `role` attribute value to be set on the overlay.
*
* @attr {string} overlay-role
*/
overlayRole: string;

/**
* Custom function for rendering the content of the overlay.
* Receives two arguments:
Expand Down
70 changes: 70 additions & 0 deletions packages/popover/src/vaadin-popover.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { defineCustomElement } from '@vaadin/component-base/src/define.js';
import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
import { OverlayClassMixin } from '@vaadin/component-base/src/overlay-class-mixin.js';
import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
import { generateUniqueId } from '@vaadin/component-base/src/unique-id-utils.js';
import { ThemePropertyMixin } from '@vaadin/vaadin-themable-mixin/vaadin-theme-property-mixin.js';
import { PopoverPositionMixin } from './vaadin-popover-position-mixin.js';
import { PopoverTargetMixin } from './vaadin-popover-target-mixin.js';
Expand Down Expand Up @@ -41,6 +42,24 @@ class Popover extends PopoverPositionMixin(

static get properties() {
return {
/**
* String used to label the overlay to screen reader users.
*
* @attr {string} accessible-name
*/
accessibleName: {
type: String,
},

/**
* Id of the element used as label of the overlay to screen reader users.
*
* @attr {string} accessible-name-ref
*/
accessibleNameRef: {
type: String,
},

/**
* Height to be set on the overlay content.
*
Expand Down Expand Up @@ -69,6 +88,16 @@ class Popover extends PopoverPositionMixin(
observer: '__openedChanged',
},

/**
* The `role` attribute value to be set on the overlay.
*
* @attr {string} overlay-role
*/
overlayRole: {
type: String,
value: 'dialog',
},

/**
* Custom function for rendering the content of the overlay.
* Receives two arguments:
Expand Down Expand Up @@ -153,18 +182,28 @@ class Popover extends PopoverPositionMixin(
value: false,
sync: true,
},

/** @private */
__overlayId: {
type: String,
},
};
}

static get observers() {
return [
'__updateContentHeight(contentHeight, _overlayElement)',
'__updateContentWidth(contentWidth, _overlayElement)',
'__openedOrTargetChanged(opened, target)',
'__overlayRoleOrTargetChanged(overlayRole, target)',
];
}

constructor() {
super();

this.__overlayId = `vaadin-popover-${generateUniqueId()}`;

this.__onGlobalClick = this.__onGlobalClick.bind(this);
this.__onGlobalKeyDown = this.__onGlobalKeyDown.bind(this);
this.__onTargetClick = this.__onTargetClick.bind(this);
Expand All @@ -181,6 +220,10 @@ class Popover extends PopoverPositionMixin(

return html`
<vaadin-popover-overlay
id="${this.__overlayId}"
role="${this.overlayRole}"
aria-label="${ifDefined(this.accessibleName)}"
aria-labelledby="${ifDefined(this.accessibleNameRef)}"
.renderer="${this.renderer}"
.owner="${this}"
theme="${ifDefined(this._theme)}"
Expand Down Expand Up @@ -282,6 +325,33 @@ class Popover extends PopoverPositionMixin(
}
}

/** @private */
__openedOrTargetChanged(opened, target) {
if (target) {
target.setAttribute('aria-expanded', opened ? 'true' : 'false');

if (opened) {
target.setAttribute('aria-controls', this.__overlayId);
} else {
target.removeAttribute('aria-controls');
}
}
}

/** @private */
__overlayRoleOrTargetChanged(overlayRole, target) {
if (this.__oldTarget) {
this.__oldTarget.removeAttribute('aria-haspopup');
}

if (target) {
const isDialog = overlayRole === 'dialog' || overlayRole === 'alertdialog';
target.setAttribute('aria-haspopup', isDialog ? 'dialog' : 'true');

this.__oldTarget = target;
}
}

/**
* Overlay's global outside click listener doesn't work when
* the overlay is modeless, so we use a separate listener.
Expand Down
105 changes: 105 additions & 0 deletions packages/popover/test/a11y.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,111 @@ describe('a11y', () => {
overlay = popover.shadowRoot.querySelector('vaadin-popover-overlay');
});

describe('ARIA attributes', () => {
it('should set role attribute on the overlay to dialog', () => {
expect(overlay.getAttribute('role')).to.equal('dialog');
});

it('should change role attribute on the overlay based on overlayRole', async () => {
popover.overlayRole = 'alertdialog';
await nextUpdate(popover);
expect(overlay.getAttribute('role')).to.equal('alertdialog');
});

it('should set aria-haspopup attribute on the target', () => {
expect(target.getAttribute('aria-haspopup')).to.equal('dialog');
});

it('should keep aria-haspopup attribute when overlayRole is set to alertdialog', async () => {
popover.overlayRole = 'alertdialog';
await nextUpdate(popover);
expect(target.getAttribute('aria-haspopup')).to.equal('dialog');
});

it('should update aria-haspopup attribute when overlayRole is set to different value', async () => {
popover.overlayRole = 'menu';
await nextUpdate(popover);
expect(target.getAttribute('aria-haspopup')).to.equal('true');
});

it('should remove aria-haspopup attribute when target is cleared', async () => {
popover.target = null;
await nextUpdate(popover);
expect(target.hasAttribute('aria-haspopup')).to.be.false;
});

it('should remove aria-controls attribute when target is cleared', async () => {
popover.target = null;
await nextUpdate(popover);
expect(target.hasAttribute('aria-haspopup')).to.be.false;
});

it('should set aria-expanded attribute on the target when closed', () => {
expect(target.getAttribute('aria-expanded')).to.equal('false');
});

it('should set aria-expanded attribute on the target when opened', async () => {
popover.opened = true;
await nextRender();
expect(target.getAttribute('aria-expanded')).to.equal('true');
});

it('should set aria-controls attribute on the target when opened', async () => {
popover.opened = true;
await nextRender();
expect(target.getAttribute('aria-controls')).to.equal(overlay.id);
});

it('should remove aria-controls attribute from the target when closed', async () => {
popover.opened = true;
await nextRender();

popover.opened = false;
await nextUpdate(popover);
expect(target.hasAttribute('aria-controls')).to.be.false;
});
});

describe('accessible name', () => {
it('should not set aria-label on the overlay by default', () => {
expect(overlay.hasAttribute('aria-label')).to.be.false;
});

it('should set aria-label on the overlay when accessibleName is set', async () => {
popover.accessibleName = 'Label text';
await nextUpdate(popover);
expect(overlay.getAttribute('aria-label')).to.equal('Label text');
});

it('should remove aria-label on the overlay when accessibleName is removed', async () => {
popover.accessibleName = 'Label text';
await nextUpdate(popover);

popover.accessibleName = null;
await nextUpdate(popover);
expect(overlay.hasAttribute('aria-label')).to.be.false;
});

it('should not set aria-labelledby on the overlay by default', () => {
expect(overlay.hasAttribute('aria-labelledby')).to.be.false;
});

it('should set aria-labelledby on the overlay when accessibleName is set', async () => {
popover.accessibleNameRef = 'custom-label';
await nextUpdate(popover);
expect(overlay.getAttribute('aria-labelledby')).to.equal('custom-label');
});

it('should remove aria-label on the overlay when accessibleName is removed', async () => {
popover.accessibleNameRef = 'custom-label';
await nextUpdate(popover);

popover.accessibleNameRef = null;
await nextUpdate(popover);
expect(overlay.hasAttribute('aria-labelledby')).to.be.false;
});
});

describe('focus restoration', () => {
describe('focus trigger', () => {
beforeEach(async () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/popover/test/typings/popover.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ assertType<HTMLElement | undefined>(popover.target);
assertType<PopoverPosition>(popover.position);
assertType<PopoverRenderer | null | undefined>(popover.renderer);
assertType<PopoverTrigger[] | null | undefined>(popover.trigger);
assertType<string | null | undefined>(popover.accessibleName);
assertType<string | null | undefined>(popover.accessibleNameRef);
assertType<string>(popover.contentHeight);
assertType<string>(popover.contentWidth);
assertType<string>(popover.overlayClass);
assertType<string>(popover.overlayRole);
assertType<boolean>(popover.opened);
assertType<boolean>(popover.modal);
assertType<boolean>(popover.withBackdrop);
Expand Down

0 comments on commit bdabeea

Please sign in to comment.