diff --git a/README.md b/README.md index 77e95e8e3..49da9a573 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ The accessibility column indicates whether the functionality is accessible in it | [dialog](https://lion-web-components.netlify.app/?path=/docs/overlays-dialog--main) | [![dialog](https://img.shields.io/npm/v/@lion/dialog.svg)](https://www.npmjs.com/package/@lion/dialog) | Dialog element | ✔️ | | [tooltip](https://lion-web-components.netlify.app/?path=/docs/overlays-tooltip--main) | [![tooltip](https://img.shields.io/npm/v/@lion/tooltip.svg)](https://www.npmjs.com/package/@lion/tooltip) | Tooltip element | [#175][i175] | | **-- [Navigation System](https://lion-web-components.netlify.app/?path=/docs/navigation-intro--page) --** | | Components which are used to guide users | | +| [accordion](https://lion-web-components.netlify.app/?path=/docs/navigation-accordion--main) | [![accordion](https://img.shields.io/npm/v/@lion/accordion.svg)](https://www.npmjs.com/package/@lion/accordion) | Accordion | ✔️ | | [steps](https://lion-web-components.netlify.app/?path=/docs/navigation-steps--main) | [![steps](https://img.shields.io/npm/v/@lion/steps.svg)](https://www.npmjs.com/package/@lion/steps) | Multi Step System | n/a | | [tabs](https://lion-web-components.netlify.app/?path=/docs/navigation-tabs--main) | [![tBS](https://img.shields.io/npm/v/@lion/tabs.svg)](https://www.npmjs.com/package/@lion/tabs) | Move between a small number of equally important views | n/a | | **-- [localize System](https://lion-web-components.netlify.app/?path=/docs/localize-intro--page) --** | | Localize text, numbers, dates and a way to store/fetch these data. | | diff --git a/packages/accordion/README.md b/packages/accordion/README.md new file mode 100644 index 000000000..6c35da962 --- /dev/null +++ b/packages/accordion/README.md @@ -0,0 +1,275 @@ +[//]: # 'AUTO INSERT HEADER PREPUBLISH' + +# Accordion + +`lion-accordion` is a component used to toggle the display of sections of content. +Its purpose is to reduce the need to scroll when presenting multiple sections of content on a single page. Accordions often allow users to get the big picture before focusing on details. + +```js script +import { LitElement } from 'lit-element'; +import { html } from 'lit-html'; +import './lion-accordion.js'; + +export default { + title: 'Navigation/Accordion', +}; +``` + +```js preview-story +export const main = () => html` + +

+ +

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. +

+

+ +

+

+ Laboriosam sequi odit cumque, enim aut assumenda itaque quis voluptas est quos fugiat unde + labore reiciendis saepe, iure, optio officiis obcaecati quibusdam. +

+
+`; +``` + +## How to use + +### Installation + +```bash +npm i --save @lion/accordion +``` + +```js +import { LionAccordion } from '@lion/accordion'; +// or +import '@lion/accordion/lion-accordion.js'; +``` + +### Usage + +```html + +

+ +

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. +

+

+ +

+

+ Laboriosam sequi odit cumque, enim aut assumenda itaque quis voluptas est quos fugiat unde + labore reiciendis saepe, iure, optio officiis obcaecati quibusdam. +

+
+``` + +> An accordion exists off a list of expandable headings (of the same level). To get this behavior you need to add a slot="invoker" to the heading and place a button as the content. + +## Examples + +### Expanded + +You can set `expanded` to pre-expand a certain invoker. + +```js preview-story +export const expanded = () => html` + +

+ +

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. +

+

+ +

+

+ Laboriosam sequi odit cumque, enim aut assumenda itaque quis voluptas est quos fugiat unde + labore reiciendis saepe, iure, optio officiis obcaecati quibusdam. +

+
+`; +``` + +### Slots Order + +The invoker and content slots are ordered by DOM order. + +This means you must locate your content before it's invoker. + +```js preview-story +export const slotsOrder = () => html` + +

+ +

+

+ Lorem ipsum dolor sit, amet consectetur adipisicing elit. +

+

+ +

+

+ Laboriosam sequi odit cumque, enim aut assumenda itaque quis voluptas est quos fugiat unde + labore reiciendis saepe, iure, optio officiis obcaecati quibusdam. +

+
+`; +``` + +### Distribute New Elements + +Below, we demonstrate on how you could dynamically add new invoker + content. + +```js preview-story +export const distributeNewElement = () => { + const tagName = 'demo-accordion-add-dynamically'; + if (!customElements.get(tagName)) { + customElements.define( + tagName, + class extends LitElement { + static get properties() { + return { + __collection: { type: Array }, + }; + } + render() { + return html` +

Append

+ +

+ +

+

content 1

+

+ +

+

content 2

+
+ +
+

Push

+ +

+ +

+

content 1

+

+ +

+

content 2

+ ${this.__collection.map( + item => html` +

+

${item.content}

+ `, + )} +
+ + `; + } + constructor() { + super(); + this.__collection = []; + } + __handleAppendClick() { + const accordionElement = this.shadowRoot.querySelector('#appendAccordion'); + const c = 2; + const n = Math.floor(accordionElement.children.length / 2); + for (let i = n + 1; i < n + c; i += 1) { + const invoker = document.createElement('h4'); + const button = document.createElement('button'); + button.innerText = `header ${i}`; + invoker.setAttribute('slot', 'invoker'); + invoker.appendChild(button); + const content = document.createElement('p'); + content.setAttribute('slot', 'content'); + content.innerText = `content ${i}`; + accordionElement.append(invoker); + accordionElement.append(content); + } + } + __handlePushClick() { + const accordionElement = this.shadowRoot.querySelector('#pushTabs'); + const i = Math.floor(accordionElement.children.length / 2) + 1; + this.__collection = [ + ...this.__collection, + { + invoker: `header ${i}`, + content: `content ${i}`, + }, + ]; + } + }, + ); + } + return html` `; +}; +``` + +One way is by creating the DOM elements and appending them as needed. + +Inside your `lion-accordion` extension, an example for appending nodes on a certain button click: + +```js +__handleAppendClick() { + const accordionAmount = this.children.length / 2; + const invoker = document.createElement('h4'); + const button = document.createElement('button'); + button.innerText = `header ${accordionAmount + 1}`; + invoker.setAttribute('slot', 'invoker'); + invoker.appendChild(button); + const content = document.createElement('p'); + content.setAttribute('slot', 'content'); + content.innerText = `content ${accordionAmount + 1}`; + this.append(invoker); + this.append(content); +} +``` + +The other way is by adding data to a Lit property where you loop over this property in your template. +You then need to ensure this causes a re-render. + +```js +__handlePushClick() { + const accordionAmount = this.children.length; + myCollection = [ + ...myCollection, + { + invoker: `header ${accordionAmount + 1}`, + content: `content ${accordionAmount + 1}`, + }, + ]; + renderMyCollection(); +} +``` + +Make sure your template re-renders when myCollection is updated. + +```html + + ${myCollection.map(item => html` +

+ +

+

${item.content}

+ `)} +
+``` + +## Rationale + +### Contents are not focusable + +Focusable elements should have a means to interact with them. Contents themselves do not offer any interactiveness. +If there is a button or a form inside the tab panel then these elements get focused directly. diff --git a/packages/accordion/index.js b/packages/accordion/index.js new file mode 100644 index 000000000..275dd49aa --- /dev/null +++ b/packages/accordion/index.js @@ -0,0 +1 @@ +export { LionAccordion } from './src/LionAccordion.js'; diff --git a/packages/accordion/lion-accordion.js b/packages/accordion/lion-accordion.js new file mode 100644 index 000000000..b113c589d --- /dev/null +++ b/packages/accordion/lion-accordion.js @@ -0,0 +1,3 @@ +import { LionAccordion } from './src/LionAccordion.js'; + +customElements.define('lion-accordion', LionAccordion); diff --git a/packages/accordion/package.json b/packages/accordion/package.json new file mode 100644 index 000000000..08542881b --- /dev/null +++ b/packages/accordion/package.json @@ -0,0 +1,42 @@ +{ + "name": "@lion/accordion", + "version": "0.0.0", + "description": "Vertically stacked list of invokers that can be clicked to reveal or hide content associated with them.", + "author": "ing-bank", + "homepage": "https://github.com/ing-bank/lion/", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/ing-bank/lion.git", + "directory": "packages/accordion" + }, + "scripts": { + "prepublishOnly": "../../scripts/npm-prepublish.js", + "start": "cd ../../ && yarn dev-server --open packages/tabs/README.md", + "test": "cd ../../ && yarn test:browser --grep \"packages/tabs/test/**/*.test.js\"", + "test:watch": "cd ../../ && yarn test:browser:watch --grep \"packages/tabs/test/**/*.test.js\"" + }, + "keywords": [ + "lion", + "web-components", + "accordion" + ], + "main": "index.js", + "module": "index.js", + "files": [ + "docs", + "src", + "test", + "translations", + "*.js" + ], + "sideEffects": [ + "lion-accordion.js" + ], + "dependencies": { + "@lion/core": "0.7.1" + } +} diff --git a/packages/accordion/src/LionAccordion.js b/packages/accordion/src/LionAccordion.js new file mode 100644 index 000000000..ab5a772e2 --- /dev/null +++ b/packages/accordion/src/LionAccordion.js @@ -0,0 +1,293 @@ +import { LitElement, css, html } from '@lion/core'; + +const uuid = () => Math.random().toString(36).substr(2, 10); + +const setupContent = ({ element, uid, index }) => { + element.style.setProperty('order', index + 1); + element.setAttribute('id', `content-${uid}`); + element.setAttribute('aria-labelledby', `invoker-${uid}`); +}; + +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('keyup', keydownHandler); +}; + +const cleanInvoker = (element, clickHandler, keydownHandler) => { + element.firstElementChild.removeAttribute('id'); + element.firstElementChild.removeAttribute('aria-controls'); + element.firstElementChild.removeEventListener('click', clickHandler); + element.firstElementChild.removeEventListener('keyup', keydownHandler); +}; + +const focusInvoker = element => { + element.firstElementChild.focus(); + element.firstElementChild.setAttribute('focused', true); +}; + +const unfocusInvoker = element => { + element.firstElementChild.removeAttribute('focused'); +}; + +const expandInvoker = element => { + element.setAttribute('expanded', true); + element.firstElementChild.setAttribute('expanded', true); + element.firstElementChild.setAttribute('aria-expanded', true); +}; + +const collapseInvoker = element => { + element.removeAttribute('expanded'); + element.firstElementChild.removeAttribute('expanded'); + element.firstElementChild.setAttribute('aria-expanded', false); +}; + +const expandContent = element => { + element.setAttribute('expanded', true); +}; + +const collapseContent = element => { + element.removeAttribute('expanded'); +}; + +/** + * # webcomponent + * + * @customElement lion-accordion + * @extends LitElement + */ +export class LionAccordion extends LitElement { + static get properties() { + return { + /** + * index number of the focused accordion + */ + focusedIndex: { + type: Number, + }, + /** + * array of indices of the expanded accordions + */ + expanded: { + type: Array, + }, + }; + } + + static get styles() { + return [ + css` + .accordion { + display: flex; + flex-direction: column; + } + + .accordion ::slotted([slot='invoker']) { + margin: 0; + } + + .accordion ::slotted([slot='invoker'][expanded]) { + font-weight: bold; + } + + .accordion ::slotted([slot='content']) { + margin: 0; + visibility: hidden; + display: none; + } + + .accordion ::slotted([slot='content'][expanded]) { + visibility: visible; + display: block; + } + `, + ]; + } + + render() { + return html` +
+ + +
+ `; + } + + constructor() { + super(); + this.focusedIndex = null; + this.expanded = []; + this.styles = {}; + } + + firstUpdated() { + super.firstUpdated(); + this.__setupSlots(); + } + + __setupSlots() { + const invokerSlot = this.shadowRoot.querySelector('slot[name=invoker]'); + const handleSlotChange = () => { + this.__cleanStore(); + this.__setupStore(); + this.__updateFocused(); + this.__updateExpanded(); + }; + invokerSlot.addEventListener('slotchange', handleSlotChange); + } + + __setupStore() { + this.__store = []; + const invokers = this.querySelectorAll('[slot="invoker"]'); + const contents = this.querySelectorAll('[slot="content"]'); + if (invokers.length !== contents.length) { + // eslint-disable-next-line no-console + console.warn( + `The amount of invokers (${invokers.length}) doesn't match the amount of contents (${contents.length}).`, + ); + } + + invokers.forEach((invoker, index) => { + const uid = uuid(); + const content = contents[index]; + const entry = { + uid, + index, + invoker, + content, + clickHandler: this.__createInvokerClickHandler(index), + keydownHandler: this.__handleInvokerKeydown.bind(this), + }; + setupContent({ element: entry.content, ...entry }); + setupInvoker({ element: entry.invoker, ...entry }); + unfocusInvoker(entry.invoker); + collapseContent(entry.content); + collapseInvoker(entry.invoker); + this.__store.push(entry); + }); + } + + __cleanStore() { + if (!this.__store) { + return; + } + this.__store.forEach(entry => { + cleanInvoker(entry.invoker, entry.clickHandler, entry.keydownHandler); + }); + } + + __createInvokerClickHandler(index) { + return () => { + this.focusedIndex = index; + this.__toggleExpanded(index); + }; + } + + __handleInvokerKeydown(e) { + switch (e.key) { + case 'ArrowDown': + case 'ArrowRight': + e.preventDefault(); + if (this.focusedIndex + 1 >= this._pairCount) { + this.focusedIndex = 0; + } else { + this.focusedIndex += 1; + } + break; + case 'ArrowUp': + case 'ArrowLeft': + e.preventDefault(); + if (this.focusedIndex <= 0) { + this.focusedIndex = this._pairCount - 1; + } else { + this.focusedIndex -= 1; + } + break; + case 'Home': + e.preventDefault(); + this.focusedIndex = 0; + break; + case 'End': + e.preventDefault(); + this.focusedIndex = this._pairCount - 1; + break; + /* no default */ + } + } + + set focusedIndex(value) { + const stale = this.__focusedIndex; + this.__focusedIndex = value; + this.__updateFocused(); + this.dispatchEvent(new Event('focused-changed')); + this.requestUpdate('focusedIndex', stale); + } + + get focusedIndex() { + return this.__focusedIndex; + } + + get _pairCount() { + return this.__store.length; + } + + set expanded(value) { + const stale = this.__expanded; + this.__expanded = value; + this.__updateExpanded(); + this.dispatchEvent(new Event('expanded-changed')); + this.requestUpdate('expanded', stale); + } + + get expanded() { + return this.__expanded; + } + + __updateFocused() { + if (!(this.__store && this.__store[this.focusedIndex])) { + return; + } + const previousInvoker = Array.from(this.children).find( + child => child.slot === 'invoker' && child.firstElementChild.hasAttribute('focused'), + ); + if (previousInvoker) { + unfocusInvoker(previousInvoker); + } + const { invoker: currentInvoker } = this.__store[this.focusedIndex]; + if (currentInvoker) { + focusInvoker(currentInvoker); + } + } + + __updateExpanded() { + if (!this.__store) { + return; + } + this.__store.forEach((entry, index) => { + const entryExpanded = this.expanded.indexOf(index) !== -1; + + if (entryExpanded) { + expandInvoker(entry.invoker); + expandContent(entry.content); + } else { + collapseInvoker(entry.invoker); + collapseContent(entry.content); + } + }); + } + + __toggleExpanded(value) { + const { expanded } = this; + const index = expanded.indexOf(value); + + if (index === -1) { + expanded.push(value); + } else { + expanded.splice(index, 1); + } + + this.expanded = expanded; + } +} diff --git a/packages/accordion/test/lion-accordion.test.js b/packages/accordion/test/lion-accordion.test.js new file mode 100644 index 000000000..672e057fc --- /dev/null +++ b/packages/accordion/test/lion-accordion.test.js @@ -0,0 +1,424 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import sinon from 'sinon'; + +import '../lion-accordion.js'; + +const basicAccordion = html` + +

+
content 1
+

+
content 2
+

+
content 3
+
+`; + +describe('', () => { + describe('Accordion', () => { + it('sets expanded to [] by default', async () => { + const el = await fixture(basicAccordion); + expect(el.expanded).to.deep.equal([]); + }); + + it('can programmatically set expanded', async () => { + const el = 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, + ).to.equal('invoker 2'); + + el.expanded = [0]; + expect( + Array.from(el.children).find( + child => child.slot === 'invoker' && child.hasAttribute('expanded'), + ).textContent, + ).to.equal('invoker 1'); + }); + + it('has [expanded] on current expanded invoker which serves as styling hook', async () => { + const el = await fixture(basicAccordion); + const invokers = el.querySelectorAll('[slot=invoker]'); + 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 = await fixture(basicAccordion); + const invokers = el.querySelectorAll('[slot=invoker]'); + 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('sends event "expanded-changed" for every expanded state change', async () => { + const el = await fixture(basicAccordion); + const spy = sinon.spy(); + el.addEventListener('expanded-changed', spy); + el.expanded = [1]; + expect(spy).to.have.been.calledOnce; + }); + + it('throws warning if unequal amount of invokers and contents', async () => { + const spy = sinon.spy(console, 'warn'); + await fixture(html` + +

+
content 1
+
content 2
+
+ `); + expect(spy.callCount).to.equal(1); + console.warn.restore(); + }); + }); + + describe('Accordion navigation', () => { + it('sets focusedIndex to null by default', async () => { + const el = await fixture(basicAccordion); + expect(el.focusedIndex).to.be.null; + }); + + it('can programmatically set focusedIndex', async () => { + const el = 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, + ).to.equal('invoker 2'); + + el.focusedIndex = 0; + expect( + Array.from(el.children).find( + 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 invokers = el.querySelectorAll('[slot=invoker]'); + 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 = await fixture(basicAccordion); + const spy = sinon.spy(); + el.addEventListener('focused-changed', spy); + el.focusedIndex = 1; + expect(spy).to.have.been.calledOnce; + }); + }); + + describe('Accordion Contents (slot=content)', () => { + it('are visible when corresponding invoker is expanded', async () => { + const el = await fixture(basicAccordion); + const contents = el.querySelectorAll('[slot=content]'); + el.expanded = [0]; + 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; + }); + + 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 a invoker on click', async () => { + const el = await fixture(basicAccordion); + const invokers = el.querySelectorAll('[slot=invoker]'); + 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 invokers = el.querySelectorAll('[slot=invoker]'); + 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 invokers = el.querySelectorAll('[slot=invoker]'); + invokers[0].firstElementChild.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter' })); + expect(el.expanded).to.deep.equal([0]); + 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 invokers = el.querySelectorAll('[slot=invoker]'); + el.focusedIndex = 0; + invokers[0].firstElementChild.dispatchEvent( + new KeyboardEvent('keyup', { key: 'ArrowRight' }), + ); + expect(el.focusedIndex).to.equal(1); + invokers[0].firstElementChild.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowDown' })); + expect(el.focusedIndex).to.equal(2); + }); + + it('selects previous invoker on [arrow-left] and [arrow-up]', async () => { + const el = await fixture(html` + +

+
content 1
+

+
content 2
+

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

+
content 1
+

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

+
content 1
+

+
content 2
+

+
content 3
+
+ `); + const invokers = el.querySelectorAll('[slot=invoker]'); + invokers[2].firstElementChild.dispatchEvent( + new KeyboardEvent('keyup', { key: 'ArrowRight' }), + ); + expect(el.focusedIndex).to.equal(0); + }); + + it('selects last invoker on [arrow-left] if on first invoker', async () => { + const el = await fixture(html` + +

+
content 1
+

+
content 2
+

+
content 3
+
+ `); + const invokers = el.querySelectorAll('[slot=invoker]'); + invokers[0].firstElementChild.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft' })); + expect(el.focusedIndex).to.equal(2); + }); + }); + + 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 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 = [el.children.length / 2 - 1]; + await el.updateComplete; + expect( + Array.from(el.children).find( + child => child.slot === 'invoker' && child.hasAttribute('expanded'), + ).textContent, + ).to.equal('invoker 5'); + expect( + Array.from(el.children).find( + child => child.slot === 'content' && child.hasAttribute('expanded'), + ).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 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 = [el.children.length / 2 - 1]; + await el.updateComplete; + const invokers = el.querySelectorAll('[slot=invoker]'); + const contents = 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` + +

+
content 1
+

+
content 2
+
+ `); + expect(Array.from(el.children).find(child => child.slot === 'content')).to.not.have.attribute( + 'tabindex', + ); + expect(Array.from(el.children).find(child => child.slot === 'content')).to.not.have.attribute( + 'tabindex', + ); + }); + + describe('Invokers', () => { + it('links ids of content items to invoker first child via [aria-controls]', async () => { + const el = await fixture(html` + +

+
content 1
+

+
content 2
+
+ `); + const invokers = el.querySelectorAll('[slot=invoker]'); + const contents = el.querySelectorAll('[slot=content]'); + 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 = await fixture(html` + +

+
content
+
+ `); + expect( + 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` + +

+
content
+
+ `); + el.expanded = [0]; + expect( + Array.from(el.children).find(child => child.slot === 'invoker').firstElementChild, + ).to.have.attribute('aria-expanded', 'true'); + }); + }); + + describe('Contents', () => { + it('adds aria-labelledby referring to invoker ids', async () => { + const el = await fixture(html` + +

+
content 1
+

+
content 2
+
+ `); + 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); + }); + }); + }); +});