Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ARIA attributes to popover overlay and target #7446

Merged
merged 5 commits into from
May 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading