@lion/ui
Version:
A package of extendable web components
438 lines (399 loc) • 11.7 kB
JavaScript
import { css, html, LitElement } from 'lit';
import { uuid } from '@lion/ui/core.js';
/**
* @typedef {Object} StoreEntry
* @property {HTMLElement} el Dom Element
* @property {string} uid Unique ID for the entry
* @property {HTMLElement} button Button HTMLElement for the entry
* @property {HTMLElement} panel Panel HTMLElement for the entry
* @property {(event: Event) => unknown} clickHandler executed on click event
* @property {(event: Event) => unknown} keydownHandler executed on keydown event
* @property {(event: Event) => unknown} keyupHandler executed on keyup event
*/
/**
* @param {StoreEntry} options
*/
function setupPanel({ el, uid }) {
el.setAttribute('id', `panel-${uid}`);
el.setAttribute('role', 'tabpanel');
el.setAttribute('aria-labelledby', `button-${uid}`);
/**
* Facilitates navigation to panel content for assistive technology users.
*
* Focusable tab panel elements are recommended if any panels in a set contain
* content where the first element in the panel is not focusable.
* https://www.w3.org/WAI/ARIA/apg/patterns/tabs/examples/tabs-automatic/
*/
if (!el.hasAttribute('tabindex')) {
el.setAttribute('tabindex', '0');
}
}
/**
* @param {HTMLElement} el
*/
function selectPanel(el) {
el.setAttribute('selected', 'true');
}
/**
* @param {HTMLElement} el
*/
function deselectPanel(el) {
el.removeAttribute('selected');
}
/**
* @param {StoreEntry} options
*/
function setupButton({ el, uid, clickHandler, keydownHandler, keyupHandler }) {
el.setAttribute('id', `button-${uid}`);
el.setAttribute('role', 'tab');
el.setAttribute('aria-controls', `panel-${uid}`);
el.addEventListener('click', clickHandler);
el.addEventListener('keyup', keyupHandler);
el.addEventListener('keydown', keydownHandler);
}
/**
* @param {StoreEntry} options
*/
function cleanButton({ el, clickHandler, keydownHandler, keyupHandler }) {
el.removeAttribute('id');
el.removeAttribute('role');
el.removeAttribute('aria-controls');
el.removeEventListener('click', clickHandler);
el.removeEventListener('keyup', keyupHandler);
el.removeEventListener('keydown', keydownHandler);
}
/**
* @param {HTMLElement} el
* @param {boolean} withFocus
*/
function selectButton(el, withFocus = false) {
if (withFocus) {
el.focus();
}
el.setAttribute('selected', 'true');
el.setAttribute('aria-selected', 'true');
el.setAttribute('tabindex', '0');
}
/**
* @param {HTMLElement} el
*/
function deselectButton(el) {
el.removeAttribute('selected');
el.setAttribute('aria-selected', 'false');
el.setAttribute('tabindex', '-1');
}
/**
* @param {Event} ev
*/
function handleButtonKeydown(ev) {
const _ev = /** @type {KeyboardEvent} */ (ev);
switch (_ev.key) {
case 'ArrowDown':
case 'ArrowRight':
case 'ArrowUp':
case 'ArrowLeft':
case 'Home':
case 'End':
_ev.preventDefault();
/* no default */
}
}
/**
* LionTabs: A tabbed interface component
*
* @slot tab - The tab elements for the tabs
* @slot panel - The panel elements for the tabs
*
* @customElement lion-tabs
*/
export class LionTabs extends LitElement {
static get properties() {
return {
selectedIndex: {
type: Number,
attribute: 'selected-index',
reflect: true,
},
};
}
static get styles() {
return [
css`
.tabs__tab-group {
display: flex;
}
.tabs__tab-group ::slotted([slot='tab'][selected]) {
font-weight: bold;
}
.tabs__panels ::slotted([slot='panel']) {
visibility: hidden;
display: none;
}
.tabs__panels ::slotted([slot='panel'][selected]) {
visibility: visible;
display: block;
}
.tabs__panels {
display: block;
}
`,
];
}
render() {
return html`
<div class="tabs__tab-group" role="tablist">
<slot name="tab"></slot>
</div>
<div class="tabs__panels">
<slot name="panel"></slot>
</div>
`;
}
constructor() {
super();
/**
* An index number of the selected tab
*/
this.selectedIndex = 0;
}
/** @param {import('lit').PropertyValues } changedProperties */
firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.__setupSlots();
if (this.tabs[0]?.disabled) {
this.selectedIndex = this.tabs.findIndex(tab => !tab.disabled);
}
}
get tabs() {
return /** @type {HTMLButtonElement[]} */ (Array.from(this.children)).filter(
child => child.slot === 'tab',
);
}
get panels() {
return /** @type {HTMLElement[]} */ (Array.from(this.children)).filter(
child => child.slot === 'panel',
);
}
/**
* // TODO: check if this is a false positive or if we can improve
* @configure ReactiveElement
*/
static enabledWarnings = super.enabledWarnings?.filter(w => w !== 'change-in-update') || [];
/** @private */
__setupSlots() {
if (this.shadowRoot) {
const tabSlot = this.shadowRoot.querySelector('slot[name=tab]');
const handleSlotChange = () => {
this.__cleanStore();
this.__setupStore();
this.__updateSelected(false);
};
if (tabSlot) {
tabSlot.addEventListener('slotchange', handleSlotChange);
}
}
}
/** @private */
__setupStore() {
/** @type {StoreEntry[]} */
this.__store = [];
if (this.tabs.length !== this.panels.length) {
// eslint-disable-next-line no-console
console.warn(
`The amount of tabs (${this.tabs.length}) doesn't match the amount of panels (${this.panels.length}).`,
);
}
this.tabs.forEach((button, index) => {
const uid = uuid();
const panel = this.panels[index];
/** @type {StoreEntry} */
const entry = {
uid,
el: button,
button,
panel,
clickHandler: this.__createButtonClickHandler(index),
keydownHandler: handleButtonKeydown.bind(this),
keyupHandler: this.__handleButtonKeyup.bind(this),
};
setupPanel({ ...entry, el: entry.panel });
setupButton(entry);
deselectPanel(entry.panel);
deselectButton(entry.button);
if (this.__store) {
this.__store.push(entry);
}
});
}
/** @private */
__cleanStore() {
if (!this.__store) {
return;
}
this.__store.forEach(entry => {
cleanButton(entry);
});
this.__store = [];
}
/**
* @param {HTMLButtonElement[]} tabs
* @param {HTMLButtonElement} currentTab
* @param {'right' | 'left'} dir
* @returns {HTMLButtonElement|undefined}
* @private
*/
__getNextNotDisabledTab(tabs, currentTab, dir) {
let orderedNotDisabledTabs = /** @type {HTMLButtonElement[]} */ ([]);
const nextNotDisabledTabs = tabs.filter((tab, i) => !tab.disabled && i > this.selectedIndex);
const prevNotDisabledTabs = tabs.filter((tab, i) => !tab.disabled && i < this.selectedIndex);
if (dir === 'right') {
orderedNotDisabledTabs = [...nextNotDisabledTabs, ...prevNotDisabledTabs];
} else {
orderedNotDisabledTabs = [...prevNotDisabledTabs.reverse(), ...nextNotDisabledTabs.reverse()];
}
return orderedNotDisabledTabs[0];
}
/**
* @param {number} newIndex
* @param {string} direction
* @returns {number}
* @private
*/
__getNextAvailableIndex(newIndex, direction) {
const currentTab = this.tabs[this.selectedIndex];
if (this.tabs.every(tab => !tab.disabled)) {
return newIndex;
}
if (direction === 'ArrowRight' || direction === 'ArrowDown') {
const nextNotDisabledTab = this.__getNextNotDisabledTab(this.tabs, currentTab, 'right');
return this.tabs.findIndex(tab => nextNotDisabledTab === tab);
}
if (direction === 'ArrowLeft' || direction === 'ArrowUp') {
const nextNotDisabledTab = this.__getNextNotDisabledTab(this.tabs, currentTab, 'left');
return this.tabs.findIndex(tab => nextNotDisabledTab === tab);
}
if (direction === 'Home') {
return this.tabs.findIndex(tab => !tab.disabled);
}
if (direction === 'End') {
const notDisabledTabs = this.tabs
.map((tab, i) => ({ disabled: tab.disabled, index: i }))
.filter(tab => !tab.disabled);
return notDisabledTabs[notDisabledTabs.length - 1].index;
}
return -1;
}
/**
* @param {number} index
* @returns {(event: Event) => unknown}
* @private
*/
__createButtonClickHandler(index) {
return () => {
this._setSelectedIndexWithFocus(index);
};
}
/**
* @param {Event} ev
* @private
*/
__handleButtonKeyup(ev) {
const _ev = /** @type {KeyboardEvent} */ (ev);
if (typeof this.selectedIndex === 'number') {
switch (_ev.key) {
case 'ArrowDown':
case 'ArrowRight':
if (this.selectedIndex + 1 >= this._pairCount) {
this._setSelectedIndexWithFocus(this.__getNextAvailableIndex(0, _ev.key));
} else {
this._setSelectedIndexWithFocus(
this.__getNextAvailableIndex(this.selectedIndex + 1, _ev.key),
);
}
break;
case 'ArrowUp':
case 'ArrowLeft':
if (this.selectedIndex <= 0) {
this._setSelectedIndexWithFocus(
this.__getNextAvailableIndex(this._pairCount - 1, _ev.key),
);
} else {
this._setSelectedIndexWithFocus(
this.__getNextAvailableIndex(this.selectedIndex - 1, _ev.key),
);
}
break;
case 'Home':
this._setSelectedIndexWithFocus(this.__getNextAvailableIndex(0, _ev.key));
break;
case 'End':
this._setSelectedIndexWithFocus(
this.__getNextAvailableIndex(this._pairCount - 1, _ev.key),
);
break;
/* no default */
}
}
}
/**
* @return {number}
*/
get selectedIndex() {
return this.__selectedIndex || 0;
}
/**
* @param {number} value The new index
*/
set selectedIndex(value) {
if (value === this.__selectedIndex) {
return;
}
const stale = this.__selectedIndex;
/** @type {number | undefined} */
this.__selectedIndex = value;
this.__updateSelected(false);
this.dispatchEvent(new Event('selected-changed'));
this.requestUpdate('selectedIndex', stale);
}
/**
* @param {number} value The new index for focus
* @protected
*/
_setSelectedIndexWithFocus(value) {
if (value === -1) {
return;
}
const stale = this.__selectedIndex;
this.__selectedIndex = value;
this.__updateSelected(true);
this.dispatchEvent(new Event('selected-changed'));
this.requestUpdate('selectedIndex', stale);
}
/** @protected */
get _pairCount() {
return (this.__store && this.__store.length) || 0;
}
/** @private */
__updateSelected(withFocus = false) {
if (
!(this.__store && typeof this.selectedIndex === 'number' && this.__store[this.selectedIndex])
) {
return;
}
const previousButton = this.tabs.find(child => child.hasAttribute('selected'));
const previousPanel = this.panels.find(child => child.hasAttribute('selected'));
if (previousButton) {
deselectButton(previousButton);
}
if (previousPanel) {
deselectPanel(previousPanel);
}
const { button: currentButton, panel: currentPanel } = this.__store[this.selectedIndex];
if (currentButton) {
selectButton(currentButton, withFocus);
}
if (currentPanel) {
selectPanel(currentPanel);
}
}
}