@georapbox/a-tab-group
Version:
A custom element to create a group of tabs and tab panels.
242 lines (207 loc) • 6.64 kB
JavaScript
// @ts-check
import { uid } from './utils/uid.js';
import { upgradeProperty } from './utils/upgrade-property.js';
let tabCounter = 0;
const styles = /* css */ `
:host {
box-sizing: border-box;
display: inline-block;
contain: content;
}
:host([hidden]),
[hidden] {
display: none !important;
}
:host *,
:host *::before,
:host *::after {
box-sizing: inherit;
}
.tab {
display: inline-flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.375rem 0.75rem;
white-space: nowrap;
cursor: pointer;
}
:host([disabled]) .tab {
opacity: 0.7;
cursor: not-allowed;
}
:host([selected]) .tab {
color: var(--selected-tab-color);
background-color: var(--selected-tab-bg-color);
}
.tab__close {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
font-size: inherit;
cursor: pointer;
}
`;
const template = document.createElement('template');
template.innerHTML = /* html */ `
<style>
${styles}
</style>
<div part="base" class="tab">
<slot></slot>
</div>
`;
/**
* @summary
* This is a tab for a <a-tab-group> tab panel.
* `<a-tab>` should always be used with `role=heading` in the markup so that the semantics remain useable when JavaScript is failing.
* @documentation https://github.com/georapbox/a-tab-group
*
* @tagname a-tab
* @extends HTMLElement
*
* @property {boolean} selected - Whether the tab is selected.
* @property {boolean} disabled - Whether the tab is disabled.
* @property {boolean} closable - Whether the tab is closable.
*
* @attribute selected - Reflects the selected property.
* @attribute disabled - Reflects the disabled property.
* @attribute closable - Reflects the closable property.
*
* @csspart base - The component's base wrapper.
* @csspart close-tab - The close button.
* @csspart close-tab-icon - The close button icon.
*
* @slot - The tab's content.
*
* @event a-tab-close - Fires when the tab's close button is clicked.
*/
class ATab extends HTMLElement {
constructor() {
super();
if (!this.shadowRoot) {
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(template.content.cloneNode(true));
}
}
static get observedAttributes() {
return ['selected', 'disabled', 'closable'];
}
/**
* Lifecycle method that is called when attributes are changed, added, removed, or replaced.
*
* @param {string} name - The name of the attribute.
* @param {string} oldValue - The old value of the attribute.
* @param {string} newValue - The new value of the attribute.
*/
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'selected' && oldValue !== newValue) {
this.setAttribute('aria-selected', this.selected.toString());
this.setAttribute('tabindex', this.disabled || !this.selected ? '-1' : '0');
}
if (name === 'disabled' && oldValue !== newValue) {
this.setAttribute('aria-disabled', this.disabled.toString());
this.setAttribute('tabindex', this.disabled || !this.selected ? '-1' : '0');
}
if (name === 'closable' && oldValue !== newValue) {
if (this.closable) {
const closeButton = document.createElement('span');
closeButton.className = 'tab__close';
closeButton.setAttribute('part', 'close-tab');
closeButton.innerHTML = /* html */ `<svg part="close-tab-icon" xmlns="http://www.w3.org/2000/svg" width="0.875em" height="0.875em" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/></svg>`;
this.shadowRoot?.querySelector('.tab')?.appendChild(closeButton);
closeButton.addEventListener('click', this.#handleCloseButtonClick);
} else {
const closeButton = this.shadowRoot?.querySelector('.tab__close');
closeButton?.removeEventListener('click', this.#handleCloseButtonClick);
closeButton?.remove();
}
}
}
/**
* Lifecycle method that is called when the element is first connected to the DOM.
*/
connectedCallback() {
this.#upgradeProperty('selected');
this.#upgradeProperty('disabled');
this.#upgradeProperty('closable');
if (!this.id) {
this.id = uid('tab', (++tabCounter).toString());
}
this.setAttribute('slot', 'tab');
this.setAttribute('role', 'tab');
this.setAttribute('aria-selected', 'false');
this.setAttribute('tabindex', this.disabled || !this.selected ? '-1' : '0');
}
/**
* Lifecycle method that is called when the element is disconnected from the DOM.
*/
disconnectedCallback() {
const closeButton = this.shadowRoot?.querySelector('.tab__close');
closeButton?.removeEventListener('click', this.#handleCloseButtonClick);
}
/**
* @type {boolean} - Whether the tab is selected.
* @default false
* @attribute selected - Reflects the selected property.
*/
get selected() {
return this.hasAttribute('selected');
}
set selected(value) {
this.toggleAttribute('selected', !!value);
}
/**
* @type {boolean} - Whether the tab is disabled.
* @default false
* @attribute disabled - Reflects the disabled property.
*/
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(value) {
this.toggleAttribute('disabled', !!value);
}
/**
* @type {boolean} - Whether the tab is closable.
* @default false
* @attribute closable - Reflects the closable property.
*/
get closable() {
return this.hasAttribute('closable');
}
set closable(value) {
this.toggleAttribute('closable', !!value);
}
/**
* Handles the click event on the close button.
*
* @param {Event} evt - The click event.
*/
#handleCloseButtonClick = evt => {
evt.stopPropagation();
this.dispatchEvent(
new CustomEvent(`a-tab-close`, {
bubbles: true,
composed: true,
detail: { tabId: this.id }
})
);
};
/**
* Wrapper for the `upgradeProperty` function.
*
* @param {'selected' | 'disabled' | 'closable'} prop - The property to upgrade.
*/
#upgradeProperty(prop) {
return upgradeProperty(prop, this);
}
static defineCustomElement(elementName = 'a-tab') {
if (typeof window !== 'undefined' && !window.customElements.get(elementName)) {
window.customElements.define(elementName, ATab);
}
}
}
ATab.defineCustomElement();
export { ATab };