feat(accordion): add types for accordion

This commit is contained in:
Joren Broekema 2020-09-24 18:34:39 +02:00 committed by Thomas Allmer
parent 56cc174c0b
commit 175e6bea6f
4 changed files with 206 additions and 105 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/accordion': minor
---
Added types for the accordion package.

View file

@ -1,53 +1,118 @@
import { LitElement, css, html } from '@lion/core'; 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); 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 }) => { const setupContent = ({ element, uid, index }) => {
element.style.setProperty('order', index + 1); element.style.setProperty('order', `${index + 1}`);
element.setAttribute('id', `content-${uid}`); element.setAttribute('id', `content-${uid}`);
element.setAttribute('aria-labelledby', `invoker-${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 }) => { const setupInvoker = ({ element, uid, index, clickHandler, keydownHandler }) => {
element.style.setProperty('order', index + 1); element.style.setProperty('order', `${index + 1}`);
element.firstElementChild.setAttribute('id', `invoker-${uid}`); const firstChild = element.firstElementChild;
element.firstElementChild.setAttribute('aria-controls', `content-${uid}`); if (firstChild) {
element.firstElementChild.addEventListener('click', clickHandler); firstChild.setAttribute('id', `invoker-${uid}`);
element.firstElementChild.addEventListener('keydown', keydownHandler); 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) => { const cleanInvoker = (element, clickHandler, keydownHandler) => {
element.firstElementChild.removeAttribute('id'); const firstChild = element.firstElementChild;
element.firstElementChild.removeAttribute('aria-controls'); if (firstChild) {
element.firstElementChild.removeEventListener('click', clickHandler); firstChild.removeAttribute('id');
element.firstElementChild.removeEventListener('keydown', keydownHandler); firstChild.removeAttribute('aria-controls');
firstChild.removeEventListener('click', clickHandler);
firstChild.removeEventListener('keydown', keydownHandler);
}
}; };
/**
* @param {HTMLElement} element
*/
const focusInvoker = element => { const focusInvoker = element => {
element.firstElementChild.focus(); const firstChild = /** @type {HTMLElement|null} */ (element.firstElementChild);
element.firstElementChild.setAttribute('focused', true); if (firstChild) {
firstChild.focus();
firstChild.setAttribute('focused', `${true}`);
}
}; };
/**
* @param {HTMLElement} element
*/
const unfocusInvoker = element => { const unfocusInvoker = element => {
element.firstElementChild.removeAttribute('focused'); const firstChild = element.firstElementChild;
if (firstChild) {
firstChild.removeAttribute('focused');
}
}; };
/**
* @param {HTMLElement} element
*/
const expandInvoker = element => { const expandInvoker = element => {
element.setAttribute('expanded', true); element.setAttribute('expanded', `${true}`);
element.firstElementChild.setAttribute('expanded', true); const firstChild = element.firstElementChild;
element.firstElementChild.setAttribute('aria-expanded', true); if (firstChild) {
firstChild.setAttribute('expanded', `${true}`);
firstChild.setAttribute('aria-expanded', `${true}`);
}
}; };
/**
* @param {HTMLElement} element
*/
const collapseInvoker = element => { const collapseInvoker = element => {
element.removeAttribute('expanded'); element.removeAttribute('expanded');
element.firstElementChild.removeAttribute('expanded'); const firstChild = element.firstElementChild;
element.firstElementChild.setAttribute('aria-expanded', false); if (firstChild) {
firstChild.removeAttribute('expanded');
firstChild.setAttribute('aria-expanded', `${false}`);
}
}; };
/**
* @param {HTMLElement} element
*/
const expandContent = element => { const expandContent = element => {
element.setAttribute('expanded', true); element.setAttribute('expanded', `${true}`);
}; };
/**
* @param {HTMLElement} element
*/
const collapseContent = element => { const collapseContent = element => {
element.removeAttribute('expanded'); element.removeAttribute('expanded');
}; };
@ -117,31 +182,44 @@ export class LionAccordion extends LitElement {
constructor() { constructor() {
super(); super();
this.focusedIndex = null;
this.expanded = [];
this.styles = {}; this.styles = {};
/** @type {StoreEntry[]} */
this.__store = [];
/** @type {number} */
this.__focusedIndex = -1;
/** @type {number[]} */
this.__expanded = [];
} }
firstUpdated() { /** @param {import('lit-element').PropertyValues } changedProperties */
super.firstUpdated(); firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__setupSlots(); this.__setupSlots();
} }
__setupSlots() { __setupSlots() {
const invokerSlot = this.shadowRoot.querySelector('slot[name=invoker]'); const invokerSlot = this.shadowRoot?.querySelector('slot[name=invoker]');
const handleSlotChange = () => { const handleSlotChange = () => {
this.__cleanStore(); this.__cleanStore();
this.__setupStore(); this.__setupStore();
this.__updateFocused(); this.__updateFocused();
this.__updateExpanded(); this.__updateExpanded();
}; };
invokerSlot.addEventListener('slotchange', handleSlotChange); if (invokerSlot) {
invokerSlot.addEventListener('slotchange', handleSlotChange);
}
} }
__setupStore() { __setupStore() {
this.__store = []; const invokers = /** @type {HTMLElement[]} */ (Array.from(
const invokers = this.querySelectorAll('[slot="invoker"]'); this.querySelectorAll('[slot="invoker"]'),
const contents = this.querySelectorAll('[slot="content"]'); ));
const contents = /** @type {HTMLElement[]} */ (Array.from(
this.querySelectorAll('[slot="content"]'),
));
if (invokers.length !== contents.length) { if (invokers.length !== contents.length) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn( console.warn(
@ -152,6 +230,7 @@ export class LionAccordion extends LitElement {
invokers.forEach((invoker, index) => { invokers.forEach((invoker, index) => {
const uid = uuid(); const uid = uuid();
const content = contents[index]; const content = contents[index];
/** @type {StoreEntry} */
const entry = { const entry = {
uid, uid,
index, index,
@ -176,8 +255,13 @@ export class LionAccordion extends LitElement {
this.__store.forEach(entry => { this.__store.forEach(entry => {
cleanInvoker(entry.invoker, entry.clickHandler, entry.keydownHandler); cleanInvoker(entry.invoker, entry.clickHandler, entry.keydownHandler);
}); });
this.__store = [];
} }
/**
*
* @param {number} index
*/
__createInvokerClickHandler(index) { __createInvokerClickHandler(index) {
return () => { return () => {
this.focusedIndex = index; this.focusedIndex = index;
@ -185,28 +269,32 @@ export class LionAccordion extends LitElement {
}; };
} }
/**
* @param {Event} e
*/
__handleInvokerKeydown(e) { __handleInvokerKeydown(e) {
switch (e.key) { const _e = /** @type {KeyboardEvent} */ (e);
switch (_e.key) {
case 'ArrowDown': case 'ArrowDown':
case 'ArrowRight': case 'ArrowRight':
e.preventDefault(); _e.preventDefault();
if (this.focusedIndex + 2 <= this._pairCount) { if (this.focusedIndex + 2 <= this._pairCount) {
this.focusedIndex += 1; this.focusedIndex += 1;
} }
break; break;
case 'ArrowUp': case 'ArrowUp':
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); _e.preventDefault();
if (this.focusedIndex >= 1) { if (this.focusedIndex >= 1) {
this.focusedIndex -= 1; this.focusedIndex -= 1;
} }
break; break;
case 'Home': case 'Home':
e.preventDefault(); _e.preventDefault();
this.focusedIndex = 0; this.focusedIndex = 0;
break; break;
case 'End': case 'End':
e.preventDefault(); _e.preventDefault();
this.focusedIndex = this._pairCount - 1; this.focusedIndex = this._pairCount - 1;
break; break;
/* no default */ /* no default */
@ -245,9 +333,9 @@ export class LionAccordion extends LitElement {
if (!(this.__store && this.__store[this.focusedIndex])) { if (!(this.__store && this.__store[this.focusedIndex])) {
return; return;
} }
const previousInvoker = Array.from(this.children).find( const previousInvoker = /** @type {HTMLElement | null} */ (Array.from(this.children).find(
child => child.slot === 'invoker' && child.firstElementChild.hasAttribute('focused'), child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'),
); ));
if (previousInvoker) { if (previousInvoker) {
unfocusInvoker(previousInvoker); unfocusInvoker(previousInvoker);
} }
@ -274,6 +362,9 @@ export class LionAccordion extends LitElement {
}); });
} }
/**
* @param {number} value
*/
__toggleExpanded(value) { __toggleExpanded(value) {
const { expanded } = this; const { expanded } = this;
const index = expanded.indexOf(value); const index = expanded.indexOf(value);

View file

@ -2,6 +2,9 @@ import { expect, fixture, html } from '@open-wc/testing';
import sinon from 'sinon'; import sinon from 'sinon';
import '../lion-accordion.js'; import '../lion-accordion.js';
/**
* @typedef {import('../src/LionAccordion.js').LionAccordion} LionAccordion
*/
const basicAccordion = html` const basicAccordion = html`
<lion-accordion> <lion-accordion>
@ -17,36 +20,36 @@ const basicAccordion = html`
describe('<lion-accordion>', () => { describe('<lion-accordion>', () => {
describe('Accordion', () => { describe('Accordion', () => {
it('sets expanded to [] by default', async () => { 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([]); expect(el.expanded).to.deep.equal([]);
}); });
it('can programmatically set expanded', async () => { it('can programmatically set expanded', async () => {
const el = await fixture(html` const el = /** @type {LionAccordion} */ (await fixture(html`
<lion-accordion .expanded=${[1]}> <lion-accordion .expanded=${[1]}>
<h2 slot="invoker"><button>invoker 1</button></h2> <h2 slot="invoker"><button>invoker 1</button></h2>
<div slot="content">content 1</div> <div slot="content">content 1</div>
<h2 slot="invoker"><button>invoker 2</button></h2> <h2 slot="invoker"><button>invoker 2</button></h2>
<div slot="content">content 2</div> <div slot="content">content 2</div>
</lion-accordion> </lion-accordion>
`); `));
expect(el.expanded).to.deep.equal([1]); expect(el.expanded).to.deep.equal([1]);
expect( expect(
Array.from(el.children).find( Array.from(el.children).find(
child => child.slot === 'invoker' && child.hasAttribute('expanded'), child => child.slot === 'invoker' && child.hasAttribute('expanded'),
).textContent, )?.textContent,
).to.equal('invoker 2'); ).to.equal('invoker 2');
el.expanded = [0]; el.expanded = [0];
expect( expect(
Array.from(el.children).find( Array.from(el.children).find(
child => child.slot === 'invoker' && child.hasAttribute('expanded'), child => child.slot === 'invoker' && child.hasAttribute('expanded'),
).textContent, )?.textContent,
).to.equal('invoker 1'); ).to.equal('invoker 1');
}); });
it('has [expanded] on current expanded invoker which serves as styling hook', async () => { 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]'); const invokers = el.querySelectorAll('[slot=invoker]');
el.expanded = [0]; el.expanded = [0];
expect(invokers[0]).to.have.attribute('expanded'); expect(invokers[0]).to.have.attribute('expanded');
@ -58,7 +61,7 @@ describe('<lion-accordion>', () => {
}); });
it('has [expanded] on current expanded invoker first child which serves as styling hook', async () => { 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]'); const invokers = el.querySelectorAll('[slot=invoker]');
el.expanded = [0]; el.expanded = [0];
expect(invokers[0].firstElementChild).to.have.attribute('expanded'); expect(invokers[0].firstElementChild).to.have.attribute('expanded');
@ -70,7 +73,7 @@ describe('<lion-accordion>', () => {
}); });
it('sends event "expanded-changed" for every expanded state change', async () => { 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(); const spy = sinon.spy();
el.addEventListener('expanded-changed', spy); el.addEventListener('expanded-changed', spy);
el.expanded = [1]; el.expanded = [1];
@ -95,44 +98,44 @@ describe('<lion-accordion>', () => {
describe('Accordion navigation', () => { describe('Accordion navigation', () => {
it('sets focusedIndex to null by default', async () => { it('sets focusedIndex to null by default', async () => {
const el = await fixture(basicAccordion); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
expect(el.focusedIndex).to.be.null; expect(el.focusedIndex).to.equal(-1);
}); });
it('can programmatically set focusedIndex', async () => { it('can programmatically set focusedIndex', async () => {
const el = await fixture(html` const el = /** @type {LionAccordion} */ (await fixture(html`
<lion-accordion .focusedIndex=${1}> <lion-accordion .focusedIndex=${1}>
<h2 slot="invoker"><button>invoker 1</button></h2> <h2 slot="invoker"><button>invoker 1</button></h2>
<div slot="content">content 1</div> <div slot="content">content 1</div>
<h2 slot="invoker"><button>invoker 2</button></h2> <h2 slot="invoker"><button>invoker 2</button></h2>
<div slot="content">content 2</div> <div slot="content">content 2</div>
</lion-accordion> </lion-accordion>
`); `));
expect(el.focusedIndex).to.equal(1); expect(el.focusedIndex).to.equal(1);
expect( expect(
Array.from(el.children).find( Array.from(el.children).find(
child => child.slot === 'invoker' && child.firstElementChild.hasAttribute('focused'), child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'),
).textContent, )?.textContent,
).to.equal('invoker 2'); ).to.equal('invoker 2');
el.focusedIndex = 0; el.focusedIndex = 0;
expect( expect(
Array.from(el.children).find( Array.from(el.children).find(
child => child.slot === 'invoker' && child.firstElementChild.hasAttribute('focused'), child => child.slot === 'invoker' && child.firstElementChild?.hasAttribute('focused'),
).textContent, )?.textContent,
).to.equal('invoker 1'); ).to.equal('invoker 1');
}); });
it('has [focused] on current focused invoker first child which serves as styling hook', async () => { 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]'); const invokers = el.querySelectorAll('[slot=invoker]');
el.focusedIndex = [0]; el.focusedIndex = 0;
expect(invokers[0]).to.not.have.attribute('focused'); expect(invokers[0]).to.not.have.attribute('focused');
expect(invokers[1]).to.not.have.attribute('focused'); expect(invokers[1]).to.not.have.attribute('focused');
expect(invokers[0].firstElementChild).to.have.attribute('focused'); expect(invokers[0].firstElementChild).to.have.attribute('focused');
expect(invokers[1].firstElementChild).to.not.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[0]).to.not.have.attribute('focused');
expect(invokers[1]).to.not.have.attribute('focused'); expect(invokers[1]).to.not.have.attribute('focused');
expect(invokers[0].firstElementChild).to.not.have.attribute('focused'); expect(invokers[0].firstElementChild).to.not.have.attribute('focused');
@ -140,7 +143,7 @@ describe('<lion-accordion>', () => {
}); });
it('sends event "focused-changed" for every focused state change', async () => { 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(); const spy = sinon.spy();
el.addEventListener('focused-changed', spy); el.addEventListener('focused-changed', spy);
el.focusedIndex = 1; el.focusedIndex = 1;
@ -150,7 +153,7 @@ describe('<lion-accordion>', () => {
describe('Accordion Contents (slot=content)', () => { describe('Accordion Contents (slot=content)', () => {
it('are visible when corresponding invoker is expanded', async () => { 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]'); const contents = el.querySelectorAll('[slot=content]');
el.expanded = [0]; el.expanded = [0];
expect(contents[0]).to.be.visible; expect(contents[0]).to.be.visible;
@ -174,44 +177,44 @@ describe('<lion-accordion>', () => {
*/ */
describe('User interaction', () => { describe('User interaction', () => {
it('opens a invoker on click', async () => { 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]'); 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]); expect(el.expanded).to.deep.equal([1]);
}); });
it('selects a invoker on click', async () => { 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]'); 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); expect(el.focusedIndex).to.equal(1);
}); });
it.skip('opens/close invoker on [enter] and [space]', async () => { 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]'); 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]); 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([]); expect(el.expanded).to.deep.equal([]);
}); });
it('selects next invoker on [arrow-right] and [arrow-down]', async () => { 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]'); const invokers = el.querySelectorAll('[slot=invoker]');
el.focusedIndex = 0; el.focusedIndex = 0;
invokers[0].firstElementChild.dispatchEvent( invokers[0].firstElementChild?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight' }), new KeyboardEvent('keydown', { key: 'ArrowRight' }),
); );
expect(el.focusedIndex).to.equal(1); expect(el.focusedIndex).to.equal(1);
invokers[0].firstElementChild.dispatchEvent( invokers[0].firstElementChild?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown' }), new KeyboardEvent('keydown', { key: 'ArrowDown' }),
); );
expect(el.focusedIndex).to.equal(2); expect(el.focusedIndex).to.equal(2);
}); });
it('selects previous invoker on [arrow-left] and [arrow-up]', async () => { it('selects previous invoker on [arrow-left] and [arrow-up]', async () => {
const el = await fixture(html` const el = /** @type {LionAccordion} */ (await fixture(html`
<lion-accordion .focusedIndex=${1}> <lion-accordion .focusedIndex=${1}>
<h2 slot="invoker"><button>invoker 1</button></h2> <h2 slot="invoker"><button>invoker 1</button></h2>
<div slot="content">content 1</div> <div slot="content">content 1</div>
@ -220,40 +223,42 @@ describe('<lion-accordion>', () => {
<h2 slot="invoker"><button>invoker 3</button></h2> <h2 slot="invoker"><button>invoker 3</button></h2>
<div slot="content">content 3</div> <div slot="content">content 3</div>
</lion-accordion> </lion-accordion>
`); `));
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = el.querySelectorAll('[slot=invoker]');
el.focusedIndex = 2; el.focusedIndex = 2;
invokers[2].firstElementChild.dispatchEvent( invokers[2].firstElementChild?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft' }), new KeyboardEvent('keydown', { key: 'ArrowLeft' }),
); );
expect(el.focusedIndex).to.equal(1); 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); expect(el.focusedIndex).to.equal(0);
}); });
it('selects first invoker on [home]', async () => { it('selects first invoker on [home]', async () => {
const el = await fixture(html` const el = /** @type {LionAccordion} */ (await fixture(html`
<lion-accordion .focusedIndex=${1}> <lion-accordion .focusedIndex=${1}>
<h2 slot="invoker"><button>invoker 1</button></h2> <h2 slot="invoker"><button>invoker 1</button></h2>
<div slot="content">content 1</div> <div slot="content">content 1</div>
<h2 slot="invoker"><button>invoker 2</button></h2> <h2 slot="invoker"><button>invoker 2</button></h2>
<div slot="content">content 2</div> <div slot="content">content 2</div>
</lion-accordion> </lion-accordion>
`); `));
const invokers = el.querySelectorAll('[slot=invoker]'); 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); expect(el.focusedIndex).to.equal(0);
}); });
it('selects last invoker on [end]', async () => { 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]'); 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); expect(el.focusedIndex).to.equal(2);
}); });
it('stays on last invoker on [arrow-right]', async () => { it('stays on last invoker on [arrow-right]', async () => {
const el = await fixture(html` const el = /** @type {LionAccordion} */ (await fixture(html`
<lion-accordion focusedIndex="2"> <lion-accordion focusedIndex="2">
<h2 slot="invoker"><button>invoker 1</button></h2> <h2 slot="invoker"><button>invoker 1</button></h2>
<div slot="content">content 1</div> <div slot="content">content 1</div>
@ -262,16 +267,16 @@ describe('<lion-accordion>', () => {
<h2 slot="invoker"><button>invoker 3</button></h2> <h2 slot="invoker"><button>invoker 3</button></h2>
<div slot="content">content 3</div> <div slot="content">content 3</div>
</lion-accordion> </lion-accordion>
`); `));
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = el.querySelectorAll('[slot=invoker]');
invokers[2].firstElementChild.dispatchEvent( invokers[2].firstElementChild?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight' }), new KeyboardEvent('keydown', { key: 'ArrowRight' }),
); );
expect(el.focusedIndex).to.equal(2); expect(el.focusedIndex).to.equal(2);
}); });
it('stays on first invoker on [arrow-left]', async () => { it('stays on first invoker on [arrow-left]', async () => {
const el = await fixture(html` const el = /** @type {LionAccordion} */ (await fixture(html`
<lion-accordion> <lion-accordion>
<h2 slot="invoker"><button>invoker 1</button></h2> <h2 slot="invoker"><button>invoker 1</button></h2>
<div slot="content">content 1</div> <div slot="content">content 1</div>
@ -280,21 +285,19 @@ describe('<lion-accordion>', () => {
<h2 slot="invoker"><button>invoker 3</button></h2> <h2 slot="invoker"><button>invoker 3</button></h2>
<div slot="content">content 3</div> <div slot="content">content 3</div>
</lion-accordion> </lion-accordion>
`); `));
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = el.querySelectorAll('[slot=invoker]');
invokers[0].firstElementChild.dispatchEvent( invokers[0].firstElementChild?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft' }), new KeyboardEvent('keydown', { key: 'ArrowLeft' }),
); );
expect(el.focusedIndex).to.equal(null); expect(el.focusedIndex).to.equal(-1);
}); });
}); });
describe('Content distribution', () => { describe('Content distribution', () => {
it('should work with append children', async () => { it('should work with append children', async () => {
const el = await fixture(basicAccordion); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
const c = 2; for (let i = 4; i < 6; i += 1) {
const n = el.children.length / 2;
for (let i = n + 1; i < n + c + 1; i += 1) {
const invoker = document.createElement('h2'); const invoker = document.createElement('h2');
const button = document.createElement('button'); const button = document.createElement('button');
invoker.setAttribute('slot', 'invoker'); invoker.setAttribute('slot', 'invoker');
@ -306,25 +309,23 @@ describe('<lion-accordion>', () => {
el.append(invoker); el.append(invoker);
el.append(content); el.append(content);
} }
el.expanded = [el.children.length / 2 - 1]; el.expanded = [4];
await el.updateComplete; await el.updateComplete;
expect( expect(
Array.from(el.children).find( Array.from(el.children).find(
child => child.slot === 'invoker' && child.hasAttribute('expanded'), child => child.slot === 'invoker' && child.hasAttribute('expanded'),
).textContent, )?.textContent,
).to.equal('invoker 5'); ).to.equal('invoker 5');
expect( expect(
Array.from(el.children).find( Array.from(el.children).find(
child => child.slot === 'content' && child.hasAttribute('expanded'), child => child.slot === 'content' && child.hasAttribute('expanded'),
).textContent, )?.textContent,
).to.equal('content 5'); ).to.equal('content 5');
}); });
it('should add order style property to each invoker and content', async () => { it('should add order style property to each invoker and content', async () => {
const el = await fixture(basicAccordion); const el = /** @type {LionAccordion} */ (await fixture(basicAccordion));
const c = 2; for (let i = 4; i < 6; i += 1) {
const n = el.children.length / 2;
for (let i = n + 1; i < n + c + 1; i += 1) {
const invoker = document.createElement('h2'); const invoker = document.createElement('h2');
const button = document.createElement('button'); const button = document.createElement('button');
invoker.setAttribute('slot', 'invoker'); invoker.setAttribute('slot', 'invoker');
@ -336,10 +337,13 @@ describe('<lion-accordion>', () => {
el.append(invoker); el.append(invoker);
el.append(content); el.append(content);
} }
el.expanded = [el.children.length / 2 - 1];
await el.updateComplete; await el.updateComplete;
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = /** @type {HTMLElement[]} */ (Array.from(
const contents = el.querySelectorAll('[slot=content]'); el.querySelectorAll('[slot=invoker]'),
));
const contents = /** @type {HTMLElement[]} */ (Array.from(
el.querySelectorAll('[slot=content]'),
));
invokers.forEach((invoker, index) => { invokers.forEach((invoker, index) => {
const content = contents[index]; const content = contents[index];
expect(invoker.style.getPropertyValue('order')).to.equal(`${index + 1}`); expect(invoker.style.getPropertyValue('order')).to.equal(`${index + 1}`);
@ -378,10 +382,10 @@ describe('<lion-accordion>', () => {
`); `);
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = el.querySelectorAll('[slot=invoker]');
const contents = el.querySelectorAll('[slot=content]'); 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, contents[0].id,
); );
expect(invokers[1].firstElementChild.getAttribute('aria-controls')).to.equal( expect(invokers[1].firstElementChild?.getAttribute('aria-controls')).to.equal(
contents[1].id, contents[1].id,
); );
}); });
@ -394,20 +398,20 @@ describe('<lion-accordion>', () => {
</lion-accordion> </lion-accordion>
`); `);
expect( 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'); ).to.have.attribute('aria-expanded', 'false');
}); });
it('adds aria-expanded="true" to invoker when its content is expanded', async () => { 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`
<lion-accordion> <lion-accordion>
<h2 slot="invoker"><button>invoker</button></h2> <h2 slot="invoker"><button>invoker</button></h2>
<div slot="content">content</div> <div slot="content">content</div>
</lion-accordion> </lion-accordion>
`); `));
el.expanded = [0]; el.expanded = [0];
expect( 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'); ).to.have.attribute('aria-expanded', 'true');
}); });
}); });
@ -424,8 +428,8 @@ describe('<lion-accordion>', () => {
`); `);
const contents = el.querySelectorAll('[slot=content]'); const contents = el.querySelectorAll('[slot=content]');
const invokers = el.querySelectorAll('[slot=invoker]'); const invokers = el.querySelectorAll('[slot=invoker]');
expect(contents[0]).to.have.attribute('aria-labelledby', invokers[0].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); expect(contents[1]).to.have.attribute('aria-labelledby', invokers[1].firstElementChild?.id);
}); });
}); });
}); });

View file

@ -15,6 +15,7 @@
"suppressImplicitAnyIndexErrors": true "suppressImplicitAnyIndexErrors": true
}, },
"include": [ "include": [
"packages/accordion/**/*.js",
"packages/core/**/*.js", "packages/core/**/*.js",
"packages/tabs/**/*.js", "packages/tabs/**/*.js",
"packages/singleton-manager/**/*.js", "packages/singleton-manager/**/*.js",