Skip to content

Commit

Permalink
feat(vwc-banner): add aria live polite (#1093)
Browse files Browse the repository at this point in the history
* add aria live and role

* story fix

* add aria-live and role

* render aria live

* ui test

* directive

* inport accessible-snackbar-label-directive

* ui test

* dependency

* ui test

* directive

* add dep

* aria property

* update versions

* yarn

* yarn

* reflect

* aria

* set role attribute

* add constants

* merge

* merge

* test

* test

* tests

* a11y test

* test

* test

Co-authored-by: yinonov <yinon@hotmail.com>
  • Loading branch information
rinaok and yinonov committed Oct 28, 2021
1 parent 1198141 commit 4f34e7b
Show file tree
Hide file tree
Showing 9 changed files with 165 additions and 12 deletions.
10 changes: 10 additions & 0 deletions common/foundation/src/constants.ts
Expand Up @@ -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',
}
2 changes: 1 addition & 1 deletion components/banner/package.json
Expand Up @@ -40,4 +40,4 @@
"lit-html": "^1.3.0",
"typescript": "^4.3.2"
}
}
}
82 changes: 82 additions & 0 deletions 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<this>) {
if (!open) {
return;
}

if (this.labelEl === null) {
const wrapperEl = document.createElement('div');
const messageTemplate =
html`<div class="banner--message" role=${role} aria-live=${ariaLive}></div>`;

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 &nbsp;
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`
<div class="banner--message" role=${role} aria-live=${ariaLive}>${message}</div>`;
}
}

export const accessibleBannerDirective = directive(AccessibleBannerDirective);
31 changes: 26 additions & 5 deletions components/banner/src/vwc-banner.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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'));
Expand All @@ -85,7 +105,8 @@ export class VWCBanner extends LitElement {
}, ANIMATION_DURATION);
}
}
renderDismissButton() {

renderDismissButton() :TemplateResult | unknown {
return this.dismissible
? html`<vwc-icon-button
class="dismiss-button"
Expand Down Expand Up @@ -120,7 +141,7 @@ export class VWCBanner extends LitElement {
<header class="header">
<span class="user-content">
${this.renderIcon(this.icon)}
<div role="alert" class="message">${this.message}</div>
${accessibleBannerDirective(this.message, this.open, this.role, this.ariaLive)}
<slot class="action-items" name="actionItems"></slot>
</span>
${this.renderDismissButton()}
Expand Down
22 changes: 21 additions & 1 deletion 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: {
Expand All @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions components/banner/stories/banner.stories.js
Expand Up @@ -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" });

Expand Down
24 changes: 22 additions & 2 deletions 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`<vwc-banner></vwc-banner>`);
const bannerEl = await fixture(html`<vwc-banner message="Hello" open></vwc-banner>`);
await expect(bannerEl).shadowDom.to.be.accessible();
});
it('should be with default role and aria-live values', async function () {
const bannerEl = await fixture(html`<vwc-banner message="Hello" open></vwc-banner>`);
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`<vwc-banner message="Hello" open role="alert" aria-live="assertive" dismissible></vwc-banner>`);
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`<vwc-banner message="Hello"></vwc-banner>`);
expect(bannerEl.shadowRoot.querySelector('.banner--message')).to.equal(null);
});
});
Binary file modified ui-tests/snapshots/vwc-banner.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion ui-tests/tests/vwc-banner/index.js
Expand Up @@ -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
}));
Expand Down

0 comments on commit 4f34e7b

Please sign in to comment.