diff --git a/packages/tabs/package.json b/packages/tabs/package.json index c90c8ad55..0d892b12f 100644 --- a/packages/tabs/package.json +++ b/packages/tabs/package.json @@ -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", diff --git a/packages/tabs/src/LionTabs.js b/packages/tabs/src/LionTabs.js index d1f742e78..a55fc1c69 100644 --- a/packages/tabs/src/LionTabs.js +++ b/packages/tabs/src/LionTabs.js @@ -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() { - const tabSlot = this.shadowRoot.querySelector('slot[name=tab]'); - const handleSlotChange = () => { - this.__cleanStore(); - this.__setupStore(); - this.__updateSelected(false); - }; - tabSlot.addEventListener('slotchange', handleSlotChange); + 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); - this.__store.push(entry); + + if (this.__store) { + this.__store.push(entry); + } }); } @@ -170,44 +227,57 @@ 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) { - case 'ArrowDown': - case 'ArrowRight': - if (this.selectedIndex + 1 >= this._pairCount) { + /** + * @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) { + this._setSelectedIndexWithFocus(0); + } else { + this._setSelectedIndexWithFocus(this.selectedIndex + 1); + } + break; + case 'ArrowUp': + case 'ArrowLeft': + if (this.selectedIndex <= 0) { + this._setSelectedIndexWithFocus(this._pairCount - 1); + } else { + this._setSelectedIndexWithFocus(this.selectedIndex - 1); + } + break; + case 'Home': this._setSelectedIndexWithFocus(0); - } else { - this._setSelectedIndexWithFocus(this.selectedIndex + 1); - } - break; - case 'ArrowUp': - case 'ArrowLeft': - if (this.selectedIndex <= 0) { + break; + case 'End': this._setSelectedIndexWithFocus(this._pairCount - 1); - } else { - this._setSelectedIndexWithFocus(this.selectedIndex - 1); - } - break; - case 'Home': - this._setSelectedIndexWithFocus(0); - break; - case 'End': - this._setSelectedIndexWithFocus(this._pairCount - 1); - break; - /* no default */ + break; + /* 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); } diff --git a/packages/tabs/test/lion-tabs.test.js b/packages/tabs/test/lion-tabs.test.js index c58765612..5ed322f8e 100644 --- a/packages/tabs/test/lion-tabs.test.js +++ b/packages/tabs/test/lion-tabs.test.js @@ -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('', () => { 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`
tab 1
panel 1
tab 2
panel 2
- `); + `)); expect(el.selectedIndex).to.equal(1); - expect( - Array.from(el.children).find( - child => child.slot === 'tab' && child.hasAttribute('selected'), - ).textContent, - ).to.equal('tab 2'); + let selectedTab = /** @type {Element} */ (Array.from(el.children).find( + child => child.slot === 'tab' && child.hasAttribute('selected'), + )); + expect(selectedTab.textContent).to.equal('tab 2'); el.selectedIndex = 0; - expect( - Array.from(el.children).find( - child => child.slot === 'tab' && child.hasAttribute('selected'), - ).textContent, - ).to.equal('tab 1'); + selectedTab = /** @type {Element} */ (Array.from(el.children).find( + child => child.slot === 'tab' && child.hasAttribute('selected'), + )); + 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('', () => { }); 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('', () => { `); 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`
panel
- `); + `)); expect(Array.from(el.children).find(child => child.slot === 'tab')).to.have.attribute( 'role', 'tab', @@ -101,7 +102,7 @@ describe('', () => { 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('', () => { */ 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('', () => { }); it('selects previous tab on [arrow-left] and [arrow-up]', async () => { - const el = await fixture(html` + const el = /** @type {LionTabs} */ (await fixture(html`
panel 1
@@ -150,7 +151,7 @@ describe('', () => {
panel 3
- `); + `)); const tabs = el.querySelectorAll('[slot=tab]'); el.selectedIndex = 2; tabs[2].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft' })); @@ -160,29 +161,29 @@ describe('', () => { }); it('selects first tab on [home]', async () => { - const el = await fixture(html` + const el = /** @type {LionTabs} */ (await fixture(html`
panel 1
panel 2
- `); + `)); const tabs = el.querySelectorAll('[slot=tab]'); tabs[1].dispatchEvent(new KeyboardEvent('keyup', { key: 'Home' })); expect(el.selectedIndex).to.equal(0); }); it('selects last tab on [end]', async () => { - const el = await fixture(basicTabs); + const 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` - + const el = /** @type {LionTabs} */ (await fixture(html` +
panel 1
@@ -190,14 +191,14 @@ describe('', () => {
panel 3
- `); + `)); const tabs = el.querySelectorAll('[slot=tab]'); tabs[2].dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowRight' })); expect(el.selectedIndex).to.equal(0); }); it('selects last tab on [arrow-left] if on first tab', async () => { - const el = await fixture(html` + const el = /** @type {LionTabs} */ (await fixture(html`
panel 1
@@ -206,7 +207,7 @@ describe('', () => {
panel 3
- `); + `)); 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('', () => { 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('', () => { } el.selectedIndex = el.children.length / 2 - 1; await el.updateComplete; - expect( - Array.from(el.children).find( - child => child.slot === 'tab' && child.hasAttribute('selected'), - ).textContent, - ).to.equal('tab 5'); - expect( - Array.from(el.children).find( - child => child.slot === 'panel' && child.hasAttribute('selected'), - ).textContent, - ).to.equal('panel 5'); + const selectedTab = Array.from(el.children).find( + child => child.slot === 'tab' && child.hasAttribute('selected'), + ); + expect(selectedTab && selectedTab.textContent).to.equal('tab 5'); + + const selectedPanel = Array.from(el.children).find( + child => child.slot === 'panel' && child.hasAttribute('selected'), + ); + 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`
panel 1
panel 2
- `); + `)); el.selectedIndex = 1; expect(el.querySelector('[slot="tab"]:nth-of-type(2)') === document.activeElement).to.be @@ -260,56 +260,56 @@ describe('', () => { }); it('does not focus a tab on firstUpdate', async () => { - const el = await fixture(html` + const el = /** @type {LionTabs} */ (await fixture(html`
panel 1
panel 2
- `); + `)); 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`
panel 1
panel 2
- `); + `)); 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`
panel 1
panel 2
- `); - 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`
panel 1
panel 2
- `); + `)); expect(Array.from(el.children).find(child => child.slot === 'panel')).to.not.have.attribute( 'tabindex', ); @@ -319,7 +319,7 @@ describe('', () => { }); it('makes selected tab focusable (other tabs are unfocusable)', async () => { - const el = await fixture(html` + const el = /** @type {LionTabs} */ (await fixture(html`
panel 1
@@ -328,7 +328,7 @@ describe('', () => {
panel 3
- `); + `)); 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('', () => { 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`
panel 1
panel 2
- `); + `)); const tabs = el.querySelectorAll('[slot=tab]'); const panels = el.querySelectorAll('[slot=panel]'); expect(tabs[0].getAttribute('aria-controls')).to.equal(panels[0].id); @@ -352,7 +352,7 @@ describe('', () => { }); it('adds aria-selected=“true” to selected tab', async () => { - const el = await fixture(html` + const el = /** @type {LionTabs} */ (await fixture(html`
panel 1
@@ -361,7 +361,7 @@ describe('', () => {
panel 3
- `); + `)); const tabs = el.querySelectorAll('[slot=tab]'); expect(tabs[0].getAttribute('aria-selected')).to.equal('true'); @@ -372,28 +372,28 @@ describe('', () => { describe('panels', () => { it('adds role="tabpanel" to panels', async () => { - const el = await fixture(html` + const el = /** @type {LionTabs} */ (await fixture(html`
panel 1
panel 2
- `); + `)); const panels = el.querySelectorAll('[slot=panel]'); expect(panels[0]).to.have.attribute('role', 'tabpanel'); expect(panels[1]).to.have.attribute('role', 'tabpanel'); }); it('adds aria-labelledby referring to tab ids', async () => { - const el = await fixture(html` + const el = /** @type {LionTabs} */ (await fixture(html`
panel 1
panel 2
- `); + `)); const panels = el.querySelectorAll('[slot=panel]'); const tabs = el.querySelectorAll('[slot=tab]'); expect(panels[0]).to.have.attribute('aria-labelledby', tabs[0].id);