Skip to content

Commit

Permalink
feat(tab): accessibility improvements (#458)
Browse files Browse the repository at this point in the history
Co-authored-by: Murat Çorlu <[email protected]>
  • Loading branch information
pratikgaloria and muratcorlu authored Mar 16, 2023
1 parent edee744 commit 288192a
Show file tree
Hide file tree
Showing 9 changed files with 179 additions and 26 deletions.
90 changes: 86 additions & 4 deletions src/components/tab-group/bl-tab-group.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fixture, html, fixtureCleanup, expect, nextFrame } from '@open-wc/testing';
import { fixture, html, fixtureCleanup, expect, nextFrame, elementUpdated } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
import BlTabGroup from './bl-tab-group';

const createTab = () => {
Expand Down Expand Up @@ -58,7 +59,7 @@ describe('bl-tab-group', function () {
const selectedPanel = el.panels.find(p => p.tab === 'test-2');

expect(el.selectedTabName).to.be.equal('test-2');
expect(selectedPanel?.visible).to.be.true;
expect(selectedPanel?.hidden).to.be.false;
});

it('should handle bl-tab-selected event', async function () {
Expand All @@ -73,8 +74,8 @@ describe('bl-tab-group', function () {
expect(el.selectedTabName).to.be.equal('test-1');
expect(el.tabs[0].selected).to.be.true;
expect(el.tabs[1].selected).to.be.false;
expect(el.panels.find(p => p.tab === el.tabs[0].name)?.visible).to.be.true;
expect(el.panels.find(p => p.tab === el.tabs[1].name)?.visible).to.be.false;
expect(el.panels.find(p => p.tab === el.tabs[0].name)?.hidden).to.be.false;
expect(el.panels.find(p => p.tab === el.tabs[1].name)?.hidden).to.be.true;
});
});

Expand Down Expand Up @@ -187,3 +188,84 @@ describe('should selected tab functionality works when add or remove tabs ', fun
expect(el.tabs[1].disabled).to.be.true;
});
});

describe('accessibility', () => {
it('should change the tab when the right arrow key followed by enter key is used', async() => {
const el = await fixture<BlTabGroup>(html` <bl-tab-group>
<bl-tab name="test-1" slot="tabs">Test 1 Tab</bl-tab>
<bl-tab name="test-2" slot="tabs">Test 2 Tab</bl-tab>
<bl-tab name="test-3" slot="tabs" selected>Test 3 Tab</bl-tab>
<bl-tab-panel tab="test-1"></bl-tab-panel>
<bl-tab-panel tab="test-2"></bl-tab-panel>
<bl-tab-panel tab="test-3"></bl-tab-panel>
</bl-tab-group>`);

await elementUpdated(el);

await sendKeys({
press: 'Tab',
});
await sendKeys({
press: 'ArrowRight'
});
await sendKeys({
press: 'Enter'
});

expect(el.tabs[0].selected).to.be.true;
expect(el.tabs[2].selected).to.be.false;
});

it('should change the tab when the left arrow key followed by enter key is used', async() => {
const el = await fixture<BlTabGroup>(html` <bl-tab-group>
<bl-tab name="test-1" slot="tabs" selected>Test 1 Tab</bl-tab>
<bl-tab name="test-2" slot="tabs">Test 2 Tab</bl-tab>
<bl-tab name="test-3" slot="tabs">Test 3 Tab</bl-tab>
<bl-tab-panel tab="test-1"></bl-tab-panel>
<bl-tab-panel tab="test-2"></bl-tab-panel>
<bl-tab-panel tab="test-3"></bl-tab-panel>
</bl-tab-group>`);

await elementUpdated(el);

await sendKeys({
press: 'Tab',
});
await sendKeys({
press: 'ArrowLeft'
});
await sendKeys({
press: 'Enter'
});

expect(el.tabs[0].selected).to.be.false;
expect(el.tabs[2].selected).to.be.true;
});

it('should skip the disabled tabs when the arrow keys are used', async() => {
const el = await fixture<BlTabGroup>(html` <bl-tab-group>
<bl-tab name="test-1" slot="tabs" selected>Test 1 Tab</bl-tab>
<bl-tab name="test-2" slot="tabs" disabled>Test 2 Tab</bl-tab>
<bl-tab name="test-3" slot="tabs">Test 3 Tab</bl-tab>
<bl-tab-panel tab="test-1"></bl-tab-panel>
<bl-tab-panel tab="test-2"></bl-tab-panel>
<bl-tab-panel tab="test-3"></bl-tab-panel>
</bl-tab-group>`);

await elementUpdated(el);

await sendKeys({
press: 'Tab',
});
await sendKeys({
press: 'ArrowRight'
});
await sendKeys({
press: 'Enter'
});

expect(el.tabs[0].selected).to.be.false;
expect(el.tabs[1].selected).to.be.false;
expect(el.tabs[2].selected).to.be.true;
});
});
34 changes: 30 additions & 4 deletions src/components/tab-group/bl-tab-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default class BlTabGroup extends LitElement {

private _connectedTabs: BlTab[] = [];
private _connectedPanels: BlTabPanel[] = [];
private _tabFocus = 0;

get tabs() {
return this._connectedTabs;
Expand All @@ -35,9 +36,10 @@ export default class BlTabGroup extends LitElement {
const isFirstAndNotDisabled =
this._connectedTabs.filter(t => !t.disabled).length === 0 && !tab.disabled;
this._connectedTabs.push(tab);

if ((!tab.disabled && tab.selected) || isFirstAndNotDisabled) {
this.selectedTabName = tab.name;
this._tabFocus = this._connectedTabs.length - 1;
}
}

Expand All @@ -57,7 +59,8 @@ export default class BlTabGroup extends LitElement {
* @param panel BlTabPanel reference to be registered
*/
registerTabPanel(panel: BlTabPanel) {
panel.visible = panel.tab === this.selectedTabName;
panel.hidden = panel.tab !== this.selectedTabName;
panel.tabIndex = 0;
this._connectedPanels.push(panel);
}

Expand All @@ -81,17 +84,40 @@ export default class BlTabGroup extends LitElement {
t.selected = name === t.name;
});
this._connectedPanels.forEach(p => {
p.visible = p.tab === this._selectedTabName;
p.hidden = p.tab !== this._selectedTabName;
});
}

private _handleTabSelected(e: CustomEvent) {
this.selectedTabName = e.detail;
this._tabFocus = this._connectedTabs.findIndex(t => t.name === e.detail);
}

private _handleTabListKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
if (e.key === "ArrowRight") {
do {
this._tabFocus++;
if (this._tabFocus >= this._connectedTabs.length) {
this._tabFocus = 0;
}
} while (this._connectedTabs[this._tabFocus].disabled);
} else if (e.key === "ArrowLeft") {
do {
this._tabFocus--;
if (this._tabFocus < 0) {
this._tabFocus = this._connectedTabs.length - 1;
}
} while (this._connectedTabs[this._tabFocus].disabled);
}

this._connectedTabs[this._tabFocus].focus();
}
}

render(): TemplateResult {
return html` <div class="container" @bl-tab-selected="${this._handleTabSelected}">
<div role="tablist" class="tabs-list">
<div role="tablist" @keydown=${this._handleTabListKeyDown} class="tabs-list">
<div class="tabs">
<slot name="tabs"></slot>
</div>
Expand Down
11 changes: 5 additions & 6 deletions src/components/tab-group/tab-panel/bl-tab-panel.css
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
:host {
display: none;
div {
padding: var(--bl-size-xl);
background-color: var(--bl-color-primary-background);
border-radius: 0 0 var(--bl-border-radius-m) var(--bl-border-radius-m);
}

:host([visible]) {
display: block;
padding: var(--bl-size-xl);
background-color: var(--bl-color-primary-background);
div[hidden] {
display: none;
}
2 changes: 1 addition & 1 deletion src/components/tab-group/tab-panel/bl-tab-panel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe('bl-tab-panel', function () {
it('should render with default values', async function () {
const el = await fixture<BlTabPanel>(html` <bl-tab-panel tab="test-panel"></bl-tab-panel>`);
const expected = `
<slot></slot>
<div hidden><slot></slot></div>
`;
expect(el).to.be.shadowDom.equal(expected);
});
Expand Down
16 changes: 8 additions & 8 deletions src/components/tab-group/tab-panel/bl-tab-panel.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CSSResultGroup, html, LitElement, TemplateResult } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { customElement, property, state } from 'lit/decorators.js';
import styles from './bl-tab-panel.css';
import type BlTabGroup from '../bl-tab-group';

Expand Down Expand Up @@ -31,19 +31,19 @@ export default class BlTabPanel extends LitElement {
}

/**
* Name of the linked tab.
* This attribute set by `tab-group` to make panel visible or hidden.
*/
@property({ type: String, reflect: true })
tab: string;
@state()
hidden = true;

/**
* This attribute set by `tab-group` to make panel visible or hidden.
* Name of the linked tab.
*/
@property({ type: Boolean, reflect: true })
visible = false;
@property({ type: String, reflect: true })
tab: string;

render(): TemplateResult {
return html`<slot></slot>`;
return html`<div ?hidden=${this.hidden}><slot></slot></div>`;
}
}

Expand Down
11 changes: 11 additions & 0 deletions src/components/tab-group/tab/bl-tab.css
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
width: max-content;
height: var(--tab-height);
padding: 0 var(--tab-right-padding);
margin-right: 1px;
}

.container::after {
Expand All @@ -41,6 +42,16 @@
border-right: none;
}

:host(:focus-visible) {
outline: none;
}

:host(:focus-visible) .container, .container:focus-visible {
outline: 2px solid var(--bl-color-primary);
outline-offset: calc(-1 * var(--bl-size-3xs));
border-radius: var(--bl-border-radius-s);
}

:host .container::before {
content: '';
position: absolute;
Expand Down
17 changes: 17 additions & 0 deletions src/components/tab-group/tab/bl-tab.stories.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import { Meta, Canvas, ArgsTable, Story, Preview, Source } from '@storybook/addon-docs';
import { userEvent } from '@storybook/testing-library';

<Meta
title="Components/Tab/Tab"
Expand Down Expand Up @@ -55,6 +56,12 @@ export const SelectedDisabledTogetherTemplate = (args) => html`
</bl-tab-group>
`

export const focusTab = async ({ }) => {
await userEvent.tab();
await userEvent.keyboard('{ArrowRight}');
await userEvent.keyboard(' ');
}

# Tab
Within tab groups, tabs are used to represent and activate tab panels. A tab can be disabled by setting the disabled prop and can be selected by selected prop.

Expand Down Expand Up @@ -132,6 +139,16 @@ If disabled and selected props are used in same table, disabled prop overrides s
</Story>
</Canvas>

### Keyboard navigation

This component can handle keyboard navigation to switch between available tabs using arrow keys. First `Tab` focuses on the selected tab and user can navigate to other tabs with arrow keys, and `Space` or `Enter` key to select the tab.

<Canvas>
<Story name="Keyboard navigation" play={focusTab}>
{TabsTemplate.bind({})}
</Story>
</Canvas>

## Reference

<ArgsTable of="bl-tab" />
Expand Down
11 changes: 10 additions & 1 deletion src/components/tab-group/tab/bl-tab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe('bl-tab', function () {
<button
role="tab"
class="container"
aria-selected="false"
>
<div class="title-container">
<div class="title">
Expand Down Expand Up @@ -58,7 +59,7 @@ describe('bl-tab', function () {
it('should create custom event when change selected attribute', async () => {
const el = await fixture<BlTab>(html` <bl-tab name="test"></bl-tab>`);
el.selected=true;

const listener = await oneEvent(el, 'bl-tab-selected');
const { detail } = await listener;

Expand All @@ -71,4 +72,12 @@ describe('bl-tab', function () {

expect(caption).is.exist;
});

it('should have aria-selected attribute set to true if the tab is selected', async function () {
const el = await fixture<BlTab>(html`<bl-tab name="test" selected></bl-tab>`);

const tabButton = el.shadowRoot?.querySelector<HTMLButtonElement>('.container');
expect(el).has.attribute('tabindex', '0');
expect(tabButton).has.attribute('aria-selected', 'true');
});
});
13 changes: 11 additions & 2 deletions src/components/tab-group/tab/bl-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
PropertyValues,
TemplateResult,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { customElement, property, query } from 'lit/decorators.js';
import { event, EventDispatcher } from '../../../utilities/event';

import style from './bl-tab.css';
Expand Down Expand Up @@ -91,14 +91,22 @@ export default class BlTab extends LitElement {
*/
@event('bl-tab-selected') private _onSelect: EventDispatcher<string>;

@query('.container')
private tab: HTMLButtonElement;

/**
* Set tab selected.
*/
select() {
this.selected = true;
}

updated(changedProperties: PropertyValues<this>) {
focus() {
this.tab.focus();
}

updated(changedProperties: PropertyValues<this>) {
this.tabIndex = this.selected ? 0 : -1;
if (changedProperties.has('selected') && this.selected) {
this._onSelect(this.name);
}
Expand Down Expand Up @@ -142,6 +150,7 @@ export default class BlTab extends LitElement {
role="tab"
class="container"
@click="${() => this.select()}"
aria-selected="${this.selected}"
>
<div class="title-container">
<div class="title">${icon} ${title} ${badge}</div>
Expand Down

0 comments on commit 288192a

Please sign in to comment.