lion/packages/ui/components/accordion/LionAccordion.js
2022-10-31 16:55:07 +01:00

438 lines
10 KiB
JavaScript

/* eslint-disable class-methods-use-this */
import { LitElement, css, html } from 'lit';
import { uuid } from '@lion/ui/core.js';
/**
* @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 {(event: Event) => unknown} clickHandler executed on click event
* @property {(event: Event) => unknown} keydownHandler executed on keydown event
*/
/**
* # <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 [slot='invoker'] {
margin: 0;
}
.accordion [slot='invoker'][expanded] {
font-weight: bold;
}
.accordion [slot='content'] {
margin: 0;
visibility: hidden;
display: none;
}
.accordion [slot='content'][expanded] {
visibility: visible;
display: block;
}
`,
];
}
/**
* @param {number} value
*/
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;
}
/**
* @param {number[]} value
*/
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;
}
constructor() {
super();
this.styles = {};
/**
* @type {StoreEntry[]}
* @private
*/
this.__store = [];
/**
* @type {number}
* @private
*/
this.__focusedIndex = -1;
/**
* @type {number[]}
* @private
*/
this.__expanded = [];
}
/** @param {import('@lion/core').PropertyValues } changedProperties */
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__setupSlots();
}
render() {
return html`
<div class="accordion">
<slot name="invoker"></slot>
<slot name="content"></slot>
<slot name="_accordion"></slot>
</div>
`;
}
/**
* @private
*/
__setupSlots() {
const invokerSlot = /** @type {HTMLSlotElement} */ (
this.shadowRoot?.querySelector('slot[name=invoker]')
);
const handleSlotChange = () => {
if (invokerSlot.assignedNodes().length > 0) {
this.__cleanStore();
this.__setupStore();
this.__updateFocused();
this.__updateExpanded();
}
};
if (invokerSlot) {
invokerSlot.addEventListener('slotchange', handleSlotChange);
}
}
/**
* @private
*/
__setupStore() {
const accordion = this.shadowRoot?.querySelector('slot[name=_accordion]');
const existingInvokers = accordion ? accordion.querySelectorAll('[slot=invoker]') : [];
const existingContent = accordion ? accordion.querySelectorAll('[slot=content]') : [];
const invokers = /** @type {HTMLElement[]} */ ([
...Array.from(existingInvokers),
...Array.from(this.querySelectorAll('[slot="invoker"]')),
]);
const contents = /** @type {HTMLElement[]} */ ([
...Array.from(existingContent),
...Array.from(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];
/** @type {StoreEntry} */
const entry = {
uid,
index,
invoker,
content,
clickHandler: this.__createInvokerClickHandler(index),
keydownHandler: this.__handleInvokerKeydown.bind(this),
};
this._setupContent(entry);
this._setupInvoker(entry);
this._unfocusInvoker(entry);
this._collapse(entry);
this.__store.push(entry);
});
this.__rearrangeInvokersAndContent();
}
/**
* @private
*
* Moves all invokers and content to slot[name=_accordion] in correct order so focus works
* correctly when the user tabs.
*/
__rearrangeInvokersAndContent() {
const invokers = /** @type {HTMLElement[]} */ (
Array.from(this.querySelectorAll('[slot="invoker"]'))
);
const contents = /** @type {HTMLElement[]} */ (
Array.from(this.querySelectorAll('[slot="content"]'))
);
const accordion = this.shadowRoot?.querySelector('slot[name=_accordion]');
if (accordion) {
invokers.forEach((invoker, index) => {
accordion.insertAdjacentElement('beforeend', invoker);
accordion.insertAdjacentElement('beforeend', contents[index]);
});
}
}
/**
* @param {number} index
* @private
*/
__createInvokerClickHandler(index) {
return () => {
this.focusedIndex = index;
this.__toggleExpanded(index);
};
}
/**
* @param {Event} e
* @private
*/
__handleInvokerKeydown(e) {
const _e = /** @type {KeyboardEvent} */ (e);
switch (_e.key) {
case 'ArrowDown':
case 'ArrowRight':
_e.preventDefault();
if (this.focusedIndex + 2 <= this._pairCount) {
this.focusedIndex += 1;
}
break;
case 'ArrowUp':
case 'ArrowLeft':
_e.preventDefault();
if (this.focusedIndex >= 1) {
this.focusedIndex -= 1;
}
break;
case 'Home':
_e.preventDefault();
this.focusedIndex = 0;
break;
case 'End':
_e.preventDefault();
this.focusedIndex = this._pairCount - 1;
break;
/* no default */
}
}
/**
* @private
*/
get _pairCount() {
return this.__store.length;
}
/**
* @param {StoreEntry} entry
* @protected
*/
_setupContent(entry) {
const { content, index, uid } = entry;
content.style.setProperty('order', `${index + 1}`);
content.setAttribute('id', `content-${uid}`);
content.setAttribute('aria-labelledby', `invoker-${uid}`);
}
/**
* @param {StoreEntry} entry
* @protected
*/
_setupInvoker(entry) {
const { invoker, uid, index, clickHandler, keydownHandler } = entry;
invoker.style.setProperty('order', `${index + 1}`);
const firstChild = invoker.firstElementChild;
if (firstChild) {
firstChild.setAttribute('id', `invoker-${uid}`);
firstChild.setAttribute('aria-controls', `content-${uid}`);
firstChild.addEventListener('click', clickHandler);
firstChild.addEventListener('keydown', keydownHandler);
}
}
/**
* @param {StoreEntry} entry
* @protected
*/
_cleanInvoker(entry) {
const { invoker, clickHandler, keydownHandler } = entry;
const firstChild = invoker.firstElementChild;
if (firstChild) {
firstChild.removeAttribute('id');
firstChild.removeAttribute('aria-controls');
firstChild.removeEventListener('click', clickHandler);
firstChild.removeEventListener('keydown', keydownHandler);
}
}
/**
* @param {StoreEntry} entry
* @protected
*/
_focusInvoker(entry) {
const { invoker } = entry;
const firstChild = /** @type {HTMLElement|null} */ (invoker.firstElementChild);
if (firstChild) {
firstChild.focus();
firstChild.setAttribute('focused', `${true}`);
}
}
/**
* @param {StoreEntry} entry
* @protected
*/
_unfocusInvoker(entry) {
const { invoker } = entry;
const firstChild = invoker.firstElementChild;
if (firstChild) {
firstChild.removeAttribute('focused');
}
}
/**
* @param {StoreEntry} entry
* @protected
*/
_collapse(entry) {
const { content, invoker } = entry;
content.removeAttribute('expanded');
invoker.removeAttribute('expanded');
const firstChild = invoker.firstElementChild;
if (firstChild) {
firstChild.removeAttribute('expanded');
firstChild.setAttribute('aria-expanded', `${false}`);
}
}
/**
* @param {StoreEntry} entry
* @protected
*/
_expand(entry) {
const { content, invoker } = entry;
content.setAttribute('expanded', `${true}`);
invoker.setAttribute('expanded', `${true}`);
const firstChild = invoker.firstElementChild;
if (firstChild) {
firstChild.setAttribute('expanded', `${true}`);
firstChild.setAttribute('aria-expanded', `${true}`);
}
}
/**
* @private
*/
__updateFocused() {
const focusedEntry = this.__store[this.focusedIndex];
const previousFocusedEntry = Array.from(this.__store).find(
entry => entry.invoker && entry.invoker.firstElementChild?.hasAttribute('focused'),
);
if (previousFocusedEntry) {
this._unfocusInvoker(previousFocusedEntry);
}
if (focusedEntry) {
this._focusInvoker(focusedEntry);
}
}
/**
* @private
*/
__updateExpanded() {
if (!this.__store) {
return;
}
this.__store.forEach((entry, index) => {
const entryExpanded = this.expanded.indexOf(index) !== -1;
if (entryExpanded) {
this._expand(entry);
} else {
this._collapse(entry);
}
});
}
/**
* @param {number} value
* @private
*/
__toggleExpanded(value) {
const { expanded } = this;
const index = expanded.indexOf(value);
if (index === -1) {
expanded.push(value);
} else {
expanded.splice(index, 1);
}
this.expanded = expanded;
}
/**
* @private
*/
__cleanStore() {
if (!this.__store) {
return;
}
this.__store.forEach(entry => {
this._cleanInvoker(entry);
});
this.__store = [];
}
}