feat(tabs): create tabs component

Co-authored-by: CubLion <Alex.Ghiu@ing.com>
Co-authored-by: erikkroes <Erik.Kroes@ing.com>
This commit is contained in:
qa46hx 2019-10-23 17:38:14 +02:00 committed by CubLion
parent b3b1abe200
commit 7a562a63a3
10 changed files with 871 additions and 31 deletions

View file

@ -29,37 +29,38 @@ npm i @lion/<package-name>
The accessibility column indicates whether the functionality is accessible in its core. Aspects like styling and content determine actual accessibility in usage. 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 | | 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 | | [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 | | [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 | | [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] | | [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 | ✔️ | | [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] | | [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] | | [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 | | [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 --** | | | | [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 |
| [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 | ✔️ | | **-- Forms --** | | |
| [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] | | [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 | ✔️ |
| [fieldset](./packages/fieldset) | [![fieldset](https://img.shields.io/npm/v/@lion/fieldset.svg)](https://www.npmjs.com/package/@lion/fieldset) | Group for form inputs | ✔️ | | [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] |
| [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 | | [fieldset](./packages/fieldset) | [![fieldset](https://img.shields.io/npm/v/@lion/fieldset.svg)](https://www.npmjs.com/package/@lion/fieldset) | Group for form inputs | ✔️ |
| [checkbox](./packages/checkbox) | [![checkbox](https://img.shields.io/npm/v/@lion/checkbox.svg)](https://www.npmjs.com/package/@lion/checkbox) | Checkbox form element | ✔️ | | [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-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 | ✔️ | | [checkbox](./packages/checkbox) | [![checkbox](https://img.shields.io/npm/v/@lion/checkbox.svg)](https://www.npmjs.com/package/@lion/checkbox) | Checkbox form element | ✔️ |
| [input](./packages/input) | [![input](https://img.shields.io/npm/v/@lion/input.svg)](https://www.npmjs.com/package/@lion/input) | Input element for strings | ✔️ | | [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-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](./packages/input) | [![input](https://img.shields.io/npm/v/@lion/input.svg)](https://www.npmjs.com/package/@lion/input) | Input element for strings | ✔️ |
| [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-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-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-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-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-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-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] | | [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] |
| [radio](./packages/radio) | [![radio](https://img.shields.io/npm/v/@lion/radio.svg)](https://www.npmjs.com/package/@lion/radio) | Radio from element | ✔️ | | [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-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 | ✔️ | | [radio](./packages/radio) | [![radio](https://img.shields.io/npm/v/@lion/radio.svg)](https://www.npmjs.com/package/@lion/radio) | Radio from element | ✔️ |
| [select](./packages/select) | [![select](https://img.shields.io/npm/v/@lion/select.svg)](https://www.npmjs.com/package/@lion/select) | Simple native dropdown 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 | ✔️ |
| [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] | | [select](./packages/select) | [![select](https://img.shields.io/npm/v/@lion/select.svg)](https://www.npmjs.com/package/@lion/select) | Simple native dropdown element | ✔️ |
| **-- Overlays --** | | | | | [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](./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 | ✔️ | | **-- Overlays --** | | | |
| [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] | | [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 | ✔️ |
| [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] | | [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 ## How to use

View file

@ -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.

51
packages/tabs/README.md Normal file
View file

@ -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
<lion-tabs>
<button slot="tab">Info</button>
<p slot="panel">
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.
</p>
<div slot="tab">About</div>
<p slot="panel">
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.
</p>
</lion-tabs>
```
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 <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.
- **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.

1
packages/tabs/index.js Normal file
View file

@ -0,0 +1 @@
export { LionTabs } from './src/LionTabs.js';

View file

@ -0,0 +1,3 @@
import { LionTabs } from './src/LionTabs.js';
customElements.define('lion-tabs', LionTabs);

View file

@ -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"
}
}

View file

@ -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`
<div class="tabs__tab-group" role="tablist">
<slot name="tab"></slot>
</div>
<div class="tabs__panels">
<slot name="panel"></slot>
</div>
`;
}
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);
}
}
}

View file

@ -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`
<style>
${tabsDemoStyle}
</style>
<lion-tabs>
<button slot="tab" class="demo-tabs__tab">Info</button>
<div slot="panel">
<h2>Info</h2>
<p>
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.
</p>
</div>
<button slot="tab" class="demo-tabs__tab">About</button>
<div slot="panel">
<h2>About</h2>
<p>
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.
</p>
</div>
</lion-tabs>
`,
)
.add(
'selectedIndex',
() => html`
<lion-tabs .selectedIndex=${1}>
<button slot="tab">Info</button>
<button slot="tab">About</button>
<div slot="panel">
<h2>Info</h2>
<p>
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.
</p>
</div>
<div slot="panel">
<h2>About</h2>
<p>
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.
</p>
</div>
</lion-tabs>
`,
)
.add(
'Slots order',
() => html`
<lion-tabs>
<button slot="tab">Info</button>
<button slot="tab">About</button>
<div slot="panel">
<h2>Info</h2>
<p>This is exactly the same just in the code it's differently ordered.</p>
</div>
<div slot="panel">
<h2>About</h2>
<p>
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.
</p>
</div>
</lion-tabs>
`,
)
.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`
<h3>Append</h3>
<button @click="${this.__handleAppendClick}">
Append
</button>
<lion-tabs id="appendTabs">
<button slot="tab">tab 1</button>
<p slot="panel">panel 1</p>
<button slot="tab">tab 2</button>
<p slot="panel">panel 2</p>
</lion-tabs>
<hr />
<h3>Push</h3>
<button @click="${this.__handlePushClick}">
Push
</button>
<lion-tabs id="pushTabs">
<button slot="tab">tab 1</button>
<p slot="panel">panel 1</p>
<button slot="tab">tab 2</button>
<p slot="panel">panel 2</p>
${this.__collection.map(
item => html`
<button slot="tab">${item.button}</button>
<p slot="panel">${item.panel}</p>
`,
)}
</lion-tabs>
`;
}
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`
<lion-tabs-experimental></lion-tabs-experimental>
`;
});

View file

@ -0,0 +1,333 @@
import { expect, fixture, html } from '@open-wc/testing';
import sinon from 'sinon';
import '../lion-tabs.js';
const basicTabs = html`
<lion-tabs>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab">tab 2</button>
<div slot="panel">panel 2</div>
<button slot="tab">tab 3</button>
<div slot="panel">panel 3</div>
</lion-tabs>
`;
describe('<lion-tabs>', () => {
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`
<lion-tabs .selectedIndex=${1}>
<div slot="tab">tab 1</div>
<div slot="panel">panel 1</div>
<div slot="tab">tab 2</div>
<div slot="panel">panel 2</div>
</lion-tabs>
`);
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`
<lion-tabs>
<button slot="tab">tab</button>
<div slot="panel">panel 1</div>
<div slot="panel">panel 2</div>
</lion-tabs>
`);
expect(spy.callCount).to.equal(1);
console.warn.restore();
});
});
describe('Tabs ([slot=tab])', () => {
it('adds role=tab', async () => {
const el = await fixture(html`
<lion-tabs>
<button slot="tab">tab</button>
<div slot="panel">panel</div>
</lion-tabs>
`);
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`
<lion-tabs .selectedIndex=${1}>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab">tab 2</button>
<div slot="panel">panel 2</div>
<button slot="tab">tab 3</button>
<div slot="panel">panel 3</div>
</lion-tabs>
`);
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`
<lion-tabs .selectedIndex=${1}>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab">tab 2</button>
<div slot="panel">panel 2</div>
</lion-tabs>
`);
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`
<lion-tabs selectedIndex="2">
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab">tab 2</button>
<div slot="panel">panel 2</div>
<button slot="tab">tab 3</button>
<div slot="panel">panel 3</div>
</lion-tabs>
`);
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`
<lion-tabs>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab">tab 2</button>
<div slot="panel">panel 2</div>
<button slot="tab">tab 3</button>
<div slot="panel">panel 3</div>
</lion-tabs>
`);
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`
<lion-tabs>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab">tab 2</button>
<div slot="panel">panel 2</div>
</lion-tabs>
`);
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`
<lion-tabs>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab">tab 2</button>
<div slot="panel">panel 2</div>
<button slot="tab">tab 3</button>
<div slot="panel">panel 3</div>
</lion-tabs>
`);
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`
<lion-tabs>
<button id="t1" slot="tab">tab 1</button>
<div id="p1" slot="panel">panel 1</div>
<button id="t2" slot="tab">tab 2</button>
<div id="p2" slot="panel">panel 2</div>
</lion-tabs>
`);
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`
<lion-tabs>
<button id="t1" slot="tab">tab 1</button>
<div id="p1" slot="panel">panel 1</div>
<button id="t2" slot="tab">tab 2</button>
<div id="p2" slot="panel">panel 2</div>
<button id="t3" slot="tab">tab 3</button>
<div id="p3" slot="panel">panel 3</div>
</lion-tabs>
`);
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`
<lion-tabs>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab">tab 2</button>
<div slot="panel">panel 2</div>
</lion-tabs>
`);
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`
<lion-tabs>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
<button slot="tab">tab 2</button>
<div slot="panel">panel 2</div>
</lion-tabs>
`);
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
*/
});

View file

@ -1,3 +1,5 @@
import '../packages/tabs/stories/index.stories.js';
import '../packages/button/stories/index.stories.js'; import '../packages/button/stories/index.stories.js';
import '../packages/input/stories/index.stories.js'; import '../packages/input/stories/index.stories.js';