Merge pull request #770 from ing-bank/feat/accordion
feat(accordion): add basic structure
This commit is contained in:
commit
6d15386ca1
7 changed files with 1039 additions and 0 deletions
|
|
@ -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) | [](https://www.npmjs.com/package/@lion/dialog) | Dialog element | ✔️ |
|
||||
| [tooltip](https://lion-web-components.netlify.app/?path=/docs/overlays-tooltip--main) | [](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) | [](https://www.npmjs.com/package/@lion/accordion) | Accordion | ✔️ |
|
||||
| [steps](https://lion-web-components.netlify.app/?path=/docs/navigation-steps--main) | [](https://www.npmjs.com/package/@lion/steps) | Multi Step System | n/a |
|
||||
| [tabs](https://lion-web-components.netlify.app/?path=/docs/navigation-tabs--main) | [](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. | |
|
||||
|
|
|
|||
275
packages/accordion/README.md
Normal file
275
packages/accordion/README.md
Normal file
|
|
@ -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`
|
||||
<lion-accordion>
|
||||
<h3 slot="invoker">
|
||||
<button>Lorem</button>
|
||||
</h3>
|
||||
<p slot="content">
|
||||
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
|
||||
</p>
|
||||
<h3 slot="invoker">
|
||||
<button>Laboriosam</button>
|
||||
</h3>
|
||||
<p slot="content">
|
||||
Laboriosam sequi odit cumque, enim aut assumenda itaque quis voluptas est quos fugiat unde
|
||||
labore reiciendis saepe, iure, optio officiis obcaecati quibusdam.
|
||||
</p>
|
||||
</lion-accordion>
|
||||
`;
|
||||
```
|
||||
|
||||
## 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
|
||||
<lion-accordion>
|
||||
<h3 slot="invoker">
|
||||
<button>Lorem</button>
|
||||
</h3>
|
||||
<p slot="content">
|
||||
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
|
||||
</p>
|
||||
<h3 slot="invoker">
|
||||
<button>Laboriosam</button>
|
||||
</h3>
|
||||
<p slot="content">
|
||||
Laboriosam sequi odit cumque, enim aut assumenda itaque quis voluptas est quos fugiat unde
|
||||
labore reiciendis saepe, iure, optio officiis obcaecati quibusdam.
|
||||
</p>
|
||||
</lion-accordion>
|
||||
```
|
||||
|
||||
> 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`
|
||||
<lion-accordion .expanded=${[1]}>
|
||||
<h3 slot="invoker">
|
||||
<button>Lorem</button>
|
||||
</h3>
|
||||
<p slot="content">
|
||||
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
|
||||
</p>
|
||||
<h3 slot="invoker">
|
||||
<button>Laboriosam</button>
|
||||
</h3>
|
||||
<p slot="content">
|
||||
Laboriosam sequi odit cumque, enim aut assumenda itaque quis voluptas est quos fugiat unde
|
||||
labore reiciendis saepe, iure, optio officiis obcaecati quibusdam.
|
||||
</p>
|
||||
</lion-accordion>
|
||||
`;
|
||||
```
|
||||
|
||||
### 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`
|
||||
<lion-accordion>
|
||||
<h3 slot="invoker">
|
||||
<button>Lorem</button>
|
||||
</h3>
|
||||
<p slot="content">
|
||||
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
|
||||
</p>
|
||||
<h3 slot="invoker">
|
||||
<button>Laboriosam</button>
|
||||
</h3>
|
||||
<p slot="content">
|
||||
Laboriosam sequi odit cumque, enim aut assumenda itaque quis voluptas est quos fugiat unde
|
||||
labore reiciendis saepe, iure, optio officiis obcaecati quibusdam.
|
||||
</p>
|
||||
</lion-accordion>
|
||||
`;
|
||||
```
|
||||
|
||||
### 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`
|
||||
<h3>Append</h3>
|
||||
<lion-accordion id="appendAccordion">
|
||||
<h4 slot="invoker">
|
||||
<button>header 1</button>
|
||||
</h4>
|
||||
<p slot="content">content 1</p>
|
||||
<h4 slot="invoker">
|
||||
<button>header 2</button>
|
||||
</h4>
|
||||
<p slot="content">content 2</p>
|
||||
</lion-accordion>
|
||||
<button @click="${this.__handleAppendClick}">
|
||||
Append
|
||||
</button>
|
||||
<hr />
|
||||
<h3>Push</h3>
|
||||
<lion-accordion id="pushTabs">
|
||||
<h4 slot="invoker">
|
||||
<button>header 1</button>
|
||||
</h4>
|
||||
<p slot="content">content 1</p>
|
||||
<h4 slot="invoker">
|
||||
<button>header 2</button>
|
||||
</h4>
|
||||
<p slot="content">content 2</p>
|
||||
${this.__collection.map(
|
||||
item => html`
|
||||
<h4 slot="invoker"><button>${item.invoker}</button></h4>
|
||||
<p slot="content">${item.content}</p>
|
||||
`,
|
||||
)}
|
||||
</lion-accordion>
|
||||
<button @click="${this.__handlePushClick}">
|
||||
Push
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
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` <demo-accordion-add-dynamically></demo-accordion-add-dynamically> `;
|
||||
};
|
||||
```
|
||||
|
||||
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
|
||||
<lion-accordion id="pushAccordion">
|
||||
${myCollection.map(item => html`
|
||||
<h4 slot="invoker">
|
||||
<button>${item.invoker}</button>
|
||||
</h4>
|
||||
<p slot="content">${item.content}</p>
|
||||
`)}
|
||||
</lion-accordion>
|
||||
```
|
||||
|
||||
## 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.
|
||||
1
packages/accordion/index.js
Normal file
1
packages/accordion/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { LionAccordion } from './src/LionAccordion.js';
|
||||
3
packages/accordion/lion-accordion.js
Normal file
3
packages/accordion/lion-accordion.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { LionAccordion } from './src/LionAccordion.js';
|
||||
|
||||
customElements.define('lion-accordion', LionAccordion);
|
||||
42
packages/accordion/package.json
Normal file
42
packages/accordion/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
293
packages/accordion/src/LionAccordion.js
Normal file
293
packages/accordion/src/LionAccordion.js
Normal file
|
|
@ -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');
|
||||
};
|
||||
|
||||
/**
|
||||
* # <lion-accordion> 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`
|
||||
<div class="accordion">
|
||||
<slot name="invoker"></slot>
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
424
packages/accordion/test/lion-accordion.test.js
Normal file
424
packages/accordion/test/lion-accordion.test.js
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
import { expect, fixture, html } from '@open-wc/testing';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import '../lion-accordion.js';
|
||||
|
||||
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>
|
||||
`;
|
||||
|
||||
describe('<lion-accordion>', () => {
|
||||
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`
|
||||
<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(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`
|
||||
<lion-accordion>
|
||||
<h2 slot="invoker"><button>invoker</button></h2>
|
||||
<div slot="content">content 1</div>
|
||||
<div slot="content">content 2</div>
|
||||
</lion-accordion>
|
||||
`);
|
||||
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`
|
||||
<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(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`
|
||||
<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 = 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`
|
||||
<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 = 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`
|
||||
<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 = 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`
|
||||
<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 = 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`
|
||||
<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>
|
||||
`);
|
||||
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`
|
||||
<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 = 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`
|
||||
<lion-accordion>
|
||||
<h2 slot="invoker"><button>invoker</button></h2>
|
||||
<div slot="content">content</div>
|
||||
</lion-accordion>
|
||||
`);
|
||||
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`
|
||||
<lion-accordion>
|
||||
<h2 slot="invoker"><button>invoker</button></h2>
|
||||
<div slot="content">content</div>
|
||||
</lion-accordion>
|
||||
`);
|
||||
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`
|
||||
<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 = 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue