Skip to content

Commit 7a562a6

Browse files
gerjanvangeestCubLionerikkroes
committed
feat(tabs): create tabs component
Co-authored-by: CubLion <Alex.Ghiu@ing.com> Co-authored-by: erikkroes <Erik.Kroes@ing.com>
1 parent b3b1abe commit 7a562a6

File tree

10 files changed

+871
-31
lines changed

10 files changed

+871
-31
lines changed

README.md

Lines changed: 32 additions & 31 deletions
Large diffs are not rendered by default.

packages/tabs/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Change Log
2+
3+
All notable changes to this project will be documented in this file.
4+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.

packages/tabs/README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Tabs
2+
3+
`lion-tabs` implements Tabs view to allow users to quickly move between a small number of equally important views
4+
5+
## How to use
6+
7+
### Installation
8+
9+
```sh
10+
npm i --save @lion/tabs;
11+
```
12+
13+
### Usage
14+
15+
```js
16+
import '@lion/tabs/lion-tabs.js';
17+
```
18+
19+
```html
20+
<lion-tabs>
21+
<button slot="tab">Info</button>
22+
<p slot="panel">
23+
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Laboriosam sequi odit cumque, enim aut
24+
assumenda itaque quis voluptas est quos fugiat unde labore reiciendis saepe, iure, optio
25+
officiis obcaecati quibusdam.
26+
</p>
27+
<div slot="tab">About</div>
28+
<p slot="panel">
29+
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Laboriosam sequi odit cumque, enim aut
30+
assumenda itaque quis voluptas est quos fugiat unde labore reiciendis saepe, iure, optio
31+
officiis obcaecati quibusdam.
32+
</p>
33+
</lion-tabs>
34+
```
35+
36+
Rationales:
37+
38+
- **No separate active/focus state when using keyboard**
39+
40+
We will immediately switch content as all our content comes from light dom (e.g. no latency)
41+
42+
See Note at <https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-19>
43+
44+
> It is recommended that tabs activate automatically when they receive focus as long as their
45+
> associated tab panels are displayed without noticeable latency. This typically requires tab
46+
> panel content to be preloaded.
47+
48+
- **Panels are not focusable**
49+
50+
Focusable elements should have a means to interact with them. Tab panels themselves do not offer any interactiveness.
51+
If there is a button or a form inside the tab panel then these elements get focused directly.

packages/tabs/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { LionTabs } from './src/LionTabs.js';

packages/tabs/lion-tabs.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { LionTabs } from './src/LionTabs.js';
2+
3+
customElements.define('lion-tabs', LionTabs);

packages/tabs/package.json

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"name": "@lion/tabs",
3+
"version": "0.0.0",
4+
"description": "Allows users to quickly move between a small number of equally important views.",
5+
"author": "ing-bank",
6+
"homepage": "https://github.com/ing-bank/lion/",
7+
"license": "MIT",
8+
"publishConfig": {
9+
"access": "public"
10+
},
11+
"repository": {
12+
"type": "git",
13+
"url": "https://github.com/ing-bank/lion.git",
14+
"directory": "packages/tabs"
15+
},
16+
"scripts": {
17+
"prepublishOnly": "../../scripts/npm-prepublish.js"
18+
},
19+
"keywords": [
20+
"lion",
21+
"web-components",
22+
"tabs"
23+
],
24+
"main": "index.js",
25+
"module": "index.js",
26+
"files": [
27+
"docs",
28+
"src",
29+
"stories",
30+
"test",
31+
"translations",
32+
"*.js"
33+
],
34+
"dependencies": {
35+
"@lion/core": "^0.2.1"
36+
},
37+
"devDependencies": {
38+
"@open-wc/demoing-storybook": "^0.2.0",
39+
"@open-wc/testing": "^2.3.4",
40+
"sinon": "^7.2.2"
41+
}
42+
}

packages/tabs/src/LionTabs.js

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { LitElement, css, html } from '@lion/core';
2+
3+
const uuid = () =>
4+
Math.random()
5+
.toString(36)
6+
.substr(2, 10);
7+
8+
const setupPanel = ({ element, uid }) => {
9+
element.setAttribute('id', `panel-${uid}`);
10+
element.setAttribute('role', 'tabpanel');
11+
element.setAttribute('aria-labelledby', `button-${uid}`);
12+
};
13+
14+
const selectPanel = element => {
15+
element.setAttribute('selected', true);
16+
};
17+
18+
const deselectPanel = element => {
19+
element.removeAttribute('selected');
20+
};
21+
22+
const setupButton = ({ element, uid, clickHandler, keydownHandler }) => {
23+
element.setAttribute('id', `button-${uid}`);
24+
element.setAttribute('role', 'tab');
25+
element.setAttribute('aria-controls', `panel-${uid}`);
26+
element.addEventListener('click', clickHandler);
27+
element.addEventListener('keyup', keydownHandler);
28+
};
29+
30+
const cleanButton = (element, clickHandler, keydownHandler) => {
31+
element.removeAttribute('id');
32+
element.removeAttribute('role');
33+
element.removeAttribute('aria-controls');
34+
element.removeEventListener('click', clickHandler);
35+
element.removeEventListener('keyup', keydownHandler);
36+
};
37+
38+
const selectButton = element => {
39+
element.focus();
40+
element.setAttribute('selected', true);
41+
element.setAttribute('aria-selected', true);
42+
element.setAttribute('tabindex', 0);
43+
};
44+
45+
const deselectButton = element => {
46+
element.removeAttribute('selected');
47+
element.setAttribute('aria-selected', false);
48+
element.setAttribute('tabindex', -1);
49+
};
50+
51+
export class LionTabs extends LitElement {
52+
static get properties() {
53+
return {
54+
/**
55+
* index number of the selected tab
56+
*/
57+
selectedIndex: {
58+
type: Number,
59+
value: 0,
60+
},
61+
};
62+
}
63+
64+
static get styles() {
65+
return [
66+
css`
67+
.tabs__tab-group {
68+
display: flex;
69+
}
70+
71+
.tabs__tab-group ::slotted([slot='tab'][selected]) {
72+
font-weight: bold;
73+
}
74+
75+
.tabs__panels ::slotted([slot='panel']) {
76+
visibility: hidden;
77+
display: none;
78+
}
79+
80+
.tabs__panels ::slotted([slot='panel'][selected]) {
81+
visibility: visible;
82+
display: block;
83+
}
84+
85+
.tabs__panels {
86+
display: block;
87+
}
88+
`,
89+
];
90+
}
91+
92+
render() {
93+
return html`
94+
<div class="tabs__tab-group" role="tablist">
95+
<slot name="tab"></slot>
96+
</div>
97+
<div class="tabs__panels">
98+
<slot name="panel"></slot>
99+
</div>
100+
`;
101+
}
102+
103+
constructor() {
104+
super();
105+
this.selectedIndex = 0;
106+
}
107+
108+
firstUpdated() {
109+
super.firstUpdated();
110+
this.__setupSlots();
111+
}
112+
113+
__setupSlots() {
114+
const tabSlot = this.shadowRoot.querySelector('slot[name=tab]');
115+
const handleSlotChange = () => {
116+
this.__cleanStore();
117+
this.__setupStore();
118+
this.__updateSelected();
119+
};
120+
tabSlot.addEventListener('slotchange', handleSlotChange);
121+
}
122+
123+
__setupStore() {
124+
this.__store = [];
125+
const buttons = this.querySelectorAll('[slot="tab"]');
126+
const panels = this.querySelectorAll('[slot="panel"]');
127+
if (buttons.length !== panels.length) {
128+
// eslint-disable-next-line no-console
129+
console.warn(
130+
`The amount of tabs (${buttons.length}) doesn't match the amount of panels (${panels.length}).`,
131+
);
132+
}
133+
134+
buttons.forEach((button, index) => {
135+
const uid = uuid();
136+
const panel = panels[index];
137+
const entry = {
138+
uid,
139+
button,
140+
panel,
141+
clickHandler: this.__createButtonClickHandler(index),
142+
keydownHandler: this.__handleButtonKeydown.bind(this),
143+
};
144+
setupPanel({ element: entry.panel, ...entry });
145+
setupButton({ element: entry.button, ...entry });
146+
deselectPanel(entry.panel);
147+
deselectButton(entry.button);
148+
this.__store.push(entry);
149+
});
150+
}
151+
152+
__cleanStore() {
153+
if (!this.__store) {
154+
return;
155+
}
156+
this.__store.forEach(entry => {
157+
cleanButton(entry.button, entry.clickHandler, entry.keydownHandler);
158+
});
159+
}
160+
161+
__createButtonClickHandler(index) {
162+
return () => {
163+
this.selectedIndex = index;
164+
};
165+
}
166+
167+
__handleButtonKeydown(e) {
168+
switch (e.key) {
169+
case 'ArrowDown':
170+
case 'ArrowRight':
171+
e.preventDefault();
172+
if (this.selectedIndex + 1 >= this._pairCount) {
173+
this.selectedIndex = 0;
174+
} else {
175+
this.selectedIndex += 1;
176+
}
177+
break;
178+
case 'ArrowUp':
179+
case 'ArrowLeft':
180+
e.preventDefault();
181+
if (this.selectedIndex <= 0) {
182+
this.selectedIndex = this._pairCount - 1;
183+
} else {
184+
this.selectedIndex -= 1;
185+
}
186+
break;
187+
case 'Home':
188+
e.preventDefault();
189+
this.selectedIndex = 0;
190+
break;
191+
case 'End':
192+
e.preventDefault();
193+
this.selectedIndex = this._pairCount - 1;
194+
break;
195+
/* no default */
196+
}
197+
}
198+
199+
set selectedIndex(value) {
200+
const stale = this.__selectedIndex;
201+
this.__selectedIndex = value;
202+
this.__updateSelected();
203+
this.dispatchEvent(new Event('selected-changed'));
204+
this.requestUpdate('selectedIndex', stale);
205+
}
206+
207+
get selectedIndex() {
208+
return this.__selectedIndex;
209+
}
210+
211+
get _pairCount() {
212+
return this.__store.length;
213+
}
214+
215+
__updateSelected() {
216+
if (!(this.__store && this.__store[this.selectedIndex])) {
217+
return;
218+
}
219+
const previousButton = this.querySelector('[slot="tab"][selected]');
220+
const previousPanel = this.querySelector('[slot="panel"][selected]');
221+
if (previousButton) {
222+
deselectButton(previousButton);
223+
}
224+
if (previousPanel) {
225+
deselectPanel(previousPanel);
226+
}
227+
const { button: currentButton, panel: currentPanel } = this.__store[this.selectedIndex];
228+
if (currentButton) {
229+
selectButton(currentButton);
230+
}
231+
if (currentPanel) {
232+
selectPanel(currentPanel);
233+
}
234+
}
235+
}

0 commit comments

Comments
 (0)