feat(tabs): fix types and export type definition files for tabs

This commit is contained in:
Joren Broekema 2020-07-13 11:30:55 +02:00
parent ec65da5da6
commit 0cf8a0c921
3 changed files with 238 additions and 157 deletions

View file

@ -13,11 +13,14 @@
"main": "index.js",
"module": "index.js",
"files": [
"*.d.ts",
"*.js",
"docs",
"src",
"test",
"translations"
"test-helpers",
"translations",
"types"
],
"scripts": {
"prepublishOnly": "../../scripts/npm-prepublish.js",

View file

@ -1,77 +1,114 @@
import { css, html, LitElement } from '@lion/core';
const uuid = () => Math.random().toString(36).substr(2, 10);
/**
* @typedef {Object} StoreEntry
* @property {HTMLElement} el Dom Element
* @property {string} uid Unique ID for the entry
* @property {HTMLElement} button Button HTMLElement for the entry
* @property {HTMLElement} panel Panel HTMLElement for the entry
* @property {EventHandlerNonNull} clickHandler executed on click event
* @property {EventHandlerNonNull} keydownHandler executed on keydown event
* @property {EventHandlerNonNull} keyupHandler executed on keyup event
*/
const setupPanel = ({ element, uid }) => {
element.setAttribute('id', `panel-${uid}`);
element.setAttribute('role', 'tabpanel');
element.setAttribute('aria-labelledby', `button-${uid}`);
};
function uuid() {
return Math.random().toString(36).substr(2, 10);
}
const selectPanel = element => {
element.setAttribute('selected', true);
};
/**
* @param {StoreEntry} options
*/
function setupPanel({ el, uid }) {
el.setAttribute('id', `panel-${uid}`);
el.setAttribute('role', 'tabpanel');
el.setAttribute('aria-labelledby', `button-${uid}`);
}
const deselectPanel = element => {
element.removeAttribute('selected');
};
/**
* @param {HTMLElement} el
*/
function selectPanel(el) {
el.setAttribute('selected', 'true');
}
const setupButton = ({ element, uid, clickHandler, keydownHandler, keyupHandler }) => {
element.setAttribute('id', `button-${uid}`);
element.setAttribute('role', 'tab');
element.setAttribute('aria-controls', `panel-${uid}`);
element.addEventListener('click', clickHandler);
element.addEventListener('keyup', keyupHandler);
element.addEventListener('keydown', keydownHandler);
};
/**
* @param {HTMLElement} el
*/
function deselectPanel(el) {
el.removeAttribute('selected');
}
const cleanButton = ({ element, clickHandler, keydownHandler, keyupHandler }) => {
element.removeAttribute('id');
element.removeAttribute('role');
element.removeAttribute('aria-controls');
element.removeEventListener('click', clickHandler);
element.removeEventListener('keyup', keyupHandler);
element.removeEventListener('keydown', keydownHandler);
};
/**
* @param {StoreEntry} options
*/
function setupButton({ el, uid, clickHandler, keydownHandler, keyupHandler }) {
el.setAttribute('id', `button-${uid}`);
el.setAttribute('role', 'tab');
el.setAttribute('aria-controls', `panel-${uid}`);
el.addEventListener('click', clickHandler);
el.addEventListener('keyup', keyupHandler);
el.addEventListener('keydown', keydownHandler);
}
const selectButton = (element, withFocus = false) => {
/**
* @param {StoreEntry} options
*/
function cleanButton({ el, clickHandler, keydownHandler, keyupHandler }) {
el.removeAttribute('id');
el.removeAttribute('role');
el.removeAttribute('aria-controls');
el.removeEventListener('click', clickHandler);
el.removeEventListener('keyup', keyupHandler);
el.removeEventListener('keydown', keydownHandler);
}
/**
* @param {HTMLElement} el
* @param {boolean} withFocus
*/
function selectButton(el, withFocus = false) {
if (withFocus) {
element.focus();
el.focus();
}
element.setAttribute('selected', true);
element.setAttribute('aria-selected', true);
element.setAttribute('tabindex', 0);
};
el.setAttribute('selected', 'true');
el.setAttribute('aria-selected', 'true');
el.setAttribute('tabindex', '0');
}
const deselectButton = element => {
element.removeAttribute('selected');
element.setAttribute('aria-selected', false);
element.setAttribute('tabindex', -1);
};
/**
* @param {HTMLElement} el
*/
function deselectButton(el) {
el.removeAttribute('selected');
el.setAttribute('aria-selected', 'false');
el.setAttribute('tabindex', '-1');
}
const handleButtonKeydown = e => {
switch (e.key) {
/**
* @param {Event} ev
*/
function handleButtonKeydown(ev) {
const _ev = /** @type {KeyboardEvent} */ (ev);
switch (_ev.key) {
case 'ArrowDown':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowLeft':
case 'Home':
case 'End':
e.preventDefault();
_ev.preventDefault();
/* no default */
}
};
}
export class LionTabs extends LitElement {
static get properties() {
return {
/**
* index number of the selected tab.
*/
selectedIndex: {
type: Number,
value: 0,
attribute: 'selected-index',
reflect: true,
},
};
}
@ -117,28 +154,42 @@ export class LionTabs extends LitElement {
constructor() {
super();
/**
* An index number of the selected tab
*/
this.selectedIndex = 0;
}
firstUpdated() {
super.firstUpdated();
/** @param {import('lit-element').PropertyValues } changedProperties */
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__setupSlots();
}
__setupSlots() {
if (this.shadowRoot) {
const tabSlot = this.shadowRoot.querySelector('slot[name=tab]');
const handleSlotChange = () => {
this.__cleanStore();
this.__setupStore();
this.__updateSelected(false);
};
if (tabSlot) {
tabSlot.addEventListener('slotchange', handleSlotChange);
}
}
}
__setupStore() {
/** @type {StoreEntry[]} */
this.__store = [];
const buttons = this.querySelectorAll('[slot="tab"]');
const panels = this.querySelectorAll('[slot="panel"]');
const buttons = /** @type {HTMLElement[]} */ (Array.from(
this.querySelectorAll('[slot="tab"]'),
));
const panels = /** @type {HTMLElement[]} */ (Array.from(
this.querySelectorAll('[slot="panel"]'),
));
if (buttons.length !== panels.length) {
// eslint-disable-next-line no-console
console.warn(
@ -149,19 +200,25 @@ export class LionTabs extends LitElement {
buttons.forEach((button, index) => {
const uid = uuid();
const panel = panels[index];
/** @type {StoreEntry} */
const entry = {
uid,
el: button,
button,
panel,
clickHandler: this.__createButtonClickHandler(index),
keydownHandler: handleButtonKeydown,
keydownHandler: handleButtonKeydown.bind(this),
keyupHandler: this.__handleButtonKeyup.bind(this),
};
setupPanel({ element: entry.panel, ...entry });
setupButton({ element: entry.button, ...entry });
setupPanel({ ...entry, el: entry.panel });
setupButton(entry);
deselectPanel(entry.panel);
deselectButton(entry.button);
if (this.__store) {
this.__store.push(entry);
}
});
}
@ -170,18 +227,27 @@ export class LionTabs extends LitElement {
return;
}
this.__store.forEach(entry => {
cleanButton({ element: entry.button, ...entry });
cleanButton(entry);
});
}
/**
* @param {number} index
* @returns {EventHandlerNonNull}
*/
__createButtonClickHandler(index) {
return () => {
this._setSelectedIndexWithFocus(index);
};
}
__handleButtonKeyup(e) {
switch (e.key) {
/**
* @param {Event} ev
*/
__handleButtonKeyup(ev) {
const _ev = /** @type {KeyboardEvent} */ (ev);
if (typeof this.selectedIndex === 'number') {
switch (_ev.key) {
case 'ArrowDown':
case 'ArrowRight':
if (this.selectedIndex + 1 >= this._pairCount) {
@ -207,7 +273,11 @@ export class LionTabs extends LitElement {
/* no default */
}
}
}
/**
* @param {number} value The new index
*/
set selectedIndex(value) {
const stale = this.__selectedIndex;
this.__selectedIndex = value;
@ -216,6 +286,9 @@ export class LionTabs extends LitElement {
this.requestUpdate('selectedIndex', stale);
}
/**
* @param {number} value The new index for focus
*/
_setSelectedIndexWithFocus(value) {
const stale = this.__selectedIndex;
this.__selectedIndex = value;
@ -224,24 +297,29 @@ export class LionTabs extends LitElement {
this.requestUpdate('selectedIndex', stale);
}
/**
* @return {number}
*/
get selectedIndex() {
return this.__selectedIndex;
return this.__selectedIndex || 0;
}
get _pairCount() {
return this.__store.length;
return (this.__store && this.__store.length) || 0;
}
__updateSelected(withFocus = false) {
if (!(this.__store && this.__store[this.selectedIndex])) {
if (
!(this.__store && typeof this.selectedIndex === 'number' && this.__store[this.selectedIndex])
) {
return;
}
const previousButton = Array.from(this.children).find(
const previousButton = /** @type {HTMLElement} */ (Array.from(this.children).find(
child => child.slot === 'tab' && child.hasAttribute('selected'),
);
const previousPanel = Array.from(this.children).find(
));
const previousPanel = /** @type {HTMLElement} */ (Array.from(this.children).find(
child => child.slot === 'panel' && child.hasAttribute('selected'),
);
));
if (previousButton) {
deselectButton(previousButton);
}

View file

@ -1,6 +1,9 @@
import { expect, fixture, html } from '@open-wc/testing';
import sinon from 'sinon';
/**
* @typedef {import('../src/LionTabs.js').LionTabs} LionTabs
*/
import '../lion-tabs.js';
const basicTabs = html`
@ -17,36 +20,34 @@ const basicTabs = html`
describe('<lion-tabs>', () => {
describe('Tabs', () => {
it('sets selectedIndex to 0 by default', async () => {
const el = await fixture(basicTabs);
const el = /** @type {LionTabs} */ (await fixture(basicTabs));
expect(el.selectedIndex).to.equal(0);
});
it('can programmatically set selectedIndex', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (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(
Array.from(el.children).find(
let selectedTab = /** @type {Element} */ (Array.from(el.children).find(
child => child.slot === 'tab' && child.hasAttribute('selected'),
).textContent,
).to.equal('tab 2');
));
expect(selectedTab.textContent).to.equal('tab 2');
el.selectedIndex = 0;
expect(
Array.from(el.children).find(
selectedTab = /** @type {Element} */ (Array.from(el.children).find(
child => child.slot === 'tab' && child.hasAttribute('selected'),
).textContent,
).to.equal('tab 1');
));
expect(selectedTab.textContent).to.equal('tab 1');
});
it('has [selected] on current selected tab which serves as styling hook', async () => {
const el = await fixture(basicTabs);
const el = /** @type {LionTabs} */ (await fixture(basicTabs));
const tabs = el.querySelectorAll('[slot=tab]');
el.selectedIndex = 0;
expect(tabs[0]).to.have.attribute('selected');
@ -58,7 +59,7 @@ describe('<lion-tabs>', () => {
});
it('sends event "selected-changed" for every selected state change', async () => {
const el = await fixture(basicTabs);
const el = /** @type {LionTabs} */ (await fixture(basicTabs));
const spy = sinon.spy();
el.addEventListener('selected-changed', spy);
el.selectedIndex = 1;
@ -75,18 +76,18 @@ describe('<lion-tabs>', () => {
</lion-tabs>
`);
expect(spy.callCount).to.equal(1);
console.warn.restore();
spy.restore();
});
});
describe('Tabs ([slot=tab])', () => {
it('adds role=tab', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (await fixture(html`
<lion-tabs>
<button slot="tab">tab</button>
<div slot="panel">panel</div>
</lion-tabs>
`);
`));
expect(Array.from(el.children).find(child => child.slot === 'tab')).to.have.attribute(
'role',
'tab',
@ -101,7 +102,7 @@ describe('<lion-tabs>', () => {
describe('Tab Panels (slot=panel)', () => {
it('are visible when corresponding tab is selected ', async () => {
const el = await fixture(basicTabs);
const el = /** @type {LionTabs} */ (await fixture(basicTabs));
const panels = el.querySelectorAll('[slot=panel]');
el.selectedIndex = 0;
expect(panels[0]).to.be.visible;
@ -125,14 +126,14 @@ describe('<lion-tabs>', () => {
*/
describe('User interaction', () => {
it('selects a tab on click', async () => {
const el = await fixture(basicTabs);
const el = /** @type {LionTabs} */ (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 el = /** @type {LionTabs} */ (await fixture(basicTabs));
const tabs = el.querySelectorAll('[slot=tab]');
tabs[0].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' }));
expect(el.selectedIndex).to.equal(1);
@ -141,7 +142,7 @@ describe('<lion-tabs>', () => {
});
it('selects previous tab on [arrow-left] and [arrow-up]', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (await fixture(html`
<lion-tabs .selectedIndex=${1}>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
@ -150,7 +151,7 @@ describe('<lion-tabs>', () => {
<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' }));
@ -160,29 +161,29 @@ describe('<lion-tabs>', () => {
});
it('selects first tab on [home]', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (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 el = /** @type {LionTabs} */ (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">
const el = /** @type {LionTabs} */ (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>
@ -190,14 +191,14 @@ describe('<lion-tabs>', () => {
<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`
const el = /** @type {LionTabs} */ (await fixture(html`
<lion-tabs>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
@ -206,7 +207,7 @@ describe('<lion-tabs>', () => {
<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);
@ -215,7 +216,7 @@ describe('<lion-tabs>', () => {
describe('Content distribution', () => {
it('should work with append children', async () => {
const el = await fixture(basicTabs);
const el = /** @type {LionTabs} */ (await fixture(basicTabs));
const c = 2;
const n = el.children.length / 2;
for (let i = n + 1; i < n + c + 1; i += 1) {
@ -230,29 +231,28 @@ describe('<lion-tabs>', () => {
}
el.selectedIndex = el.children.length / 2 - 1;
await el.updateComplete;
expect(
Array.from(el.children).find(
const selectedTab = Array.from(el.children).find(
child => child.slot === 'tab' && child.hasAttribute('selected'),
).textContent,
).to.equal('tab 5');
expect(
Array.from(el.children).find(
);
expect(selectedTab && selectedTab.textContent).to.equal('tab 5');
const selectedPanel = Array.from(el.children).find(
child => child.slot === 'panel' && child.hasAttribute('selected'),
).textContent,
).to.equal('panel 5');
);
expect(selectedPanel && selectedPanel.textContent).to.equal('panel 5');
});
});
describe('Initializing without Focus', () => {
it('does not focus a tab when setting selectedIndex property', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (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>
`);
`));
el.selectedIndex = 1;
expect(el.querySelector('[slot="tab"]:nth-of-type(2)') === document.activeElement).to.be
@ -260,56 +260,56 @@ describe('<lion-tabs>', () => {
});
it('does not focus a tab on firstUpdate', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (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 tabs = Array.from(el.children).filter(child => child.slot === 'tab');
expect(tabs.some(tab => tab === document.activeElement)).to.be.false;
});
it('focuses on a tab when setting with _setSelectedIndexWithFocus method', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (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>
`);
`));
el._setSelectedIndexWithFocus(1);
expect(el.querySelector('[slot="tab"]:nth-of-type(2)') === document.activeElement).to.be.true;
});
});
it('focuses on a tab when the selected tab is changed by user interaction', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (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 secondTab = el.querySelector('[slot="tab"]:nth-of-type(2)');
`));
const secondTab = /** @type {Element} */ (el.querySelector('[slot="tab"]:nth-of-type(2)'));
secondTab.dispatchEvent(new MouseEvent('click'));
expect(secondTab === document.activeElement).to.be.true;
});
describe('Accessibility', () => {
it('does not make panels focusable', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (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(Array.from(el.children).find(child => child.slot === 'panel')).to.not.have.attribute(
'tabindex',
);
@ -319,7 +319,7 @@ describe('<lion-tabs>', () => {
});
it('makes selected tab focusable (other tabs are unfocusable)', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (await fixture(html`
<lion-tabs>
<button slot="tab">tab 1</button>
<div slot="panel">panel 1</div>
@ -328,7 +328,7 @@ describe('<lion-tabs>', () => {
<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');
@ -337,14 +337,14 @@ describe('<lion-tabs>', () => {
describe('Tabs', () => {
it('links ids of content items to tab via [aria-controls]', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (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);
@ -352,7 +352,7 @@ describe('<lion-tabs>', () => {
});
it('adds aria-selected=“true” to selected tab', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (await fixture(html`
<lion-tabs>
<button id="t1" slot="tab">tab 1</button>
<div id="p1" slot="panel">panel 1</div>
@ -361,7 +361,7 @@ describe('<lion-tabs>', () => {
<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');
@ -372,28 +372,28 @@ describe('<lion-tabs>', () => {
describe('panels', () => {
it('adds role="tabpanel" to panels', async () => {
const el = await fixture(html`
const el = /** @type {LionTabs} */ (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`
const el = /** @type {LionTabs} */ (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);