Skip to content

Commit

Permalink
feat: upgrade accessibility of tab/tab-list family of elements
Browse files Browse the repository at this point in the history
  • Loading branch information
Westbrook Johnson authored and Westbrook committed Dec 17, 2019
1 parent cf046f6 commit c7ea803
Show file tree
Hide file tree
Showing 8 changed files with 240 additions and 33 deletions.
4 changes: 2 additions & 2 deletions packages/tab-list/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,6 @@ The `<sp-tab-list>` component contains set of tab-item elements. This is typical
</div>
```

## Keyboard Focus
## Accessibility

By default, the first tab in tab-list automatically becomes selected when the tab-list receives focus.
When an `<sp-tab-list>` has a `selected` value, the `<sp-tab>` child of that `value` will be given `[tabindex="0"]` and will receive initial focus when tabbing into the `<sp-tab-list>` element. When no `selected` value is present, the first `<sp-tab>` child will be treated in this way. When focus is currently within the `<sp-tab-list>` element, the left and right arrows will move that focus back and forth through the available `<sp-tab>` children.
1 change: 1 addition & 0 deletions packages/tab-list/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@spectrum-css/tabs": "^2.1.1"
},
"dependencies": {
"@spectrum-web-components/tab": "^0.2.2",
"tslib": "^1.10.0"
}
}
60 changes: 60 additions & 0 deletions packages/tab-list/src/tab-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@ import {
property,
CSSResultArray,
TemplateResult,
PropertyValues,
} from 'lit-element';
import { Tab } from '@spectrum-web-components/tab';

import tabStyles from './tab-list.css.js';

const availableArrowsByDirection = {
vertical: ['ArrowUp', 'ArrowDown'],
horizontal: ['ArrowLeft', 'ArrowRight'],
};

/**
* @slot - Child tab elements
* @attr {Boolean} quiet - The tab-list border is a lot smaller
Expand Down Expand Up @@ -58,6 +65,8 @@ export class TabList extends LitElement {

private _selected = '';

private tabs: Tab[] = [];

protected render(): TemplateResult {
return html`
<slot
Expand All @@ -72,6 +81,47 @@ export class TabList extends LitElement {
`;
}

protected firstUpdated(changes: PropertyValues): void {
super.firstUpdated(changes);
this.setAttribute('role', 'tablist');
this.addEventListener('focusin', this.startListeningToKeyboard);
this.addEventListener('focusout', this.stopListeningToKeyboard);
}

protected updated(changes: PropertyValues): void {
super.updated(changes);
if (changes.has('direction')) {
if (this.direction === 'vertical') {
this.setAttribute('aria-orientation', 'vertical');
} else {
this.removeAttribute('aria-orientation');
}
}
}

public startListeningToKeyboard = (): void => {
this.addEventListener('keydown', this.handleKeydown);
};

public stopListeningToKeyboard = (): void => {
this.removeEventListener('keydown', this.handleKeydown);
};

public handleKeydown(event: KeyboardEvent): void {
const { code } = event;
const availableArrows = availableArrowsByDirection[this.direction];
if (!availableArrows.includes(code)) {
return;
}
event.preventDefault();
const currentFocusedTab = document.activeElement as Tab;
let currentFocusedTabIndex = this.tabs.indexOf(currentFocusedTab);
currentFocusedTabIndex += code === availableArrows[0] ? -1 : 1;
this.tabs[
(currentFocusedTabIndex + this.tabs.length) % this.tabs.length
].focus();
}

private onClick(event: Event): void {
const target = event.target as HTMLElement;
this.selectTarget(target);
Expand All @@ -81,6 +131,7 @@ export class TabList extends LitElement {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const target = event.target as HTMLElement;
/* istanbul ignore else */
if (target) {
this.selectTarget(target);
}
Expand All @@ -105,6 +156,7 @@ export class TabList extends LitElement {

private onSlotChange(): void {
this.updateCheckedState(this.selected);
this.tabs = [...this.querySelectorAll('[role="tab"]')] as Tab[];
}

private updateCheckedState(value: string): void {
Expand All @@ -119,6 +171,14 @@ export class TabList extends LitElement {

if (currentChecked) {
currentChecked.setAttribute('selected', '');
} else {
this.selected = '';
}
}
if (!this.selected) {
const firstTab = this.querySelector('[role="tab"]');
if (firstTab) {
firstTab.setAttribute('tabindex', '0');
}
}

Expand Down
123 changes: 107 additions & 16 deletions packages/tab-list/test/tab-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,18 @@ const keyboardEvent = (code: string): KeyboardEvent =>
});
const enterEvent = keyboardEvent('Enter');
const spaceEvent = keyboardEvent(' ');
const arrowRightEvent = keyboardEvent('ArrowRight');
const arrowLeftEvent = keyboardEvent('ArrowLeft');
const arrowUpEvent = keyboardEvent('ArrowUp');
const arrowDownEvent = keyboardEvent('ArrowDown');

const createTabList = async (): Promise<TabList> =>
await fixture<TabList>(
html`
<sp-tab-list selected="first">
<sp-tab label="Tab 1" value="first" tabindex="1"></sp-tab>
<sp-tab label="Tab 2" value="second" tabindex="2"></sp-tab>
<sp-tab label="Tab 3" value="third" tabindex="3"></sp-tab>
<sp-tab label="Tab 1" value="first"></sp-tab>
<sp-tab label="Tab 2" value="second"></sp-tab>
<sp-tab label="Tab 3" value="third"></sp-tab>
</sp-tab-list>
`
);
Expand Down Expand Up @@ -167,9 +171,9 @@ describe('TabList', () => {
it('displays `vertical`', async () => {
const el = await fixture<TabList>(html`
<sp-tab-list selected="first" direction="vertical">
<sp-tab label="Tab 1" value="first" tabindex="1"></sp-tab>
<sp-tab label="Tab 2" value="second" tabindex="2"></sp-tab>
<sp-tab label="Tab 3" value="third" tabindex="3"></sp-tab>
<sp-tab label="Tab 1" value="first"></sp-tab>
<sp-tab label="Tab 2" value="second"></sp-tab>
<sp-tab label="Tab 3" value="third"></sp-tab>
</sp-tab-list>
`);

Expand All @@ -183,9 +187,9 @@ describe('TabList', () => {
it('displays with nothing `selected`', async () => {
const el = await fixture<TabList>(html`
<sp-tab-list>
<sp-tab label="Tab 1" value="first" tabindex="1"></sp-tab>
<sp-tab label="Tab 2" value="second" tabindex="2"></sp-tab>
<sp-tab label="Tab 3" value="third" tabindex="3"></sp-tab>
<sp-tab label="Tab 1" value="first"></sp-tab>
<sp-tab label="Tab 2" value="second"></sp-tab>
<sp-tab label="Tab 3" value="third"></sp-tab>
</sp-tab-list>
`);

Expand All @@ -199,7 +203,7 @@ describe('TabList', () => {
it('ignores children with no `value`', async () => {
const el = await fixture<TabList>(html`
<sp-tab-list selected="first">
<sp-tab label="Tab 1" value="first" tabindex="1"></sp-tab>
<sp-tab label="Tab 1" value="first"></sp-tab>
<div id="other">Other thing</div>
</sp-tab-list>
`);
Expand All @@ -216,8 +220,8 @@ describe('TabList', () => {
const cancelSelection = (event: Event): void => event.preventDefault();
const el = await fixture<TabList>(html`
<sp-tab-list selected="first" @change=${cancelSelection}>
<sp-tab label="Tab 1" value="first" tabindex="1"></sp-tab>
<sp-tab label="Tab 2" value="second" tabindex="2"></sp-tab>
<sp-tab label="Tab 1" value="first"></sp-tab>
<sp-tab label="Tab 2" value="second"></sp-tab>
</sp-tab-list>
`);

Expand All @@ -231,22 +235,109 @@ describe('TabList', () => {
});
it('accepts keyboard based selection', async () => {
const el = await fixture<TabList>(html`
<sp-tab-list selected="first">
<sp-tab label="Tab 1" value="first" tabindex="1"></sp-tab>
<sp-tab label="Tab 2" value="second" tabindex="2"></sp-tab>
<sp-tab-list selected="Unknown">
<sp-tab label="Tab 1" value="first">
<sp-icon
slot="icon"
size="s"
name="ui:CheckmarkSmall"
></sp-icon>
</sp-tab>
<sp-tab label="Tab 2" value="second">
<sp-icon
slot="icon"
size="s"
name="ui:CheckmarkSmall"
></sp-icon>
</sp-tab>
</sp-tab-list>
`);

await elementUpdated(el);
expect(el.selected).to.be.equal('first');
expect(el.selected).to.be.equal('');

const firstTab = el.querySelector('[value="first"]') as Tab;
const secondTab = el.querySelector('[value="second"]') as Tab;
firstTab.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
firstTab.focus();

await elementUpdated(el);
expect(document.activeElement === firstTab, 'Focus first tab').to.be
.true;

firstTab.dispatchEvent(arrowLeftEvent);
firstTab.dispatchEvent(arrowUpEvent);

await elementUpdated(el);
expect(document.activeElement === secondTab, 'Focus second tab').to.be
.true;

secondTab.dispatchEvent(enterEvent);

await elementUpdated(el);
expect(el.selected).to.be.equal('second');

secondTab.dispatchEvent(arrowRightEvent);

await elementUpdated(el);
expect(document.activeElement === firstTab, 'Focus first tab').to.be
.true;

firstTab.dispatchEvent(spaceEvent);

await elementUpdated(el);
expect(el.selected).to.be.equal('first');
});
it('accepts keyboard based selection - [direction="vertical"]', async () => {
const el = await fixture<TabList>(html`
<sp-tab-list selected="Unknown" direction="vertical">
<sp-tab label="Tab 1" value="first">
<sp-icon
slot="icon"
size="s"
name="ui:CheckmarkSmall"
></sp-icon>
</sp-tab>
<sp-tab label="Tab 2" value="second">
<sp-icon
slot="icon"
size="s"
name="ui:CheckmarkSmall"
></sp-icon>
</sp-tab>
</sp-tab-list>
`);

await elementUpdated(el);
expect(el.selected).to.be.equal('');

const firstTab = el.querySelector('[value="first"]') as Tab;
const secondTab = el.querySelector('[value="second"]') as Tab;
firstTab.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
firstTab.focus();

await elementUpdated(el);
expect(document.activeElement === firstTab, 'Focus first tab').to.be
.true;

firstTab.dispatchEvent(arrowLeftEvent);
firstTab.dispatchEvent(arrowUpEvent);

await elementUpdated(el);
expect(document.activeElement === secondTab, 'Focus second tab').to.be
.true;

secondTab.dispatchEvent(enterEvent);

await elementUpdated(el);
expect(el.selected).to.be.equal('second');

secondTab.dispatchEvent(arrowDownEvent);

await elementUpdated(el);
expect(document.activeElement === firstTab, 'Focus first tab').to.be
.true;

firstTab.dispatchEvent(spaceEvent);

await elementUpdated(el);
Expand Down
16 changes: 10 additions & 6 deletions packages/tab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ The `<sp-tab>` component is intended to be the child of an `<sp-tab-list>` eleme

```html
<sp-tab-list selected="1">
<sp-tab label="Tab 1" value="1" tabindex="1"></sp-tab>
<sp-tab label="Tab 2" value="2" tabindex="2"></sp-tab>
<sp-tab label="Tab 3" value="3" tabindex="3"></sp-tab>
<sp-tab label="Tab 4" value="4" tabindex="4"></sp-tab>
<sp-tab label="Tab 1" value="1"></sp-tab>
<sp-tab label="Tab 2" value="2"></sp-tab>
<sp-tab label="Tab 3" value="3"></sp-tab>
<sp-tab label="Tab 4" value="4"></sp-tab>
</sp-tab-list>
```

Expand All @@ -19,15 +19,19 @@ The `<sp-tab>` component is intended to be the child of an `<sp-tab-list>` eleme

```html
<sp-icons-medium></sp-icons-medium>
<sp-tab label="Tab 1" value="1" tabindex="1">
<sp-tab label="Tab 1" value="1">
<sp-icon slot="icon" size="m" name="ui:CheckmarkSmall"></sp-icon>
</sp-tab>
```

### Vertical w/ Icon

```html
<sp-tab label="Tab 1" value="1" tabindex="1" vertical>
<sp-tab label="Tab 1" value="1" vertical>
<sp-icon slot="icon" size="m" name="ui:CheckmarkSmall"></sp-icon>
</sp-tab>
```

## Accessibility

By default, an `<sp-tab>` element has `[tabindex="-1"]` so that it can be focused programmatically. When an `<sp-tab>` element is `[selected]` or isthe first `<sp-tab>` in an `<sp-tab-list>` element with no `selected` value, it will be given `[tabindex="0"]`.
18 changes: 17 additions & 1 deletion packages/tab/src/tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,17 @@ import {
CSSResultArray,
TemplateResult,
LitElement,
PropertyValues,
} from 'lit-element';
import { FocusVisiblePolyfillMixin } from '@spectrum-web-components/shared/lib/focus-visible.js';

import tabItemStyles from './tab.css.js';

/**
* @slot icon - The icon that appears on the left of the label
*/

export class Tab extends LitElement {
export class Tab extends FocusVisiblePolyfillMixin(LitElement) {
public static get styles(): CSSResultArray {
return [tabItemStyles];
}
Expand Down Expand Up @@ -57,4 +59,18 @@ export class Tab extends LitElement {
</label>
`;
}

protected firstUpdated(): void {
this.setAttribute('role', 'tab');
}

protected updated(changes: PropertyValues): void {
if (changes.has('selected')) {
this.setAttribute(
'aria-selected',
this.selected ? 'true' : 'false'
);
this.setAttribute('tabindex', this.selected ? '0' : '-1');
}
}
}
Loading

0 comments on commit c7ea803

Please sign in to comment.