diff --git a/README.md b/README.md index 3843514ae..665d8ddf0 100644 --- a/README.md +++ b/README.md @@ -29,37 +29,38 @@ npm i @lion/ The accessibility column indicates whether the functionality is accessible in its core. Aspects like styling and content determine actual accessibility in usage. -| Package | Version | Description | Accessibility | -| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------- | -------------------------------------------------------------------- | -| [core](./packages/core) | [![core](https://img.shields.io/npm/v/@lion/core.svg)](https://www.npmjs.com/package/@lion/core) | Core System (exports LitElement, lit-html) | n/a | -| [localize](./packages/localize) | [![localize](https://img.shields.io/npm/v/@lion/localize.svg)](https://www.npmjs.com/package/@lion/localize) | Localize and translate your application/components | n/a | -| [ajax](./packages/ajax) | [![ajax](https://img.shields.io/npm/v/@lion/ajax.svg)](https://www.npmjs.com/package/@lion/ajax) | Fetching data via ajax request | n/a | -| [button](./packages/button) | [![button](https://img.shields.io/npm/v/@lion/button.svg)](https://www.npmjs.com/package/@lion/button) | Button | [#64][i64] | -| [switch](./packages/switch) | [![switch](https://img.shields.io/npm/v/@lion/switch.svg)](https://www.npmjs.com/package/@lion/switch) | Switch | ✔️ | -| [calendar](./packages/calendar) | [![calendar](https://img.shields.io/npm/v/@lion/calendar.svg)](https://www.npmjs.com/package/@lion/calendar) | Standalone calendar | [#195][i195], [#194][i194], [#193][i193], [#191][i191] | -| [icon](./packages/icon) | [![icon](https://img.shields.io/npm/v/@lion/icon.svg)](https://www.npmjs.com/package/@lion/icon) | Display our svg icons | [#173][i173], [#172][i172] | -| [steps](./packages/steps) | [![steps](https://img.shields.io/npm/v/@lion/steps.svg)](https://www.npmjs.com/package/@lion/steps) | Multi Step System | n/a | -| **-- Forms --** | | | -| [form](./packages/form) | [![form](https://img.shields.io/npm/v/@lion/form.svg)](https://www.npmjs.com/package/@lion/form) | Wrapper for multiple form elements | ✔️ | -| [field](./packages/field) | [![field](https://img.shields.io/npm/v/@lion/field.svg)](https://www.npmjs.com/package/@lion/field) | Base Class for all inputs | [#190][i190] | -| [fieldset](./packages/fieldset) | [![fieldset](https://img.shields.io/npm/v/@lion/fieldset.svg)](https://www.npmjs.com/package/@lion/fieldset) | Group for form inputs | ✔️ | -| [validate](./packages/validate) | [![validate](https://img.shields.io/npm/v/@lion/validate.svg)](https://www.npmjs.com/package/@lion/validate) | Validation for form components | n/a | -| [checkbox](./packages/checkbox) | [![checkbox](https://img.shields.io/npm/v/@lion/checkbox.svg)](https://www.npmjs.com/package/@lion/checkbox) | Checkbox form element | ✔️ | -| [checkbox-group](./packages/checkbox-group) | [![checkbox-group](https://img.shields.io/npm/v/@lion/checkbox-group.svg)](https://www.npmjs.com/package/@lion/checkbox-group) | Group of checkboxes | ✔️ | -| [input](./packages/input) | [![input](https://img.shields.io/npm/v/@lion/input.svg)](https://www.npmjs.com/package/@lion/input) | Input element for strings | ✔️ | -| [input-amount](./packages/input-amount) | [![input-amount](https://img.shields.io/npm/v/@lion/input-amount.svg)](https://www.npmjs.com/package/@lion/input-amount) | Input element for amounts | [#166][i166] | ✔️ | -| [input-date](./packages/input-date) | [![input-date](https://img.shields.io/npm/v/@lion/input-date.svg)](https://www.npmjs.com/package/@lion/input-date) | Input element for dates | ✔️ | -| [input-datepicker](./packages/input-datepicker) | [![input-datepicker](https://img.shields.io/npm/v/@lion/input-datepicker.svg)](https://www.npmjs.com/package/@lion/input-datepicker) | Input element for dates with a datepicker | ✔️ | -| [input-email](./packages/input-email) | [![input-email](https://img.shields.io/npm/v/@lion/input-email.svg)](https://www.npmjs.com/package/@lion/input-email) | Input element for e-mails | [#169][i169] | -| [input-iban](./packages/input-iban) | [![input-iban](https://img.shields.io/npm/v/@lion/input-iban.svg)](https://www.npmjs.com/package/@lion/input-iban) | Input element for IBANs | [#169][i169] | -| [radio](./packages/radio) | [![radio](https://img.shields.io/npm/v/@lion/radio.svg)](https://www.npmjs.com/package/@lion/radio) | Radio from element | ✔️ | -| [radio-group](./packages/radio-group) | [![radio-group](https://img.shields.io/npm/v/@lion/radio-group.svg)](https://www.npmjs.com/package/@lion/radio-group) | Group of radios | ✔️ | -| [select](./packages/select) | [![select](https://img.shields.io/npm/v/@lion/select.svg)](https://www.npmjs.com/package/@lion/select) | Simple native dropdown element | ✔️ | -| [textarea](./packages/textarea) | [![textarea](https://img.shields.io/npm/v/@lion/textarea.svg)](https://www.npmjs.com/package/@lion/textarea) | Multiline text input | [#165][i165] | -| **-- Overlays --** | | | | -| [overlays](./packages/overlays) | [![overlays](https://img.shields.io/npm/v/@lion/overlays.svg)](https://www.npmjs.com/package/@lion/overlays) | Overlays System using lit-html for rendering | ✔️ | -| [popup](./packages/popup) | [![popup](https://img.shields.io/npm/v/@lion/popup.svg)](https://www.npmjs.com/package/@lion/popup) | Popup element | [#175][i175], [#174][i174] | -| [tooltip](./packages/tooltip) | [![tooltip](https://img.shields.io/npm/v/@lion/tooltip.svg)](https://www.npmjs.com/package/@lion/tooltip) | Popup element | [#178][i178], [#177][i177], [#176][i176], [#175][i175], [#174][i174] | +| Package | Version | Description | Accessibility | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | -------------------------------------------------------------------- | +| [core](./packages/core) | [![core](https://img.shields.io/npm/v/@lion/core.svg)](https://www.npmjs.com/package/@lion/core) | Core System (exports LitElement, lit-html) | n/a | +| [localize](./packages/localize) | [![localize](https://img.shields.io/npm/v/@lion/localize.svg)](https://www.npmjs.com/package/@lion/localize) | Localize and translate your application/components | n/a | +| [ajax](./packages/ajax) | [![ajax](https://img.shields.io/npm/v/@lion/ajax.svg)](https://www.npmjs.com/package/@lion/ajax) | Fetching data via ajax request | n/a | +| [button](./packages/button) | [![button](https://img.shields.io/npm/v/@lion/button.svg)](https://www.npmjs.com/package/@lion/button) | Button | [#64][i64] | +| [switch](./packages/switch) | [![switch](https://img.shields.io/npm/v/@lion/switch.svg)](https://www.npmjs.com/package/@lion/switch) | Switch | ✔️ | +| [calendar](./packages/calendar) | [![calendar](https://img.shields.io/npm/v/@lion/calendar.svg)](https://www.npmjs.com/package/@lion/calendar) | Standalone calendar | [#195][i195], [#194][i194], [#193][i193], [#191][i191] | +| [icon](./packages/icon) | [![icon](https://img.shields.io/npm/v/@lion/icon.svg)](https://www.npmjs.com/package/@lion/icon) | Display our svg icons | [#173][i173], [#172][i172] | +| [steps](./packages/steps) | [![steps](https://img.shields.io/npm/v/@lion/steps.svg)](https://www.npmjs.com/package/@lion/steps) | Multi Step System | n/a | +| [tabs](./packages/tabs) | [![tBS](https://img.shields.io/npm/v/@lion/tabs.svg)](https://www.npmjs.com/package/@lion/tabs) | Move between a small number of equally important views | n/a | +| **-- Forms --** | | | +| [form](./packages/form) | [![form](https://img.shields.io/npm/v/@lion/form.svg)](https://www.npmjs.com/package/@lion/form) | Wrapper for multiple form elements | ✔️ | +| [field](./packages/field) | [![field](https://img.shields.io/npm/v/@lion/field.svg)](https://www.npmjs.com/package/@lion/field) | Base Class for all inputs | [#190][i190] | +| [fieldset](./packages/fieldset) | [![fieldset](https://img.shields.io/npm/v/@lion/fieldset.svg)](https://www.npmjs.com/package/@lion/fieldset) | Group for form inputs | ✔️ | +| [validate](./packages/validate) | [![validate](https://img.shields.io/npm/v/@lion/validate.svg)](https://www.npmjs.com/package/@lion/validate) | Validation for form components | n/a | +| [checkbox](./packages/checkbox) | [![checkbox](https://img.shields.io/npm/v/@lion/checkbox.svg)](https://www.npmjs.com/package/@lion/checkbox) | Checkbox form element | ✔️ | +| [checkbox-group](./packages/checkbox-group) | [![checkbox-group](https://img.shields.io/npm/v/@lion/checkbox-group.svg)](https://www.npmjs.com/package/@lion/checkbox-group) | Group of checkboxes | ✔️ | +| [input](./packages/input) | [![input](https://img.shields.io/npm/v/@lion/input.svg)](https://www.npmjs.com/package/@lion/input) | Input element for strings | ✔️ | +| [input-amount](./packages/input-amount) | [![input-amount](https://img.shields.io/npm/v/@lion/input-amount.svg)](https://www.npmjs.com/package/@lion/input-amount) | Input element for amounts | [#166][i166] | ✔️ | +| [input-date](./packages/input-date) | [![input-date](https://img.shields.io/npm/v/@lion/input-date.svg)](https://www.npmjs.com/package/@lion/input-date) | Input element for dates | ✔️ | +| [input-datepicker](./packages/input-datepicker) | [![input-datepicker](https://img.shields.io/npm/v/@lion/input-datepicker.svg)](https://www.npmjs.com/package/@lion/input-datepicker) | Input element for dates with a datepicker | ✔️ | +| [input-email](./packages/input-email) | [![input-email](https://img.shields.io/npm/v/@lion/input-email.svg)](https://www.npmjs.com/package/@lion/input-email) | Input element for e-mails | [#169][i169] | +| [input-iban](./packages/input-iban) | [![input-iban](https://img.shields.io/npm/v/@lion/input-iban.svg)](https://www.npmjs.com/package/@lion/input-iban) | Input element for IBANs | [#169][i169] | +| [radio](./packages/radio) | [![radio](https://img.shields.io/npm/v/@lion/radio.svg)](https://www.npmjs.com/package/@lion/radio) | Radio from element | ✔️ | +| [radio-group](./packages/radio-group) | [![radio-group](https://img.shields.io/npm/v/@lion/radio-group.svg)](https://www.npmjs.com/package/@lion/radio-group) | Group of radios | ✔️ | +| [select](./packages/select) | [![select](https://img.shields.io/npm/v/@lion/select.svg)](https://www.npmjs.com/package/@lion/select) | Simple native dropdown element | ✔️ | +| [textarea](./packages/textarea) | [![textarea](https://img.shields.io/npm/v/@lion/textarea.svg)](https://www.npmjs.com/package/@lion/textarea) | Multiline text input | [#165][i165] | +| **-- Overlays --** | | | | +| [overlays](./packages/overlays) | [![overlays](https://img.shields.io/npm/v/@lion/overlays.svg)](https://www.npmjs.com/package/@lion/overlays) | Overlays System using lit-html for rendering | ✔️ | +| [popup](./packages/popup) | [![popup](https://img.shields.io/npm/v/@lion/popup.svg)](https://www.npmjs.com/package/@lion/popup) | Popup element | [#175][i175], [#174][i174] | +| [tooltip](./packages/tooltip) | [![tooltip](https://img.shields.io/npm/v/@lion/tooltip.svg)](https://www.npmjs.com/package/@lion/tooltip) | Popup element | [#178][i178], [#177][i177], [#176][i176], [#175][i175], [#174][i174] | ## How to use diff --git a/packages/tabs/CHANGELOG.md b/packages/tabs/CHANGELOG.md new file mode 100644 index 000000000..e4d87c4d4 --- /dev/null +++ b/packages/tabs/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/packages/tabs/README.md b/packages/tabs/README.md new file mode 100644 index 000000000..16323decf --- /dev/null +++ b/packages/tabs/README.md @@ -0,0 +1,51 @@ +# Tabs + +`lion-tabs` implements Tabs view to allow users to quickly move between a small number of equally important views + +## How to use + +### Installation + +```sh +npm i --save @lion/tabs; +``` + +### Usage + +```js +import '@lion/tabs/lion-tabs.js'; +``` + +```html + + +

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Laboriosam sequi odit cumque, enim aut + assumenda itaque quis voluptas est quos fugiat unde labore reiciendis saepe, iure, optio + officiis obcaecati quibusdam. +

+
About
+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Laboriosam sequi odit cumque, enim aut + assumenda itaque quis voluptas est quos fugiat unde labore reiciendis saepe, iure, optio + officiis obcaecati quibusdam. +

+
+``` + +Rationales: + +- **No separate active/focus state when using keyboard** + + We will immediately switch content as all our content comes from light dom (e.g. no latency) + + See Note at + + > It is recommended that tabs activate automatically when they receive focus as long as their + > associated tab panels are displayed without noticeable latency. This typically requires tab + > panel content to be preloaded. + +- **Panels are not focusable** + + Focusable elements should have a means to interact with them. Tab panels themselves do not offer any interactiveness. + If there is a button or a form inside the tab panel then these elements get focused directly. diff --git a/packages/tabs/index.js b/packages/tabs/index.js new file mode 100644 index 000000000..1fa85c874 --- /dev/null +++ b/packages/tabs/index.js @@ -0,0 +1 @@ +export { LionTabs } from './src/LionTabs.js'; diff --git a/packages/tabs/lion-tabs.js b/packages/tabs/lion-tabs.js new file mode 100644 index 000000000..bc635b9f1 --- /dev/null +++ b/packages/tabs/lion-tabs.js @@ -0,0 +1,3 @@ +import { LionTabs } from './src/LionTabs.js'; + +customElements.define('lion-tabs', LionTabs); diff --git a/packages/tabs/package.json b/packages/tabs/package.json new file mode 100644 index 000000000..201166f67 --- /dev/null +++ b/packages/tabs/package.json @@ -0,0 +1,42 @@ +{ + "name": "@lion/tabs", + "version": "0.0.0", + "description": "Allows users to quickly move between a small number of equally important views.", + "author": "ing-bank", + "homepage": "https://github.com/ing-bank/lion/", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/ing-bank/lion.git", + "directory": "packages/tabs" + }, + "scripts": { + "prepublishOnly": "../../scripts/npm-prepublish.js" + }, + "keywords": [ + "lion", + "web-components", + "tabs" + ], + "main": "index.js", + "module": "index.js", + "files": [ + "docs", + "src", + "stories", + "test", + "translations", + "*.js" + ], + "dependencies": { + "@lion/core": "^0.2.1" + }, + "devDependencies": { + "@open-wc/demoing-storybook": "^0.2.0", + "@open-wc/testing": "^2.3.4", + "sinon": "^7.2.2" + } +} diff --git a/packages/tabs/src/LionTabs.js b/packages/tabs/src/LionTabs.js new file mode 100644 index 000000000..f667f52ea --- /dev/null +++ b/packages/tabs/src/LionTabs.js @@ -0,0 +1,235 @@ +import { LitElement, css, html } from '@lion/core'; + +const uuid = () => + Math.random() + .toString(36) + .substr(2, 10); + +const setupPanel = ({ element, uid }) => { + element.setAttribute('id', `panel-${uid}`); + element.setAttribute('role', 'tabpanel'); + element.setAttribute('aria-labelledby', `button-${uid}`); +}; + +const selectPanel = element => { + element.setAttribute('selected', true); +}; + +const deselectPanel = element => { + element.removeAttribute('selected'); +}; + +const setupButton = ({ element, uid, clickHandler, keydownHandler }) => { + element.setAttribute('id', `button-${uid}`); + element.setAttribute('role', 'tab'); + element.setAttribute('aria-controls', `panel-${uid}`); + element.addEventListener('click', clickHandler); + element.addEventListener('keyup', keydownHandler); +}; + +const cleanButton = (element, clickHandler, keydownHandler) => { + element.removeAttribute('id'); + element.removeAttribute('role'); + element.removeAttribute('aria-controls'); + element.removeEventListener('click', clickHandler); + element.removeEventListener('keyup', keydownHandler); +}; + +const selectButton = element => { + element.focus(); + element.setAttribute('selected', true); + element.setAttribute('aria-selected', true); + element.setAttribute('tabindex', 0); +}; + +const deselectButton = element => { + element.removeAttribute('selected'); + element.setAttribute('aria-selected', false); + element.setAttribute('tabindex', -1); +}; + +export class LionTabs extends LitElement { + static get properties() { + return { + /** + * index number of the selected tab + */ + selectedIndex: { + type: Number, + value: 0, + }, + }; + } + + static get styles() { + return [ + css` + .tabs__tab-group { + display: flex; + } + + .tabs__tab-group ::slotted([slot='tab'][selected]) { + font-weight: bold; + } + + .tabs__panels ::slotted([slot='panel']) { + visibility: hidden; + display: none; + } + + .tabs__panels ::slotted([slot='panel'][selected]) { + visibility: visible; + display: block; + } + + .tabs__panels { + display: block; + } + `, + ]; + } + + render() { + return html` +
+ +
+
+ +
+ `; + } + + constructor() { + super(); + this.selectedIndex = 0; + } + + firstUpdated() { + super.firstUpdated(); + this.__setupSlots(); + } + + __setupSlots() { + const tabSlot = this.shadowRoot.querySelector('slot[name=tab]'); + const handleSlotChange = () => { + this.__cleanStore(); + this.__setupStore(); + this.__updateSelected(); + }; + tabSlot.addEventListener('slotchange', handleSlotChange); + } + + __setupStore() { + this.__store = []; + const buttons = this.querySelectorAll('[slot="tab"]'); + const panels = this.querySelectorAll('[slot="panel"]'); + if (buttons.length !== panels.length) { + // eslint-disable-next-line no-console + console.warn( + `The amount of tabs (${buttons.length}) doesn't match the amount of panels (${panels.length}).`, + ); + } + + buttons.forEach((button, index) => { + const uid = uuid(); + const panel = panels[index]; + const entry = { + uid, + button, + panel, + clickHandler: this.__createButtonClickHandler(index), + keydownHandler: this.__handleButtonKeydown.bind(this), + }; + setupPanel({ element: entry.panel, ...entry }); + setupButton({ element: entry.button, ...entry }); + deselectPanel(entry.panel); + deselectButton(entry.button); + this.__store.push(entry); + }); + } + + __cleanStore() { + if (!this.__store) { + return; + } + this.__store.forEach(entry => { + cleanButton(entry.button, entry.clickHandler, entry.keydownHandler); + }); + } + + __createButtonClickHandler(index) { + return () => { + this.selectedIndex = index; + }; + } + + __handleButtonKeydown(e) { + switch (e.key) { + case 'ArrowDown': + case 'ArrowRight': + e.preventDefault(); + if (this.selectedIndex + 1 >= this._pairCount) { + this.selectedIndex = 0; + } else { + this.selectedIndex += 1; + } + break; + case 'ArrowUp': + case 'ArrowLeft': + e.preventDefault(); + if (this.selectedIndex <= 0) { + this.selectedIndex = this._pairCount - 1; + } else { + this.selectedIndex -= 1; + } + break; + case 'Home': + e.preventDefault(); + this.selectedIndex = 0; + break; + case 'End': + e.preventDefault(); + this.selectedIndex = this._pairCount - 1; + break; + /* no default */ + } + } + + set selectedIndex(value) { + const stale = this.__selectedIndex; + this.__selectedIndex = value; + this.__updateSelected(); + this.dispatchEvent(new Event('selected-changed')); + this.requestUpdate('selectedIndex', stale); + } + + get selectedIndex() { + return this.__selectedIndex; + } + + get _pairCount() { + return this.__store.length; + } + + __updateSelected() { + if (!(this.__store && this.__store[this.selectedIndex])) { + return; + } + const previousButton = this.querySelector('[slot="tab"][selected]'); + const previousPanel = this.querySelector('[slot="panel"][selected]'); + if (previousButton) { + deselectButton(previousButton); + } + if (previousPanel) { + deselectPanel(previousPanel); + } + const { button: currentButton, panel: currentPanel } = this.__store[this.selectedIndex]; + if (currentButton) { + selectButton(currentButton); + } + if (currentPanel) { + selectPanel(currentPanel); + } + } +} diff --git a/packages/tabs/stories/index.stories.js b/packages/tabs/stories/index.stories.js new file mode 100644 index 000000000..408609f98 --- /dev/null +++ b/packages/tabs/stories/index.stories.js @@ -0,0 +1,168 @@ +import { storiesOf, html } from '@open-wc/demoing-storybook'; +import { LitElement, css } from '@lion/core'; +import '../lion-tabs.js'; + +const tabsDemoStyle = css` + .demo-tabs__tab[selected] { + font-weight: bold; + } +`; + +storiesOf('Tabs', module) + .add( + 'Default', + () => html` + + + +
+

Info

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Laboriosam sequi odit cumque, + enim aut assumenda itaque quis voluptas est quos fugiat unde labore reiciendis saepe, + iure, optio officiis obcaecati quibusdam. +

+
+ +
+

About

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Laboriosam sequi odit cumque, + enim aut assumenda itaque quis voluptas est quos fugiat unde labore reiciendis saepe, + iure, optio officiis obcaecati quibusdam. +

+
+
+ `, + ) + .add( + 'selectedIndex', + () => html` + + + +
+

Info

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Laboriosam sequi odit cumque, + enim aut assumenda itaque quis voluptas est quos fugiat unde labore reiciendis saepe, + iure, optio officiis obcaecati quibusdam. +

+
+
+

About

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Laboriosam sequi odit cumque, + enim aut assumenda itaque quis voluptas est quos fugiat unde labore reiciendis saepe, + iure, optio officiis obcaecati quibusdam. +

+
+
+ `, + ) + .add( + 'Slots order', + () => html` + + + +
+

Info

+

This is exactly the same just in the code it's differently ordered.

+
+
+

About

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. Laboriosam sequi odit cumque, + enim aut assumenda itaque quis voluptas est quos fugiat unde labore reiciendis saepe, + iure, optio officiis obcaecati quibusdam. +

+
+
+ `, + ) + .add('Distribute new elements', () => { + const tagName = 'lion-tabs-experimental'; + if (!customElements.get(tagName)) { + customElements.define( + tagName, + class extends LitElement { + static get properties() { + return { + __collection: { type: Array }, + }; + } + + render() { + return html` +

Append

+ + + +

panel 1

+ +

panel 2

+
+
+

Push

+ + + +

panel 1

+ +

panel 2

+ ${this.__collection.map( + item => html` + +

${item.panel}

+ `, + )} +
+ `; + } + + constructor() { + super(); + this.__collection = []; + } + + __handleAppendClick() { + const tabsElement = this.shadowRoot.querySelector('#appendTabs'); + const c = 2; + const n = Math.floor(tabsElement.children.length / 2); + for (let i = n + 1; i < n + c; i += 1) { + const tab = document.createElement('button'); + tab.setAttribute('slot', 'tab'); + tab.innerText = `tab ${i}`; + const panel = document.createElement('p'); + panel.setAttribute('slot', 'panel'); + panel.innerText = `panel ${i}`; + tabsElement.append(tab); + tabsElement.append(panel); + } + } + + __handlePushClick() { + const tabsElement = this.shadowRoot.querySelector('#pushTabs'); + const i = Math.floor(tabsElement.children.length / 2) + 1; + this.__collection = [ + ...this.__collection, + { + button: `tab ${i}`, + panel: `panel ${i}`, + }, + ]; + } + }, + ); + } + return html` + + `; + }); diff --git a/packages/tabs/test/lion-tabs.test.js b/packages/tabs/test/lion-tabs.test.js new file mode 100644 index 000000000..bbf383828 --- /dev/null +++ b/packages/tabs/test/lion-tabs.test.js @@ -0,0 +1,333 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import sinon from 'sinon'; + +import '../lion-tabs.js'; + +const basicTabs = html` + + +
panel 1
+ +
panel 2
+ +
panel 3
+
+`; + +describe('', () => { + describe('Tabs', () => { + it('sets selectedIndex to 0 by default', async () => { + const el = await fixture(basicTabs); + expect(el.selectedIndex).to.equal(0); + }); + + it('can programmatically set selectedIndex', async () => { + const el = await fixture(html` + +
tab 1
+
panel 1
+
tab 2
+
panel 2
+
+ `); + expect(el.selectedIndex).to.equal(1); + expect(el.querySelector('[slot=tab][selected]').textContent).to.equal('tab 2'); + + el.selectedIndex = 0; + expect(el.querySelector('[slot=tab][selected]').textContent).to.equal('tab 1'); + }); + + it('has [selected] on current selected tab which serves as styling hook', async () => { + const el = await fixture(basicTabs); + const tabs = el.querySelectorAll('[slot=tab]'); + el.selectedIndex = 0; + expect(tabs[0]).to.have.attribute('selected'); + expect(tabs[1]).to.not.have.attribute('selected'); + + el.selectedIndex = 1; + expect(tabs[0]).to.not.have.attribute('selected'); + expect(tabs[1]).to.have.attribute('selected'); + }); + + it('sends event "selected-changed" for every selected state change', async () => { + const el = await fixture(basicTabs); + const spy = sinon.spy(); + el.addEventListener('selected-changed', spy); + el.selectedIndex = 1; + expect(spy).to.have.been.calledOnce; + }); + + it('throws warning if unequal amount of tabs and panels', async () => { + const spy = sinon.spy(console, 'warn'); + await fixture(html` + + +
panel 1
+
panel 2
+
+ `); + expect(spy.callCount).to.equal(1); + console.warn.restore(); + }); + }); + + describe('Tabs ([slot=tab])', () => { + it('adds role=tab', async () => { + const el = await fixture(html` + + +
panel
+
+ `); + expect(el.querySelector('[slot=tab]')).to.have.attribute('role', 'tab'); + }); + + /** + * Not in scope: + * - has flexible html that allows animations like the material design underline + */ + }); + + describe('Tab Panels (slot=panel)', () => { + it('are visible when corresponding tab is selected ', async () => { + const el = await fixture(basicTabs); + const panels = el.querySelectorAll('[slot=panel]'); + el.selectedIndex = 0; + expect(panels[0]).to.be.visible; + expect(panels[1]).to.be.not.visible; + + el.selectedIndex = 1; + expect(panels[0]).to.be.not.visible; + expect(panels[1]).to.be.visible; + }); + + it.skip('have a DOM structure that allows them to be animated ', async () => {}); + }); + + /** + * We will immediately switch content as all our content comes from light dom. + * + * See Note at https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-19 + * > It is recommended that tabs activate automatically when they receive focus as long as their + * > associated tab panels are displayed without noticeable latency. This typically requires tab + * > panel content to be preloaded. + */ + describe('User interaction', () => { + it('selects a tab on click', async () => { + const el = await fixture(basicTabs); + const tabs = el.querySelectorAll('[slot=tab]'); + tabs[1].dispatchEvent(new Event('click')); + expect(el.selectedIndex).to.equal(1); + }); + + it('selects next tab on [arrow-right] and [arrow-down]', async () => { + const el = await fixture(basicTabs); + const tabs = el.querySelectorAll('[slot=tab]'); + tabs[0].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' })); + expect(el.selectedIndex).to.equal(1); + tabs[1].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); + expect(el.selectedIndex).to.equal(2); + }); + + it('selects previous tab on [arrow-left] and [arrow-up]', async () => { + const el = await fixture(html` + + +
panel 1
+ +
panel 2
+ +
panel 3
+
+ `); + const tabs = el.querySelectorAll('[slot=tab]'); + el.selectedIndex = 2; + tabs[2].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft' })); + expect(el.selectedIndex).to.equal(1); + tabs[1].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowUp' })); + expect(el.selectedIndex).to.equal(0); + }); + + it('selects first tab on [home]', async () => { + const el = await fixture(html` + + +
panel 1
+ +
panel 2
+
+ `); + const tabs = el.querySelectorAll('[slot=tab]'); + tabs[1].dispatchEvent(new KeyboardEvent('keyup', { key: 'Home' })); + expect(el.selectedIndex).to.equal(0); + }); + + it('selects last tab on [end]', async () => { + const el = await fixture(basicTabs); + const tabs = el.querySelectorAll('[slot=tab]'); + tabs[0].dispatchEvent(new KeyboardEvent('keyup', { key: 'End' })); + expect(el.selectedIndex).to.equal(2); + }); + + it('selects first tab on [arrow-right] if on last tab', async () => { + const el = await fixture(html` + + +
panel 1
+ +
panel 2
+ +
panel 3
+
+ `); + const tabs = el.querySelectorAll('[slot=tab]'); + tabs[2].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' })); + expect(el.selectedIndex).to.equal(0); + }); + + it('selects last tab on [arrow-left] if on first tab', async () => { + const el = await fixture(html` + + +
panel 1
+ +
panel 2
+ +
panel 3
+
+ `); + const tabs = el.querySelectorAll('[slot=tab]'); + tabs[0].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft' })); + expect(el.selectedIndex).to.equal(2); + }); + }); + + describe('Content distribution', () => { + it('should work with append children', async () => { + const el = await fixture(basicTabs); + const c = 2; + const n = el.children.length / 2; + for (let i = n + 1; i < n + c + 1; i += 1) { + const tab = document.createElement('button'); + tab.setAttribute('slot', 'tab'); + tab.innerText = `tab ${i}`; + const panel = document.createElement('panel'); + panel.setAttribute('slot', 'panel'); + panel.innerText = `panel ${i}`; + el.append(tab); + el.append(panel); + } + el.selectedIndex = el.children.length / 2 - 1; + await el.updateComplete; + expect(el.querySelector('[slot=tab][selected]').textContent).to.equal('tab 5'); + expect(el.querySelector('[slot=panel][selected]').textContent).to.equal('panel 5'); + }); + }); + + describe('Accessibility', () => { + it('does not make panels focusable', async () => { + const el = await fixture(html` + + +
panel 1
+ +
panel 2
+
+ `); + expect(el.querySelector('[slot=panel]')).to.not.have.attribute('tabindex'); + expect(el.querySelector('[slot=panel]')).to.not.have.attribute('tabindex'); + }); + + it('makes selected tab focusable (other tabs are unfocusable)', async () => { + const el = await fixture(html` + + +
panel 1
+ +
panel 2
+ +
panel 3
+
+ `); + const tabs = el.querySelectorAll('[slot=tab]'); + expect(tabs[0]).to.have.attribute('tabindex', '0'); + expect(tabs[1]).to.have.attribute('tabindex', '-1'); + expect(tabs[2]).to.have.attribute('tabindex', '-1'); + }); + + describe('Tabs', () => { + it('links ids of content items to tab via [aria-controls]', async () => { + const el = await fixture(html` + + +
panel 1
+ +
panel 2
+
+ `); + const tabs = el.querySelectorAll('[slot=tab]'); + const panels = el.querySelectorAll('[slot=panel]'); + expect(tabs[0].getAttribute('aria-controls')).to.equal(panels[0].id); + expect(tabs[1].getAttribute('aria-controls')).to.equal(panels[1].id); + }); + + it('adds aria-selected=“true” to selected tab', async () => { + const el = await fixture(html` + + +
panel 1
+ +
panel 2
+ +
panel 3
+
+ `); + + const tabs = el.querySelectorAll('[slot=tab]'); + expect(tabs[0].getAttribute('aria-selected')).to.equal('true'); + expect(tabs[1].getAttribute('aria-selected')).to.equal('false'); + expect(tabs[2].getAttribute('aria-selected')).to.equal('false'); + }); + }); + + describe('panels', () => { + it('adds role="tabpanel" to panels', async () => { + const el = await fixture(html` + + +
panel 1
+ +
panel 2
+
+ `); + const panels = el.querySelectorAll('[slot=panel]'); + expect(panels[0]).to.have.attribute('role', 'tabpanel'); + expect(panels[1]).to.have.attribute('role', 'tabpanel'); + }); + + it('adds aria-labelledby referring to tab ids', async () => { + const el = await fixture(html` + + +
panel 1
+ +
panel 2
+
+ `); + const panels = el.querySelectorAll('[slot=panel]'); + const tabs = el.querySelectorAll('[slot=tab]'); + expect(panels[0]).to.have.attribute('aria-labelledby', tabs[0].id); + expect(panels[1]).to.have.attribute('aria-labelledby', tabs[1].id); + }); + }); + }); + + /** + * Not in scope: + * - allow to delete tabs + * + * For extending layer with Design system: + * - add options for alignment (justify, right, left,center) of tabs + * - add option for large tabs + */ +}); diff --git a/stories/index.stories.js b/stories/index.stories.js index 486827adc..f1aa80cce 100755 --- a/stories/index.stories.js +++ b/stories/index.stories.js @@ -1,3 +1,5 @@ +import '../packages/tabs/stories/index.stories.js'; + import '../packages/button/stories/index.stories.js'; import '../packages/input/stories/index.stories.js';