diff --git a/packages/tab-list/README.md b/packages/tab-list/README.md index bdb0e3c715..465a1874ce 100644 --- a/packages/tab-list/README.md +++ b/packages/tab-list/README.md @@ -70,6 +70,6 @@ The `` component contains set of tab-item elements. This is typical ``` -## Keyboard Focus +## Accessibility -By default, the first tab in tab-list automatically becomes selected when the tab-list receives focus. +When an `` has a `selected` value, the `` child of that `value` will be given `[tabindex="0"]` and will receive initial focus when tabbing into the `` element. When no `selected` value is present, the first `` child will be treated in this way. When focus is currently within the `` element, the left and right arrows will move that focus back and forth through the available `` children. diff --git a/packages/tab-list/package.json b/packages/tab-list/package.json index dfa9e9b657..5cc6f3b5ca 100644 --- a/packages/tab-list/package.json +++ b/packages/tab-list/package.json @@ -41,6 +41,7 @@ "@spectrum-css/tabs": "^2.1.1" }, "dependencies": { + "@spectrum-web-components/tab": "^0.2.2", "tslib": "^1.10.0" } } diff --git a/packages/tab-list/src/tab-list.ts b/packages/tab-list/src/tab-list.ts index 16129a74c0..fa8a0a526d 100644 --- a/packages/tab-list/src/tab-list.ts +++ b/packages/tab-list/src/tab-list.ts @@ -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 @@ -58,6 +65,8 @@ export class TabList extends LitElement { private _selected = ''; + private tabs: Tab[] = []; + protected render(): TemplateResult { return html` { + 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); @@ -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); } @@ -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 { @@ -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'); } } diff --git a/packages/tab-list/test/tab-list.test.ts b/packages/tab-list/test/tab-list.test.ts index 1b16394c07..45608cb627 100644 --- a/packages/tab-list/test/tab-list.test.ts +++ b/packages/tab-list/test/tab-list.test.ts @@ -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 => await fixture( html` - - - + + + ` ); @@ -167,9 +171,9 @@ describe('TabList', () => { it('displays `vertical`', async () => { const el = await fixture(html` - - - + + + `); @@ -183,9 +187,9 @@ describe('TabList', () => { it('displays with nothing `selected`', async () => { const el = await fixture(html` - - - + + + `); @@ -199,7 +203,7 @@ describe('TabList', () => { it('ignores children with no `value`', async () => { const el = await fixture(html` - +
Other thing
`); @@ -216,8 +220,8 @@ describe('TabList', () => { const cancelSelection = (event: Event): void => event.preventDefault(); const el = await fixture(html` - - + + `); @@ -231,22 +235,109 @@ describe('TabList', () => { }); it('accepts keyboard based selection', async () => { const el = await fixture(html` - - - + + + + + + + `); 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(html` + + + + + + + + + `); + + 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); diff --git a/packages/tab/README.md b/packages/tab/README.md index 20130ac8c6..355ea1cfdb 100644 --- a/packages/tab/README.md +++ b/packages/tab/README.md @@ -6,10 +6,10 @@ The `` component is intended to be the child of an `` eleme ```html - - - - + + + + ``` @@ -19,7 +19,7 @@ The `` component is intended to be the child of an `` eleme ```html - + ``` @@ -27,7 +27,11 @@ The `` component is intended to be the child of an `` eleme ### Vertical w/ Icon ```html - + ``` + +## Accessibility + +By default, an `` element has `[tabindex="-1"]` so that it can be focused programmatically. When an `` element is `[selected]` or isthe first `` in an `` element with no `selected` value, it will be given `[tabindex="0"]`. diff --git a/packages/tab/src/tab.ts b/packages/tab/src/tab.ts index b46479f8a5..780040d718 100644 --- a/packages/tab/src/tab.ts +++ b/packages/tab/src/tab.ts @@ -16,7 +16,9 @@ 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'; @@ -24,7 +26,7 @@ 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]; } @@ -57,4 +59,18 @@ export class Tab extends LitElement { `; } + + 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'); + } + } } diff --git a/packages/tab/stories/tabs.stories.ts b/packages/tab/stories/tabs.stories.ts index 4519f35241..e8b7e50616 100644 --- a/packages/tab/stories/tabs.stories.ts +++ b/packages/tab/stories/tabs.stories.ts @@ -21,20 +21,20 @@ storiesOf('Tabs', module) .add('Default', () => { return html` - - - - + + + + `; }) .add('Vertical', () => { return html` - - - - + + + + `; }) diff --git a/packages/tab/test/tab.test.ts b/packages/tab/test/tab.test.ts new file mode 100644 index 0000000000..1215d57145 --- /dev/null +++ b/packages/tab/test/tab.test.ts @@ -0,0 +1,35 @@ +/* +Copyright 2019 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +import '../'; +import { Tab } from '../'; +import { fixture, elementUpdated, html, expect } from '@open-wc/testing'; + +describe('Tab', () => { + it('Updates label', async () => { + const el = await fixture( + html` + + ` + ); + + await elementUpdated(el); + const label = el.shadowRoot + ? (el.shadowRoot.querySelector('#itemLabel') as HTMLLabelElement) + : (el.querySelector('#itemLabel') as HTMLLabelElement); + expect(label.textContent).to.include('Tab 1'); + + el.label = 'Other Tab'; + + await elementUpdated(el); + expect(label.textContent).to.include('Other Tab'); + }); +});