diff --git a/src/components/tab-group/bl-tab-group.test.ts b/src/components/tab-group/bl-tab-group.test.ts index c3e48d59..3f172322 100644 --- a/src/components/tab-group/bl-tab-group.test.ts +++ b/src/components/tab-group/bl-tab-group.test.ts @@ -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 = () => { @@ -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 () { @@ -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; }); }); @@ -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(html` + Test 1 Tab + Test 2 Tab + Test 3 Tab + + + + `); + + 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(html` + Test 1 Tab + Test 2 Tab + Test 3 Tab + + + + `); + + 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(html` + Test 1 Tab + Test 2 Tab + Test 3 Tab + + + + `); + + 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; + }); +}); diff --git a/src/components/tab-group/bl-tab-group.ts b/src/components/tab-group/bl-tab-group.ts index 12c5d349..33b2e6db 100644 --- a/src/components/tab-group/bl-tab-group.ts +++ b/src/components/tab-group/bl-tab-group.ts @@ -18,6 +18,7 @@ export default class BlTabGroup extends LitElement { private _connectedTabs: BlTab[] = []; private _connectedPanels: BlTabPanel[] = []; + private _tabFocus = 0; get tabs() { return this._connectedTabs; @@ -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; } } @@ -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); } @@ -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`
-
+
diff --git a/src/components/tab-group/tab-panel/bl-tab-panel.css b/src/components/tab-group/tab-panel/bl-tab-panel.css index 98049bcb..324e1be6 100644 --- a/src/components/tab-group/tab-panel/bl-tab-panel.css +++ b/src/components/tab-group/tab-panel/bl-tab-panel.css @@ -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; } diff --git a/src/components/tab-group/tab-panel/bl-tab-panel.test.ts b/src/components/tab-group/tab-panel/bl-tab-panel.test.ts index 7b550230..2e95e535 100644 --- a/src/components/tab-group/tab-panel/bl-tab-panel.test.ts +++ b/src/components/tab-group/tab-panel/bl-tab-panel.test.ts @@ -10,7 +10,7 @@ describe('bl-tab-panel', function () { it('should render with default values', async function () { const el = await fixture(html` `); const expected = ` - + `; expect(el).to.be.shadowDom.equal(expected); }); diff --git a/src/components/tab-group/tab-panel/bl-tab-panel.ts b/src/components/tab-group/tab-panel/bl-tab-panel.ts index 511d25d5..ef0ce0b8 100644 --- a/src/components/tab-group/tab-panel/bl-tab-panel.ts +++ b/src/components/tab-group/tab-panel/bl-tab-panel.ts @@ -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'; @@ -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``; + return html`
`; } } diff --git a/src/components/tab-group/tab/bl-tab.css b/src/components/tab-group/tab/bl-tab.css index 41f3d18c..6e6c51b5 100644 --- a/src/components/tab-group/tab/bl-tab.css +++ b/src/components/tab-group/tab/bl-tab.css @@ -26,6 +26,7 @@ width: max-content; height: var(--tab-height); padding: 0 var(--tab-right-padding); + margin-right: 1px; } .container::after { @@ -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; diff --git a/src/components/tab-group/tab/bl-tab.stories.mdx b/src/components/tab-group/tab/bl-tab.stories.mdx index 966d7021..43fc773d 100644 --- a/src/components/tab-group/tab/bl-tab.stories.mdx +++ b/src/components/tab-group/tab/bl-tab.stories.mdx @@ -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'; html` ` +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. @@ -132,6 +139,16 @@ If disabled and selected props are used in same table, disabled prop overrides s +### 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. + + + + {TabsTemplate.bind({})} + + + ## Reference diff --git a/src/components/tab-group/tab/bl-tab.test.ts b/src/components/tab-group/tab/bl-tab.test.ts index 6f556234..9fbb05a8 100644 --- a/src/components/tab-group/tab/bl-tab.test.ts +++ b/src/components/tab-group/tab/bl-tab.test.ts @@ -14,6 +14,7 @@ describe('bl-tab', function () {