UNPKG

@lion/ui

Version:

A package of extendable web components

592 lines (528 loc) 22.6 kB
import { expect, fixture } from '@open-wc/testing'; import { html } from 'lit/static-html.js'; import { sendKeys } from '@web/test-runner-commands'; import sinon from 'sinon'; import '@lion/ui/define/lion-accordion.js'; /** * @typedef {import('../src/LionAccordion.js').LionAccordion} LionAccordion */ const basicAccordion = html` <lion-accordion> <h2 slot="invoker"><button>invoker 1</button></h2> <div slot="content">content 1</div> <h2 slot="invoker"><button>invoker 2</button></h2> <div slot="content">content 2</div> <h2 slot="invoker"><button>invoker 3</button></h2> <div slot="content">content 3</div> </lion-accordion> `; /** * @param {Element} el */ function getAccordionChildren(el) { if (el.shadowRoot) { const slot = el.shadowRoot?.querySelector('slot[name=_accordion]'); return slot && slot instanceof HTMLSlotElement ? slot.assignedElements() : []; } return []; } /** * @param {LionAccordion} el */ function getInvokers(el) { return getAccordionChildren(el).filter(child => child.classList.contains('invoker')); } /** * @param {LionAccordion} el */ function getContents(el) { return getAccordionChildren(el).filter(child => child.classList.contains('content')); } describe('<lion-accordion>', () => { describe('Accordion', () => { it('sets expanded to [] by default', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); expect(el.expanded).to.deep.equal([]); }); it('can programmatically set expanded', async () => { const el = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion .expanded=${[1]}> <h2 slot="invoker"><button>invoker 1</button></h2> <div slot="content">content 1</div> <h2 slot="invoker"><button>invoker 2</button></h2> <div slot="content">content 2</div> </lion-accordion> `) ); expect(el.expanded).to.deep.equal([1]); expect( Array.from(getAccordionChildren(el)).find( child => child.className === 'invoker' && child.hasAttribute('expanded'), )?.textContent, ).to.equal('invoker 2'); el.expanded = [0]; expect( Array.from(getAccordionChildren(el)).find( child => child.className === 'invoker' && child.hasAttribute('expanded'), )?.textContent, ).to.equal('invoker 1'); }); it('updates expanded with a new array when an invoker is clicked', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = getInvokers(el); const oldExpanded = el.expanded; invokers[1].firstElementChild?.dispatchEvent(new Event('click')); expect(el.expanded).to.not.equal(oldExpanded); }); it('has [expanded] on current expanded invoker which serves as styling hook', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = getInvokers(el); el.expanded = [0]; expect(invokers[0]).to.have.attribute('expanded'); expect(invokers[1]).to.not.have.attribute('expanded'); el.expanded = [1]; expect(invokers[0]).to.not.have.attribute('expanded'); expect(invokers[1]).to.have.attribute('expanded'); }); it('has [expanded] on current expanded invoker first child which serves as styling hook', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = getInvokers(el); el.expanded = [0]; expect(invokers[0].firstElementChild).to.have.attribute('expanded'); expect(invokers[1].firstElementChild).to.not.have.attribute('expanded'); el.expanded = [1]; expect(invokers[0].firstElementChild).to.not.have.attribute('expanded'); expect(invokers[1].firstElementChild).to.have.attribute('expanded'); }); it('supports [exclusive] attribute, allowing one collapsible to be open at a time', async () => { const el = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion exclusive> <h2 slot="invoker"><button>invoker 1</button></h2> <div slot="content">content 1</div> <h2 slot="invoker"><button>invoker 2</button></h2> <div slot="content">content 2</div> <h2 slot="invoker"><button>invoker 3</button></h2> <div slot="content">content 3</div> </lion-accordion> `) ); const invokerButtons = Array.from(getInvokers(el)).map( invokerHeadingEl => /** @type {HTMLButtonElement} */ (invokerHeadingEl.firstElementChild), ); // We open the first... (nothing different from not [exclusive] so far) invokerButtons[0].click(); expect(invokerButtons[0]).to.have.attribute('expanded'); expect(invokerButtons[1]).to.not.have.attribute('expanded'); expect(invokerButtons[2]).to.not.have.attribute('expanded'); // We click the second... invokerButtons[1].click(); expect(invokerButtons[0]).to.not.have.attribute('expanded'); expect(invokerButtons[1]).to.have.attribute('expanded'); expect(invokerButtons[2]).to.not.have.attribute('expanded'); // We click the third... invokerButtons[2].click(); expect(invokerButtons[0]).to.not.have.attribute('expanded'); expect(invokerButtons[1]).to.not.have.attribute('expanded'); expect(invokerButtons[2]).to.have.attribute('expanded'); el.exclusive = false; // We open the first... (behaving as default (not [exclusive]) again) invokerButtons[0].click(); expect(invokerButtons[0]).to.have.attribute('expanded'); expect(invokerButtons[1]).to.not.have.attribute('expanded'); expect(invokerButtons[2]).to.have.attribute('expanded'); }); it('sends event "expanded-changed" for every expanded state change', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const spy = sinon.spy(); el.addEventListener('expanded-changed', spy); el.expanded = [1]; expect(spy).to.have.been.calledOnce; }); it('logs warning if unequal amount of invokers and contents', async () => { const stub = sinon.stub(console, 'warn'); await fixture(html` <lion-accordion> <h2 slot="invoker"><button>invoker</button></h2> <div slot="content">content 1</div> <div slot="content">content 2</div> </lion-accordion> `); expect(stub).to.be.calledOnceWithExactly( `The amount of invokers (1) doesn't match the amount of contents (2).`, ); stub.restore(); }); it('does not select any elements with slot="invoker" and slot="content" inside slotted elements', async () => { const stub = sinon.stub(console, 'warn'); const el = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion> <h2 slot="invoker"> <button>invoker 1</button> <button slot="invoker">Nested invoker</button> </h2> <h2 slot="invoker"><button>invoker 2</button></h2> <div slot="content"> content 1 <p slot="content">Nested content 1</p> </div> <div slot="content"> content 2 <p slot="content">Nested content 2</p> </div> </lion-accordion> `) ); const invokers = Array.from(getInvokers(el)); const contents = Array.from(getContents(el)); expect(stub.called).to.be.false; expect(invokers.length).to.equal(contents.length); stub.restore(); }); }); describe('Accordion navigation', () => { it('sets focusedIndex to null by default', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); expect(el.focusedIndex).to.equal(-1); }); it('can programmatically set focusedIndex', async () => { const el = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion .focusedIndex=${1}> <h2 slot="invoker"><button>invoker 1</button></h2> <div slot="content">content 1</div> <h2 slot="invoker"><button>invoker 2</button></h2> <div slot="content">content 2</div> </lion-accordion> `) ); expect(el.focusedIndex).to.equal(1); expect( Array.from(getInvokers(el)).find(child => child.firstElementChild?.hasAttribute('focused')) ?.textContent, ).to.equal('invoker 2'); el.focusedIndex = 0; expect( Array.from(getInvokers(el)).find(child => 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 = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = getInvokers(el); 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; 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'); expect(invokers[1].firstElementChild).to.have.attribute('focused'); }); it('sends event "focused-changed" for every focused state change', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const spy = sinon.spy(); el.addEventListener('focused-changed', spy); el.focusedIndex = 1; expect(spy).to.have.been.calledOnce; }); it('tabbing sets the focusedIndex correctly', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = getInvokers(el); el.focusedIndex = 0; expect(el.focusedIndex).to.equal(0); invokers[2].firstElementChild?.dispatchEvent(new Event('focusin')); expect(el.focusedIndex).to.equal(2); invokers[1].firstElementChild?.dispatchEvent(new Event('focusin')); expect(el.focusedIndex).to.equal(1); }); }); describe('Accordion Contents (slot=content)', () => { it('are visible when corresponding invoker is expanded', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); el.expanded = [0]; const contents = getContents(el); setTimeout(() => { expect(contents[0]).to.be.visible; expect(contents[1]).to.be.not.visible; el.expanded = [1]; expect(contents[0]).to.be.not.visible; expect(contents[1]).to.be.visible; }, 250); }); it.skip('have a DOM structure that allows them to be animated ', async () => {}); }); /** * We will immediately switch content as all our content comes from light dom. * * See Note at https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-19 * > It is recommended that invokers activate automatically when they receive focus as long as their * > associated invoker contents are displayed without noticeable latency. This typically requires invoker * > content content to be preloaded. */ describe('User interaction', () => { it('opens/closes an invoker on click', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = getInvokers(el); invokers[1].firstElementChild?.dispatchEvent(new Event('click')); expect(el.expanded).to.deep.equal([1]); invokers[1].firstElementChild?.dispatchEvent(new Event('click')); expect(el.expanded).to.deep.equal([]); }); it('selects a invoker on click', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = getInvokers(el); invokers[1].firstElementChild?.dispatchEvent(new Event('click')); expect(el.focusedIndex).to.equal(1); }); it('opens/closes invoker on [enter] and [space]', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = getInvokers(el); invokers[0].getElementsByTagName('button')[0].focus(); await sendKeys({ press: 'Enter' }); expect(el.expanded).to.deep.equal([0]); await sendKeys({ press: 'Space' }); expect(el.expanded).to.deep.equal([]); }); it('selects next invoker on [arrow-right] and [arrow-down]', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = getInvokers(el); el.focusedIndex = 0; invokers[0].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowRight' }), ); expect(el.focusedIndex).to.equal(1); 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 = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion .focusedIndex=${1}> <h2 slot="invoker"><button>invoker 1</button></h2> <div slot="content">content 1</div> <h2 slot="invoker"><button>invoker 2</button></h2> <div slot="content">content 2</div> <h2 slot="invoker"><button>invoker 3</button></h2> <div slot="content">content 3</div> </lion-accordion> `) ); const invokers = getInvokers(el); el.focusedIndex = 2; invokers[2].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowLeft' }), ); expect(el.focusedIndex).to.equal(1); invokers[1].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowUp' }), ); expect(el.focusedIndex).to.equal(0); }); it('selects first invoker on [home]', async () => { const el = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion .focusedIndex=${1}> <h2 slot="invoker"><button>invoker 1</button></h2> <div slot="content">content 1</div> <h2 slot="invoker"><button>invoker 2</button></h2> <div slot="content">content 2</div> </lion-accordion> `) ); const invokers = getInvokers(el); invokers[1].firstElementChild?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home' })); expect(el.focusedIndex).to.equal(0); }); it('selects last invoker on [end]', async () => { const el = /** @type {LionAccordion} */ (await fixture(basicAccordion)); const invokers = getInvokers(el); 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 = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion focusedIndex="2"> <h2 slot="invoker"><button>invoker 1</button></h2> <div slot="content">content 1</div> <h2 slot="invoker"><button>invoker 2</button></h2> <div slot="content">content 2</div> <h2 slot="invoker"><button>invoker 3</button></h2> <div slot="content">content 3</div> </lion-accordion> `) ); const invokers = getInvokers(el); 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 = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion> <h2 slot="invoker"><button>invoker 1</button></h2> <div slot="content">content 1</div> <h2 slot="invoker"><button>invoker 2</button></h2> <div slot="content">content 2</div> <h2 slot="invoker"><button>invoker 3</button></h2> <div slot="content">content 3</div> </lion-accordion> `) ); const invokers = getInvokers(el); invokers[0].firstElementChild?.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowLeft' }), ); expect(el.focusedIndex).to.equal(-1); }); }); describe('Content distribution', () => { it('should work with append children', async () => { 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'); button.innerText = `invoker ${i}`; invoker.appendChild(button); const content = document.createElement('div'); content.setAttribute('slot', 'content'); content.innerText = `content ${i}`; el.append(invoker); el.append(content); } el.expanded = [4]; await el.updateComplete; expect( Array.from(getInvokers(el)).find(child => child.hasAttribute('expanded'))?.textContent, ).to.equal('invoker 5'); expect( Array.from(getContents(el)).find(child => child.hasAttribute('expanded'))?.textContent, ).to.equal('content 5'); }); it('should add order style property to each invoker and content', async () => { 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'); button.innerText = `invoker ${i}`; invoker.appendChild(button); const content = document.createElement('div'); content.setAttribute('slot', 'content'); content.innerText = `content ${i}`; el.append(invoker); el.append(content); } await el.updateComplete; 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}`); expect(content.style.getPropertyValue('order')).to.equal(`${index + 1}`); }); }); }); describe('Accessibility', () => { it('does not make contents focusable', async () => { const el = await fixture(html` <lion-accordion> <h2 slot="invoker"><button>invoker 1</button></h2> <div slot="content">content 1</div> <h2 slot="invoker"><button>invoker 2</button></h2> <div slot="content">content 2</div> </lion-accordion> `); // console.log(getAccordionChildren(el)); expect( Array.from(getAccordionChildren(el)).find(child => child.classList.contains('content')), ).to.not.have.attribute('tabindex'); expect( Array.from(getAccordionChildren(el)).find(child => child.classList.contains('content')), ).to.not.have.attribute('tabindex'); }); describe('Invokers', () => { it('links ids of content items to invoker first child via [aria-controls]', async () => { const el = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion> <h2 id="h1" slot="invoker"><button>invoker 1</button></h2> <div id="p1" slot="content">content 1</div> <h2 id="h2" slot="invoker"><button>invoker 2</button></h2> <div id="p2" slot="content">content 2</div> </lion-accordion> `) ); const invokers = getInvokers(el); const contents = getContents(el); expect(invokers[0].firstElementChild?.getAttribute('aria-controls')).to.equal( contents[0].id, ); expect(invokers[1].firstElementChild?.getAttribute('aria-controls')).to.equal( contents[1].id, ); }); it('adds aria-expanded="false" to invoker when its content is not expanded', async () => { const el = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion> <h2 slot="invoker"><button>invoker</button></h2> <div slot="content">content</div> </lion-accordion> `) ); expect(Array.from(getInvokers(el))[0]?.firstElementChild).to.have.attribute( 'aria-expanded', 'false', ); }); it('adds aria-expanded="true" to invoker when its content is expanded', async () => { const el = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion> <h2 slot="invoker"><button>invoker</button></h2> <div slot="content">content</div> </lion-accordion> `) ); el.expanded = [0]; expect(Array.from(getInvokers(el))[0]?.firstElementChild).to.have.attribute( 'aria-expanded', 'true', ); }); }); describe('Contents', () => { it('adds aria-labelledby referring to invoker ids', async () => { const el = /** @type {LionAccordion} */ ( await fixture(html` <lion-accordion> <h2 slot="invoker"><button>invoker 1</button></h2> <div slot="content">content 1</div> <h2 slot="invoker"><button>invoker 2</button></h2> <div slot="content">content 2</div> </lion-accordion> `) ); const contents = getContents(el); const invokers = getInvokers(el); expect(contents[0]).to.have.attribute('aria-labelledby', invokers[0].firstElementChild?.id); expect(contents[1]).to.have.attribute('aria-labelledby', invokers[1].firstElementChild?.id); }); }); }); });