diff --git a/packages/popover/src/vaadin-popover.d.ts b/packages/popover/src/vaadin-popover.d.ts index 5e2c1640b9..f51a5ce9d1 100644 --- a/packages/popover/src/vaadin-popover.d.ts +++ b/packages/popover/src/vaadin-popover.d.ts @@ -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. * @@ -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: diff --git a/packages/popover/src/vaadin-popover.js b/packages/popover/src/vaadin-popover.js index 0c7aaf78b4..408064c5cf 100644 --- a/packages/popover/src/vaadin-popover.js +++ b/packages/popover/src/vaadin-popover.js @@ -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'; @@ -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. * @@ -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: @@ -153,6 +182,11 @@ class Popover extends PopoverPositionMixin( value: false, sync: true, }, + + /** @private */ + __overlayId: { + type: String, + }, }; } @@ -160,11 +194,16 @@ class Popover extends PopoverPositionMixin( 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); @@ -181,6 +220,10 @@ class Popover extends PopoverPositionMixin( return html` { 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 () => { diff --git a/packages/popover/test/typings/popover.types.ts b/packages/popover/test/typings/popover.types.ts index 17b7313fa4..6b0f2d41b1 100644 --- a/packages/popover/test/typings/popover.types.ts +++ b/packages/popover/test/typings/popover.types.ts @@ -28,9 +28,12 @@ assertType(popover.target); assertType(popover.position); assertType(popover.renderer); assertType(popover.trigger); +assertType(popover.accessibleName); +assertType(popover.accessibleNameRef); assertType(popover.contentHeight); assertType(popover.contentWidth); assertType(popover.overlayClass); +assertType(popover.overlayRole); assertType(popover.opened); assertType(popover.modal); assertType(popover.withBackdrop);