diff --git a/common/foundation/src/constants.ts b/common/foundation/src/constants.ts index bf6de41f3..6c5d1aea8 100644 --- a/common/foundation/src/constants.ts +++ b/common/foundation/src/constants.ts @@ -54,3 +54,13 @@ export enum Position { Center = 'CENTER', End = 'END' } + +export enum Role { + Status = 'status', + Alert = 'alert', +} + +export enum AriaLive { + Polite = 'polite', + Assertive = 'assertive', +} diff --git a/components/banner/package.json b/components/banner/package.json index 62ba7be3d..8b26f085c 100644 --- a/components/banner/package.json +++ b/components/banner/package.json @@ -40,4 +40,4 @@ "lit-html": "^1.3.0", "typescript": "^4.3.2" } -} +} \ No newline at end of file diff --git a/components/banner/src/accessible-banner-directive.ts b/components/banner/src/accessible-banner-directive.ts new file mode 100644 index 000000000..0f4b9cc3f --- /dev/null +++ b/components/banner/src/accessible-banner-directive.ts @@ -0,0 +1,82 @@ +import { html, TemplateResult } from 'lit-element'; +import { render } from 'lit-html'; +import { AsyncDirective } from 'lit-html/async-directive.js'; +import { + ChildPart, directive, DirectiveParameters, PartInfo, PartType +} from 'lit-html/directive.js'; + +class AccessibleBannerDirective extends AsyncDirective { + protected labelEl: HTMLElement|null = null; + protected timerId: number|null = null; + protected previousPart: ChildPart|null = null; + + constructor(partInfo: PartInfo) { + super(partInfo); + + if (partInfo.type !== PartType.CHILD) { + throw new Error('AccessibleBannerDirective only supports child parts.'); + } + } + + override update(part: ChildPart, [ + message, open, role, ariaLive + ]: DirectiveParameters) { + if (!open) { + return; + } + + if (this.labelEl === null) { + const wrapperEl = document.createElement('div'); + const messageTemplate = + html``; + + render(messageTemplate, wrapperEl); + + const labelEl = wrapperEl.firstElementChild! as HTMLElement; + labelEl.textContent = message; + + part.endNode?.parentNode!.insertBefore(labelEl, part.endNode); + this.labelEl = labelEl; + return labelEl; + } + + const messageEl = this.labelEl; + messageEl.setAttribute('role', ''); + messageEl.setAttribute('aria-live', 'off'); + messageEl.textContent = ''; + + const spaceSpan = document.createElement('span'); + spaceSpan.style.display = 'inline-block'; + spaceSpan.style.width = '0'; + spaceSpan.style.height = '1px'; + spaceSpan.textContent = '\u00A0'; // U+00A0 is   + messageEl.appendChild(spaceSpan); + messageEl.setAttribute('message-text', message); + + if (this.timerId !== null) { + clearTimeout(this.timerId); + } + + this.timerId = window.setTimeout(() => { + this.timerId = null; + messageEl.setAttribute('role', role); + messageEl.setAttribute('aria-live', ariaLive); + messageEl.removeAttribute('message-text'); + messageEl.textContent = message; + this.setValue(this.labelEl); + }, 1000); + + return messageEl; + } + + render(message: string, isOpen: boolean, role: string, ariaLive: string): TemplateResult { + if (!isOpen) { + return html``; + } + + return html` + `; + } +} + +export const accessibleBannerDirective = directive(AccessibleBannerDirective); diff --git a/components/banner/src/vwc-banner.ts b/components/banner/src/vwc-banner.ts index c0151b380..2765cb485 100644 --- a/components/banner/src/vwc-banner.ts +++ b/components/banner/src/vwc-banner.ts @@ -9,7 +9,20 @@ import type { PropertyValues } from 'lit-element'; import { classMap } from 'lit-html/directives/class-map.js'; import type { ClassInfo } from 'lit-html/directives/class-map.js'; import { nothing, TemplateResult } from 'lit-html'; -import { Connotation } from '@vonage/vvd-foundation/constants.js'; +import { Connotation, Role, AriaLive } from '@vonage/vvd-foundation/constants.js'; +import { ariaProperty} from '@material/mwc-base/aria-property.js'; +import { accessibleBannerDirective } from './accessible-banner-directive.js'; + +/** + * A value for the `role` ARIA attribute. + */ +type BannerRole = Role.Status | Role.Alert; + +/** + * A value for the `aria-live` ARIA attribute. + */ +type BannerAriaLive = AriaLive.Polite | AriaLive.Assertive; + const ANIMATION_DURATION = 100; const KEY_ESCAPE = 'Escape'; @@ -65,18 +78,25 @@ export class VWCBanner extends LitElement { @property({ type: Boolean, reflect: true }) open = false; + @property({type: String, reflect: true, attribute: 'role'}) + role: BannerRole = Role.Status; + + @ariaProperty + @property({type: String, reflect: true, attribute: 'aria-live'}) + ariaLive: BannerAriaLive = AriaLive.Polite; + private clickCloseHandler() { this.open = false; } #transitionTimer?: number; - protected override firstUpdated() { + protected override firstUpdated() :void { // refactor to query decorator (this.shadowRoot?.querySelector('.banner') as HTMLElement).style.setProperty('--transition-delay', `${ANIMATION_DURATION}ms`); } - override updated(changedProperties: PropertyValues) { + override updated(changedProperties:PropertyValues) :void { if (changedProperties.has('open')) { clearTimeout(this.#transitionTimer); this.dispatchEvent(createCustomEvent(!this.open ? 'closing' : 'opening')); @@ -85,7 +105,8 @@ export class VWCBanner extends LitElement { }, ANIMATION_DURATION); } } - renderDismissButton() { + + renderDismissButton() :TemplateResult | unknown { return this.dismissible ? html` ${this.renderIcon(this.icon)} - + ${accessibleBannerDirective(this.message, this.open, this.role, this.ariaLive)} ${this.renderDismissButton()} diff --git a/components/banner/stories/arg-types.js b/components/banner/stories/arg-types.js index b10c23ac7..545e0ae7d 100644 --- a/components/banner/stories/arg-types.js +++ b/components/banner/stories/arg-types.js @@ -1,4 +1,4 @@ -import { Connotation } from '@vonage/vvd-foundation/constants'; +import { Connotation, Role, AriaLive } from '@vonage/vvd-foundation/constants'; export const argTypes = { icon: { @@ -11,6 +11,26 @@ export const argTypes = { type: 'boolean', } }, + role: { + control: { + type: 'select', + defaultValue: Role.Status, + options: [ + Role.Status, + Role.Alert, + ] + } + }, + ariaLive: { + control: { + type: 'select', + defaultValue: AriaLive.Polite, + options: [ + AriaLive.Polite, + AriaLive.Assertive, + ] + } + }, connotation: { control: { type: 'select', diff --git a/components/banner/stories/banner.stories.js b/components/banner/stories/banner.stories.js index 9d738e21a..3631c8556 100644 --- a/components/banner/stories/banner.stories.js +++ b/components/banner/stories/banner.stories.js @@ -105,9 +105,9 @@ const basicStory = function (text, { const extendStory = (text, args) => Object.assign(basicStory.bind(null, text), { args }); -export const Info = extendStory(`I'm here to give you advice (like, use the knobs on the right for options`, { connotation: "info" }); +export const Info = extendStory(`I'm here to give you advice (like, use the knobs on the right for options)`, { connotation: "info" }); -export const Announcement = extendStory(`I'm here to give you advice (like, use the knobs on the right for options`, { connotation: "announcement" }); +export const Announcement = extendStory(`I'm here to give you advice (like, use the knobs on the right for options)`, { connotation: "announcement" }); export const Success = extendStory(`I'm here to give you good news (Thanks for giving us money!)`, { connotation: "success" }); diff --git a/components/banner/test/banner.a11y.test.js b/components/banner/test/banner.a11y.test.js index 4145d6ad3..9e24e9cb6 100644 --- a/components/banner/test/banner.a11y.test.js +++ b/components/banner/test/banner.a11y.test.js @@ -1,10 +1,30 @@ import 'chai-a11y-axe'; import { html } from 'lit-html'; -import { fixture } from '@open-wc/testing-helpers'; +import { fixture, aTimeout } from '@open-wc/testing-helpers'; describe('banner a11y', function () { + const TRANSITION_TIME = 200; it('should adhere to accessibility guidelines', async function () { - const bannerEl = await fixture(html``); + const bannerEl = await fixture(html``); await expect(bannerEl).shadowDom.to.be.accessible(); }); + it('should be with default role and aria-live values', async function () { + const bannerEl = await fixture(html``); + expect(bannerEl.shadowRoot.querySelector('.banner--message')).to.have.attribute('role', 'status'); + expect(bannerEl.shadowRoot.querySelector('.banner--message')).to.have.attribute('aria-live', 'polite'); + }); + it('should be with reflected role and aria-live values', async function () { + const bannerEl = await fixture(html``); + expect(bannerEl.shadowRoot.querySelector('.banner--message')).to.have.attribute('role', 'alert'); + expect(bannerEl.shadowRoot.querySelector('.banner--message')).to.have.attribute('aria-live', 'assertive'); + bannerEl.shadowRoot.querySelector('vwc-icon-button')?.click(); + await aTimeout(TRANSITION_TIME * 1.1); + bannerEl.setAttribute('open', 'true'); + expect(bannerEl.shadowRoot.querySelector('.banner--message')).to.equal(null); + expect(bannerEl.shadowRoot.querySelector('.banner--message')).to.equal(null); + }); + it('should be without role and aria-live values when closed', async function () { + const bannerEl = await fixture(html``); + expect(bannerEl.shadowRoot.querySelector('.banner--message')).to.equal(null); + }); }); diff --git a/ui-tests/snapshots/vwc-banner.png b/ui-tests/snapshots/vwc-banner.png index 7d5e11160..cbba59d01 100644 Binary files a/ui-tests/snapshots/vwc-banner.png and b/ui-tests/snapshots/vwc-banner.png differ diff --git a/ui-tests/tests/vwc-banner/index.js b/ui-tests/tests/vwc-banner/index.js index eab926f84..ba986dc32 100644 --- a/ui-tests/tests/vwc-banner/index.js +++ b/ui-tests/tests/vwc-banner/index.js @@ -6,7 +6,7 @@ import { import { storiesToElement } from '../../utils/storiesToElement'; export async function createElementVariations(wrapper) { - wrapper.style.width = '600px'; + wrapper.style.width = '800px'; wrapper.appendChild(storiesToElement({ Info, Announcement, Success, Warning, Alert }));