diff --git a/.changeset/thin-rocks-chew.md b/.changeset/thin-rocks-chew.md new file mode 100644 index 000000000..43b9225a3 --- /dev/null +++ b/.changeset/thin-rocks-chew.md @@ -0,0 +1,5 @@ +--- +'@lion/accordion': minor +--- + +Added types for the accordion package. diff --git a/packages/accordion/src/LionAccordion.js b/packages/accordion/src/LionAccordion.js index 9d86f5cfa..39a256dc0 100644 --- a/packages/accordion/src/LionAccordion.js +++ b/packages/accordion/src/LionAccordion.js @@ -1,53 +1,118 @@ import { LitElement, css, html } from '@lion/core'; +/** + * @typedef {Object} StoreEntry + * @property {string} uid Unique ID for the entry + * @property {number} index index of the node + * @property {HTMLElement} invoker invoker node + * @property {HTMLElement} content content node + * @property {EventHandlerNonNull} clickHandler executed on click event + * @property {EventHandlerNonNull} keydownHandler executed on keydown event + */ + const uuid = () => Math.random().toString(36).substr(2, 10); +/** + * @param {Object} opts + * @param {HTMLElement} opts.element + * @param {string} opts.uid + * @param {number} opts.index + */ const setupContent = ({ element, uid, index }) => { - element.style.setProperty('order', index + 1); + element.style.setProperty('order', `${index + 1}`); element.setAttribute('id', `content-${uid}`); element.setAttribute('aria-labelledby', `invoker-${uid}`); }; +/** + * @param {Object} opts + * @param {HTMLElement} opts.element + * @param {string} opts.uid + * @param {number} opts.index + * @param {EventHandlerNonNull} opts.clickHandler + * @param {EventHandlerNonNull} opts.keydownHandler + */ const setupInvoker = ({ element, uid, index, clickHandler, keydownHandler }) => { - element.style.setProperty('order', index + 1); - element.firstElementChild.setAttribute('id', `invoker-${uid}`); - element.firstElementChild.setAttribute('aria-controls', `content-${uid}`); - element.firstElementChild.addEventListener('click', clickHandler); - element.firstElementChild.addEventListener('keydown', keydownHandler); + element.style.setProperty('order', `${index + 1}`); + const firstChild = element.firstElementChild; + if (firstChild) { + firstChild.setAttribute('id', `invoker-${uid}`); + firstChild.setAttribute('aria-controls', `content-${uid}`); + firstChild.addEventListener('click', clickHandler); + firstChild.addEventListener('keydown', keydownHandler); + } }; +/** + * @param {HTMLElement} element + * @param {EventHandlerNonNull} clickHandler + * @param {EventHandlerNonNull} keydownHandler + */ const cleanInvoker = (element, clickHandler, keydownHandler) => { - element.firstElementChild.removeAttribute('id'); - element.firstElementChild.removeAttribute('aria-controls'); - element.firstElementChild.removeEventListener('click', clickHandler); - element.firstElementChild.removeEventListener('keydown', keydownHandler); + const firstChild = element.firstElementChild; + if (firstChild) { + firstChild.removeAttribute('id'); + firstChild.removeAttribute('aria-controls'); + firstChild.removeEventListener('click', clickHandler); + firstChild.removeEventListener('keydown', keydownHandler); + } }; +/** + * @param {HTMLElement} element + */ const focusInvoker = element => { - element.firstElementChild.focus(); - element.firstElementChild.setAttribute('focused', true); + const firstChild = /** @type {HTMLElement|null} */ (element.firstElementChild); + if (firstChild) { + firstChild.focus(); + firstChild.setAttribute('focused', `${true}`); + } }; +/** + * @param {HTMLElement} element + */ const unfocusInvoker = element => { - element.firstElementChild.removeAttribute('focused'); + const firstChild = element.firstElementChild; + if (firstChild) { + firstChild.removeAttribute('focused'); + } }; +/** + * @param {HTMLElement} element + */ const expandInvoker = element => { - element.setAttribute('expanded', true); - element.firstElementChild.setAttribute('expanded', true); - element.firstElementChild.setAttribute('aria-expanded', true); + element.setAttribute('expanded', `${true}`); + const firstChild = element.firstElementChild; + if (firstChild) { + firstChild.setAttribute('expanded', `${true}`); + firstChild.setAttribute('aria-expanded', `${true}`); + } }; +/** + * @param {HTMLElement} element + */ const collapseInvoker = element => { element.removeAttribute('expanded'); - element.firstElementChild.removeAttribute('expanded'); - element.firstElementChild.setAttribute('aria-expanded', false); + const firstChild = element.firstElementChild; + if (firstChild) { + firstChild.removeAttribute('expanded'); + firstChild.setAttribute('aria-expanded', `${false}`); + } }; +/** + * @param {HTMLElement} element + */ const expandContent = element => { - element.setAttribute('expanded', true); + element.setAttribute('expanded', `${true}`); }; +/** + * @param {HTMLElement} element + */ const collapseContent = element => { element.removeAttribute('expanded'); }; @@ -117,31 +182,44 @@ export class LionAccordion extends LitElement { constructor() { super(); - this.focusedIndex = null; - this.expanded = []; this.styles = {}; + + /** @type {StoreEntry[]} */ + this.__store = []; + + /** @type {number} */ + this.__focusedIndex = -1; + + /** @type {number[]} */ + this.__expanded = []; } - firstUpdated() { - super.firstUpdated(); + /** @param {import('lit-element').PropertyValues } changedProperties */ + firstUpdated(changedProperties) { + super.firstUpdated(changedProperties); this.__setupSlots(); } __setupSlots() { - const invokerSlot = this.shadowRoot.querySelector('slot[name=invoker]'); + const invokerSlot = this.shadowRoot?.querySelector('slot[name=invoker]'); const handleSlotChange = () => { this.__cleanStore(); this.__setupStore(); this.__updateFocused(); this.__updateExpanded(); }; - invokerSlot.addEventListener('slotchange', handleSlotChange); + if (invokerSlot) { + invokerSlot.addEventListener('slotchange', handleSlotChange); + } } __setupStore() { - this.__store = []; - const invokers = this.querySelectorAll('[slot="invoker"]'); - const contents = this.querySelectorAll('[slot="content"]'); + const invokers = /** @type {HTMLElement[]} */ (Array.from( + this.querySelectorAll('[slot="invoker"]'), + )); + const contents = /** @type {HTMLElement[]} */ (Array.from( + this.querySelectorAll('[slot="content"]'), + )); if (invokers.length !== contents.length) { // eslint-disable-next-line no-console console.warn( @@ -152,6 +230,7 @@ export class LionAccordion extends LitElement { invokers.forEach((invoker, index) => { const uid = uuid(); const content = contents[index]; + /** @type {StoreEntry} */ const entry = { uid, index, @@ -176,8 +255,13 @@ export class LionAccordion extends LitElement { this.__store.forEach(entry => { cleanInvoker(entry.invoker, entry.clickHandler, entry.keydownHandler); }); + this.__store = []; } + /** + * + * @param {number} index + */ __createInvokerClickHandler(index) { return () => { this.focusedIndex = index; @@ -185,28 +269,32 @@ export class LionAccordion extends LitElement { }; } + /** + * @param {Event} e + */ __handleInvokerKeydown(e) { - switch (e.key) { + const _e = /** @type {KeyboardEvent} */ (e); + switch (_e.key) { case 'ArrowDown': case 'ArrowRight': - e.preventDefault(); + _e.preventDefault(); if (this.focusedIndex + 2 <= this._pairCount) { this.focusedIndex += 1; } break; case 'ArrowUp': case 'ArrowLeft': - e.preventDefault(); + _e.preventDefault(); if (this.focusedIndex >= 1) { this.focusedIndex -= 1; } break; case 'Home': - e.preventDefault(); + _e.preventDefault(); this.focusedIndex = 0; break; case 'End': - e.preventDefault(); + _e.preventDefault(); this.focusedIndex = this._pairCount - 1; break; /* no default */ @@ -245,9 +333,9 @@ export class LionAccordion extends LitElement { if (!(this.__store && this.__store[this.focusedIndex])) { return; } - const previousInvoker = Array.from(this.children).find( - child => child.slot === 'invoker' && child.firstElementChild.hasAttribute('focused'), - ); + const previousInvoker = /** @type {HTMLElement | null} */ (Array.from(this.children).find( + child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'), + )); if (previousInvoker) { unfocusInvoker(previousInvoker); } @@ -274,6 +362,9 @@ export class LionAccordion extends LitElement { }); } + /** + * @param {number} value + */ __toggleExpanded(value) { const { expanded } = this; const index = expanded.indexOf(value); diff --git a/packages/accordion/test/lion-accordion.test.js b/packages/accordion/test/lion-accordion.test.js index 70eff22e9..c77a0351a 100644 --- a/packages/accordion/test/lion-accordion.test.js +++ b/packages/accordion/test/lion-accordion.test.js @@ -2,6 +2,9 @@ import { expect, fixture, html } from '@open-wc/testing'; import sinon from 'sinon'; import '../lion-accordion.js'; +/** + * @typedef {import('../src/LionAccordion.js').LionAccordion} LionAccordion + */ const basicAccordion = html` @@ -17,36 +20,36 @@ const basicAccordion = html` describe('', () => { describe('Accordion', () => { it('sets expanded to [] by default', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); expect(el.expanded).to.deep.equal([]); }); it('can programmatically set expanded', async () => { - const el = await fixture(html` + const el = /** @type {LionAccordion} */ (await fixture(html`

content 1

content 2
- `); + `)); expect(el.expanded).to.deep.equal([1]); expect( Array.from(el.children).find( child => child.slot === 'invoker' && child.hasAttribute('expanded'), - ).textContent, + )?.textContent, ).to.equal('invoker 2'); el.expanded = [0]; expect( Array.from(el.children).find( child => child.slot === 'invoker' && child.hasAttribute('expanded'), - ).textContent, + )?.textContent, ).to.equal('invoker 1'); }); it('has [expanded] on current expanded invoker which serves as styling hook', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = el.querySelectorAll('[slot=invoker]'); el.expanded = [0]; expect(invokers[0]).to.have.attribute('expanded'); @@ -58,7 +61,7 @@ describe('', () => { }); it('has [expanded] on current expanded invoker first child which serves as styling hook', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = el.querySelectorAll('[slot=invoker]'); el.expanded = [0]; expect(invokers[0].firstElementChild).to.have.attribute('expanded'); @@ -70,7 +73,7 @@ describe('', () => { }); it('sends event "expanded-changed" for every expanded state change', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const spy = sinon.spy(); el.addEventListener('expanded-changed', spy); el.expanded = [1]; @@ -95,44 +98,44 @@ describe('', () => { describe('Accordion navigation', () => { it('sets focusedIndex to null by default', async () => { - const el = await fixture(basicAccordion); - expect(el.focusedIndex).to.be.null; + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); + expect(el.focusedIndex).to.equal(-1); }); it('can programmatically set focusedIndex', async () => { - const el = await fixture(html` + const el = /** @type {LionAccordion} */ (await fixture(html`

content 1

content 2
- `); + `)); expect(el.focusedIndex).to.equal(1); expect( Array.from(el.children).find( - child => child.slot === 'invoker' && child.firstElementChild.hasAttribute('focused'), - ).textContent, + child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'), + )?.textContent, ).to.equal('invoker 2'); el.focusedIndex = 0; expect( Array.from(el.children).find( - child => child.slot === 'invoker' && child.firstElementChild.hasAttribute('focused'), - ).textContent, + child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'), + )?.textContent, ).to.equal('invoker 1'); }); it('has [focused] on current focused invoker first child which serves as styling hook', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = el.querySelectorAll('[slot=invoker]'); - el.focusedIndex = [0]; + el.focusedIndex = 0; expect(invokers[0]).to.not.have.attribute('focused'); expect(invokers[1]).to.not.have.attribute('focused'); expect(invokers[0].firstElementChild).to.have.attribute('focused'); expect(invokers[1].firstElementChild).to.not.have.attribute('focused'); - el.focusedIndex = [1]; + el.focusedIndex = 1; expect(invokers[0]).to.not.have.attribute('focused'); expect(invokers[1]).to.not.have.attribute('focused'); expect(invokers[0].firstElementChild).to.not.have.attribute('focused'); @@ -140,7 +143,7 @@ describe('', () => { }); it('sends event "focused-changed" for every focused state change', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const spy = sinon.spy(); el.addEventListener('focused-changed', spy); el.focusedIndex = 1; @@ -150,7 +153,7 @@ describe('', () => { describe('Accordion Contents (slot=content)', () => { it('are visible when corresponding invoker is expanded', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const contents = el.querySelectorAll('[slot=content]'); el.expanded = [0]; expect(contents[0]).to.be.visible; @@ -174,44 +177,44 @@ describe('', () => { */ describe('User interaction', () => { it('opens a invoker on click', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = el.querySelectorAll('[slot=invoker]'); - invokers[1].firstElementChild.dispatchEvent(new Event('click')); + invokers[1].firstElementChild?.dispatchEvent(new Event('click')); expect(el.expanded).to.deep.equal([1]); }); it('selects a invoker on click', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = el.querySelectorAll('[slot=invoker]'); - invokers[1].firstElementChild.dispatchEvent(new Event('click')); + invokers[1].firstElementChild?.dispatchEvent(new Event('click')); expect(el.focusedIndex).to.equal(1); }); it.skip('opens/close invoker on [enter] and [space]', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = el.querySelectorAll('[slot=invoker]'); - invokers[0].firstElementChild.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + invokers[0].firstElementChild?.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); expect(el.expanded).to.deep.equal([0]); - invokers[0].firstElementChild.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); + invokers[0].firstElementChild?.dispatchEvent(new KeyboardEvent('keyup', { key: ' ' })); expect(el.expanded).to.deep.equal([]); }); it('selects next invoker on [arrow-right] and [arrow-down]', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = el.querySelectorAll('[slot=invoker]'); el.focusedIndex = 0; - invokers[0].firstElementChild.dispatchEvent( + invokers[0].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowRight' }), ); expect(el.focusedIndex).to.equal(1); - invokers[0].firstElementChild.dispatchEvent( + invokers[0].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowDown' }), ); expect(el.focusedIndex).to.equal(2); }); it('selects previous invoker on [arrow-left] and [arrow-up]', async () => { - const el = await fixture(html` + const el = /** @type {LionAccordion} */ (await fixture(html`

content 1
@@ -220,40 +223,42 @@ describe('', () => {

content 3
- `); + `)); const invokers = el.querySelectorAll('[slot=invoker]'); el.focusedIndex = 2; - invokers[2].firstElementChild.dispatchEvent( + invokers[2].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowLeft' }), ); expect(el.focusedIndex).to.equal(1); - invokers[1].firstElementChild.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })); + invokers[1].firstElementChild?.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowUp' }), + ); expect(el.focusedIndex).to.equal(0); }); it('selects first invoker on [home]', async () => { - const el = await fixture(html` + const el = /** @type {LionAccordion} */ (await fixture(html`

content 1

content 2
- `); + `)); const invokers = el.querySelectorAll('[slot=invoker]'); - invokers[1].firstElementChild.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' })); + invokers[1].firstElementChild?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' })); expect(el.focusedIndex).to.equal(0); }); it('selects last invoker on [end]', async () => { - const el = await fixture(basicAccordion); + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = el.querySelectorAll('[slot=invoker]'); - invokers[0].firstElementChild.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' })); + invokers[0].firstElementChild?.dispatchEvent(new KeyboardEvent('keydown', { key: 'End' })); expect(el.focusedIndex).to.equal(2); }); it('stays on last invoker on [arrow-right]', async () => { - const el = await fixture(html` + const el = /** @type {LionAccordion} */ (await fixture(html`

content 1
@@ -262,16 +267,16 @@ describe('', () => {

content 3
- `); + `)); const invokers = el.querySelectorAll('[slot=invoker]'); - invokers[2].firstElementChild.dispatchEvent( + invokers[2].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowRight' }), ); expect(el.focusedIndex).to.equal(2); }); it('stays on first invoker on [arrow-left]', async () => { - const el = await fixture(html` + const el = /** @type {LionAccordion} */ (await fixture(html`

content 1
@@ -280,21 +285,19 @@ describe('', () => {

content 3
- `); + `)); const invokers = el.querySelectorAll('[slot=invoker]'); - invokers[0].firstElementChild.dispatchEvent( + invokers[0].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowLeft' }), ); - expect(el.focusedIndex).to.equal(null); + expect(el.focusedIndex).to.equal(-1); }); }); describe('Content distribution', () => { it('should work with append children', async () => { - const el = await fixture(basicAccordion); - const c = 2; - const n = el.children.length / 2; - for (let i = n + 1; i < n + c + 1; i += 1) { + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); + for (let i = 4; i < 6; i += 1) { const invoker = document.createElement('h2'); const button = document.createElement('button'); invoker.setAttribute('slot', 'invoker'); @@ -306,25 +309,23 @@ describe('', () => { el.append(invoker); el.append(content); } - el.expanded = [el.children.length / 2 - 1]; + el.expanded = [4]; await el.updateComplete; expect( Array.from(el.children).find( child => child.slot === 'invoker' && child.hasAttribute('expanded'), - ).textContent, + )?.textContent, ).to.equal('invoker 5'); expect( Array.from(el.children).find( child => child.slot === 'content' && child.hasAttribute('expanded'), - ).textContent, + )?.textContent, ).to.equal('content 5'); }); it('should add order style property to each invoker and content', async () => { - const el = await fixture(basicAccordion); - const c = 2; - const n = el.children.length / 2; - for (let i = n + 1; i < n + c + 1; i += 1) { + const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); + for (let i = 4; i < 6; i += 1) { const invoker = document.createElement('h2'); const button = document.createElement('button'); invoker.setAttribute('slot', 'invoker'); @@ -336,10 +337,13 @@ describe('', () => { el.append(invoker); el.append(content); } - el.expanded = [el.children.length / 2 - 1]; await el.updateComplete; - const invokers = el.querySelectorAll('[slot=invoker]'); - const contents = el.querySelectorAll('[slot=content]'); + const invokers = /** @type {HTMLElement[]} */ (Array.from( + el.querySelectorAll('[slot=invoker]'), + )); + const contents = /** @type {HTMLElement[]} */ (Array.from( + el.querySelectorAll('[slot=content]'), + )); invokers.forEach((invoker, index) => { const content = contents[index]; expect(invoker.style.getPropertyValue('order')).to.equal(`${index + 1}`); @@ -378,10 +382,10 @@ describe('', () => { `); const invokers = el.querySelectorAll('[slot=invoker]'); const contents = el.querySelectorAll('[slot=content]'); - expect(invokers[0].firstElementChild.getAttribute('aria-controls')).to.equal( + expect(invokers[0].firstElementChild?.getAttribute('aria-controls')).to.equal( contents[0].id, ); - expect(invokers[1].firstElementChild.getAttribute('aria-controls')).to.equal( + expect(invokers[1].firstElementChild?.getAttribute('aria-controls')).to.equal( contents[1].id, ); }); @@ -394,20 +398,20 @@ describe('', () => { `); expect( - Array.from(el.children).find(child => child.slot === 'invoker').firstElementChild, + Array.from(el.children).find(child => child.slot === 'invoker')?.firstElementChild, ).to.have.attribute('aria-expanded', 'false'); }); it('adds aria-expanded="true" to invoker when its content is expanded', async () => { - const el = await fixture(html` + const el = /** @type {LionAccordion} */ (await fixture(html`

content
- `); + `)); el.expanded = [0]; expect( - Array.from(el.children).find(child => child.slot === 'invoker').firstElementChild, + Array.from(el.children).find(child => child.slot === 'invoker')?.firstElementChild, ).to.have.attribute('aria-expanded', 'true'); }); }); @@ -424,8 +428,8 @@ describe('', () => { `); const contents = el.querySelectorAll('[slot=content]'); const invokers = el.querySelectorAll('[slot=invoker]'); - expect(contents[0]).to.have.attribute('aria-labelledby', invokers[0].firstElementChild.id); - expect(contents[1]).to.have.attribute('aria-labelledby', invokers[1].firstElementChild.id); + expect(contents[0]).to.have.attribute('aria-labelledby', invokers[0].firstElementChild?.id); + expect(contents[1]).to.have.attribute('aria-labelledby', invokers[1].firstElementChild?.id); }); }); }); diff --git a/tsconfig.json b/tsconfig.json index c15b122da..a6837781c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "suppressImplicitAnyIndexErrors": true }, "include": [ + "packages/accordion/**/*.js", "packages/core/**/*.js", "packages/tabs/**/*.js", "packages/singleton-manager/**/*.js",