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

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/input/stories/index.stories.js';