UNPKG

@georapbox/a-tab-group

Version:

A custom element to create a group of tabs and tab panels.

985 lines (847 loc) 29.2 kB
// @ts-check /** * @template T * @typedef {T | null} Nullable */ /** @typedef {import('./a-tab').ATab} Tab */ /** @typedef {import('./a-tab-panel').ATabPanel} TabPanel */ export { ATab } from './a-tab.js'; export { ATabPanel } from './a-tab-panel.js'; import { upgradeProperty } from './utils/upgrade-property.js'; /** * The default scroll distance in pixels that the * tabs will scroll when the scroll buttons are clicked. */ const DEFAULT_SCROLL_DISTANCE = 200; /** * The available placements for the tabs. */ const PLACEMENT = { TOP: 'top', BOTTOM: 'bottom', START: 'start', END: 'end' }; /** * The available directionality values for the tab group. */ const DIR = { LTR: 'ltr', RTL: 'rtl' }; /** * The valid placements for the tabs. */ const validPlacements = Object.entries(PLACEMENT).map(([, value]) => value); /** * The available activation modes for the tabs. */ const ACTIVATION = { AUTO: 'auto', MANUAL: 'manual' }; /** * Defines key codes to help with handling keyboard events. */ const KEYCODE = { DOWN: 'ArrowDown', LEFT: 'ArrowLeft', RIGHT: 'ArrowRight', UP: 'ArrowUp', HOME: 'Home', END: 'End', ENTER: 'Enter', SPACE: ' ' }; const styles = /* css */ ` :host { --selected-tab-color: #005fcc; --selected-tab-bg-color: transparent; --tabs-scroll-behavior: smooth; --scroll-button-width: 2.125em; --scroll-button-height: 2.125em; --scroll-button-inline-offset: 0rem; box-sizing: border-box; display: block; contain: content; } @media (prefers-reduced-motion: reduce) { :host { --tabs-scroll-behavior: auto; } } :host([hidden]), [hidden], ::slotted([hidden]) { display: none !important; } :host *, :host *::before, :host *::after { box-sizing: inherit; } .tab-group { display: flex; width: 100%; } .tab-group__nav { position: relative; } .tab-group__nav--has-scroll-controls { padding: 0 calc(var(--scroll-button-width) + var(--scroll-button-inline-offset)); } .tab-group__scroll-button { display: flex; justify-content: center; align-items: center; position: absolute; top: 50%; transform: translateY(-50%); width: var(--scroll-button-width); height: var(--scroll-button-height); padding: 0; /* Required for iOS, otherwise the svg is not visible: https://stackoverflow.com/questions/66532071/flex-svg-behaving-strange-in-ios-safari-14-0-3 */ border: 0; z-index: 1; background-color: transparent; font-size: inherit; cursor: pointer; color: currentColor; } .tab-group__scroll-button--start { left: var(--scroll-button-inline-offset); } .tab-group__scroll-button--end { right: var(--scroll-button-inline-offset); } :host([dir="${DIR.RTL}"]) .tab-group__scroll-button--start, :host(:dir(${DIR.RTL})) .tab-group__scroll-button--start { right: var(--scroll-button-inline-offset); left: auto; transform: translateY(-50%) rotate(180deg); } :host([dir="${DIR.RTL}"]) .tab-group__scroll-button--end, :host(:dir(${DIR.RTL})) .tab-group__scroll-button--end { left: var(--scroll-button-inline-offset); right: auto; transform: translateY(-50%) rotate(180deg); } .tab-group__tabs { display: flex; padding: 0.25rem; overflow-x: auto; scroll-behavior: var(--tabs-scroll-behavior); scrollbar-width: none; } .tab-group__tabs::-webkit-scrollbar { display: none; } .tab-group__panels { padding: 1rem 0; } /* placement="top" */ .tab-group, :host([placement="${PLACEMENT.TOP}"]) .tab-group { flex-direction: column; } /* placement="bottom" */ :host([placement="${PLACEMENT.BOTTOM}"]) .tab-group { flex-direction: column; } :host([placement="${PLACEMENT.BOTTOM}"]) .tab-group__nav { order: 1; } /* placement="start" */ :host([placement="${PLACEMENT.START}"]) .tab-group { flex-direction: row; } :host([placement="${PLACEMENT.START}"]) .tab-group__tabs { flex-direction: column; align-items: flex-start; } :host([placement="${PLACEMENT.START}"]) .tab-group__panels { flex: 1; padding: 0 1rem; } /* placement="end" */ :host([placement="${PLACEMENT.END}"]) .tab-group { flex-direction: row; } :host([placement="${PLACEMENT.END}"]) .tab-group__nav { order: 1; } :host([placement="${PLACEMENT.END}"]) .tab-group__tabs { flex-direction: column; align-items: flex-start; } :host([placement="${PLACEMENT.END}"]) .tab-group__panels { flex: 1; padding: 0 1rem; } `; const template = document.createElement('template'); template.innerHTML = /* html */ ` <style>${styles}</style> <div part="base" class="tab-group"> <div part="nav" class="tab-group__nav"> <button type="button" part="scroll-button scroll-button--start" class="tab-group__scroll-button tab-group__scroll-button--start" aria-label="Scroll to start"> <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" viewBox="0 0 16 16" part="scroll-button-icon"> <path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z"/> </svg> </button> <div part="tabs" class="tab-group__tabs" role="tablist" tabindex="-1"> <slot name="tab"></slot> </div> <button type="button" part="scroll-button scroll-button--end" class="tab-group__scroll-button tab-group__scroll-button--end" aria-label="Scroll to end"> <svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1em" fill="currentColor" viewBox="0 0 16 16" part="scroll-button-icon"> <path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/> </svg> </button> </div> <div part="panels" class="tab-group__panels"> <slot name="panel"></slot> </div> </div> `; /** * @summary Container element for tabs and panels. All children of <a-tab-group> should be either `<a-tab>` or `<a-tab-panel>`. * @documentation https://github.com/georapbox/a-tab-group * * @tagname a-tab-group * @extends HTMLElement * * @property {string} placement - The placement of the tabs. * @property {boolean} noScrollControls - Whether or not the scroll controls are enabled. * @property {number} scrollDistance - The distance in pixels that the tabs will scroll when the scroll buttons are clicked. * @property {string} activation - The activation mode of the tabs. * @property {boolean} noTabCycling - Whether or not the tabs should cycle when reaching the first or last tab using the keyboard. * * @attribute placement - Reflects the placement property. * @attribute no-scroll-controls - Reflects the noScrollControls property. * @attribute scroll-distance - Reflects the scrollDistance property. * @attribute activation - Reflects the activation property. * @attribute no-tab-cycling - Reflects the noTabCycling property. * * @slot tab - Used for groupping tabs in the tab group. Must be <a-tab> elements. * @slot panel - Used for groupping tab panels in the tab group. Must be <a-tab-panel> elements. * * @csspart base - The component's base wrapper. * @csspart nav - The nav container. * @csspart nav--has-scroll-controls - The nav container when the scroll controls are enabled and visible. * @csspart scroll-button - The scroll button. * @csspart scroll-button--start - The scroll button for scrolling towards the start. * @csspart scroll-button--end - The scroll button for scrolling towards the end. * @csspart scroll-button-icon - The scroll button icon. * @csspart tabs - The tabs container. * @csspart panels - The panels container. * * @cssproperty --selected-tab-color - The color of the selected tab. * @cssproperty --selected-tab-bg-color - The background color of the selected tab. * @cssproperty --tabs-scroll-behavior - The scroll behavior of the tabs container. * @cssproperty --scroll-button-width - The width of the scroll buttons. * @cssproperty --scroll-button-height - The height of the scroll buttons. * @cssproperty --scroll-button-inline-offset - The inline offset of the scroll buttons. * * @event a-tab-show - Fired when a tab is shown. * @event a-tab-hide - Fired when a tab is shown. * * @method selectTabByIndex - Selects the tab at the given index. * @method selectTabById - Selects the tab with the given id. * @method selectTab - Selects the given tab. */ class ATabGroup extends HTMLElement { /** @type {Nullable<ResizeObserver>} */ #resizeObserver = null; /** @type {Nullable<number>} */ #rafId = null; /** @type {boolean} */ #hasTabSlotChangedOnce = false; constructor() { super(); if (!this.shadowRoot) { const shadowRoot = this.attachShadow({ mode: 'open' }); shadowRoot.appendChild(template.content.cloneNode(true)); } } static get observedAttributes() { return ['placement', 'no-scroll-controls']; } /** * 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 === 'placement' && oldValue !== newValue) { this.#syncNav(); } if (name === 'no-scroll-controls' && oldValue !== newValue) { this.#syncNav(); } } /** * @type {Nullable<string>} - The placement of the tabs. * @default 'top' * @attribute placement - Reflects the placement property. */ get placement() { return this.getAttribute('placement') || PLACEMENT.TOP; } set placement(value) { if (value != null) { this.setAttribute('placement', value); } } /** * @type {boolean} - Whether or not the scroll controls are enabled. * @default false * @attribute no-scroll-controls - Reflects the noScrollControls property. */ get noScrollControls() { return this.hasAttribute('no-scroll-controls'); } set noScrollControls(value) { this.toggleAttribute('no-scroll-controls', !!value); } /** * @type {number} - The distance in pixels that the tabs will scroll when the scroll buttons are clicked. * @default 200 * @attribute scroll-distance - Reflects the scrollDistance property. */ get scrollDistance() { const value = Number(this.getAttribute('scroll-distance')); return Math.abs(value) || DEFAULT_SCROLL_DISTANCE; } set scrollDistance(value) { this.setAttribute('scroll-distance', Math.abs(value).toString() || DEFAULT_SCROLL_DISTANCE.toString()); } /** * @type {string} - The activation mode of the tabs. * @default 'auto' * @attribute activation - Reflects the activation property. */ get activation() { return this.getAttribute('activation') || ACTIVATION.AUTO; } set activation(value) { this.setAttribute('activation', value || ACTIVATION.AUTO); } /** * @type {boolean} - Whether or not the tabs should cycle when reaching the first or last tab using the keyboard. * @default false * @attribute no-tab-cycling - Reflects the noTabCycling property. */ get noTabCycling() { return this.hasAttribute('no-tab-cycling'); } set noTabCycling(value) { this.toggleAttribute('no-tab-cycling', !!value); } /** * Lifecycle method that is called when the element is first connected to the DOM. */ connectedCallback() { this.#upgradeProperty('placement'); this.#upgradeProperty('noScrollControls'); this.#upgradeProperty('scrollDistance'); this.#upgradeProperty('activation'); this.#upgradeProperty('noTabCycling'); const tabSlot = this.shadowRoot?.querySelector('slot[name=tab]'); const panelSlot = this.shadowRoot?.querySelector('slot[name=panel]'); const tabsContainer = this.shadowRoot?.querySelector('.tab-group__tabs'); const navContainer = this.shadowRoot?.querySelector('.tab-group__nav'); const scrollButtons = Array.from(this.shadowRoot?.querySelectorAll('.tab-group__scroll-button') || []); tabSlot?.addEventListener('slotchange', this.#handleSlotChange); panelSlot?.addEventListener('slotchange', this.#handleSlotChange); tabsContainer?.addEventListener('click', this.#handleTabClick); tabsContainer?.addEventListener('keydown', this.#handleKeyDown); scrollButtons.forEach(el => el.addEventListener('click', this.#handleScrollButtonClick)); this.addEventListener('a-tab-close', this.#handleTabClose); if ('ResizeObserver' in window) { this.#resizeObserver = new ResizeObserver(entries => { this.#rafId = window.requestAnimationFrame(() => { const entry = entries?.[0]; const targetElement = entry?.target; const isElementScrollable = targetElement?.scrollWidth > targetElement?.clientWidth; scrollButtons.forEach(el => el.toggleAttribute('hidden', !isElementScrollable)); navContainer?.part.toggle('nav--has-scroll-controls', isElementScrollable); navContainer?.classList.toggle('tab-group__nav--has-scroll-controls', isElementScrollable); }); }); } this.#hideEmptyTabGroup(); this.#syncNav(); } /** * Lifecycle method that is called when the element is disconnected from the DOM. */ disconnectedCallback() { const tabSlot = this.shadowRoot?.querySelector('slot[name=tab]'); const panelSlot = this.shadowRoot?.querySelector('slot[name=panel]'); const tabsContainer = this.shadowRoot?.querySelector('.tab-group__tabs'); const scrollButtons = Array.from(this.shadowRoot?.querySelectorAll('.tab-group__scroll-button') || []); tabSlot?.removeEventListener('slotchange', this.#handleSlotChange); panelSlot?.removeEventListener('slotchange', this.#handleSlotChange); tabsContainer?.removeEventListener('click', this.#handleTabClick); tabsContainer?.removeEventListener('keydown', this.#handleKeyDown); scrollButtons.forEach(el => el.removeEventListener('click', this.#handleScrollButtonClick)); this.removeEventListener('a-tab-close', this.#handleTabClose); this.#stopResizeObserver(); } /** * Starts observing the tabs container for resize events. */ #startResizeObserver() { if (!this.#resizeObserver) { return; } const scrollElement = this.shadowRoot?.querySelector('.tab-group__tabs'); if (scrollElement) { this.#resizeObserver.unobserve(scrollElement); this.#resizeObserver.observe(scrollElement); } } /** * Stops observing the tabs container for resize events. */ #stopResizeObserver() { if (!this.#resizeObserver) { return; } this.#resizeObserver.disconnect(); if (this.#rafId !== null) { window.cancelAnimationFrame(this.#rafId); this.#rafId = null; } } /** * Gets the directionality of the tab group. * * @returns {'ltr' | 'rtl'} - The direction of the tab group. */ #getDirectionality() { return /** @type {'ltr' | 'rtl'} */ ( window.CSS.supports('selector(:dir(ltr))') ? this.matches(':dir(ltr)') ? DIR.LTR : DIR.RTL : window.getComputedStyle(this).direction || DIR.LTR ); } /** * Hides the tab group if there are no tabs. */ #hideEmptyTabGroup() { this.hidden = this.#allTabs().length === 0; } /** * Links up tabs with their adjacent panels using `aria-controls` and `aria-labelledby`. * This method makes sure only one tab is selected at a time. */ #linkPanels() { const tabs = this.#allTabs(); this.#hideEmptyTabGroup(); // Give each panel a `aria-labelledby` attribute that refers to the tab that controls it. tabs.forEach(tab => { const panel = tab.nextElementSibling; if (!panel || panel.tagName.toLowerCase() !== 'a-tab-panel') { return console.error(`Tab #${tab.id} is not a sibling of a <a-tab-panel>`); } tab.setAttribute('aria-controls', panel.id); panel.setAttribute('aria-labelledby', tab.id); }); } /** * Get all panels in the tab group. * * @returns {TabPanel[]} - All the panels in the tab group. */ #allPanels() { return Array.from(this.querySelectorAll('a-tab-panel')); } /** * Get all tabs in the tab group. * * @returns {Tab[]} - All the tabs in the tab group. */ #allTabs() { return Array.from(this.querySelectorAll('a-tab')); } /** * Get the panel for the given tab. * * @param {Tab} tab - The tab whose panel is to be returned. * @returns {Nullable<TabPanel>} - The panel controlled by the given tab. */ #panelForTab(tab) { const panelId = tab.getAttribute('aria-controls'); return this.querySelector(`#${panelId}`); } /** * Get the first non-disabled tab in the tab group. * * @returns {Nullable<Tab>} - The first tab in the tab group. */ #firstTab() { const tabs = this.#allTabs(); return tabs.find(tab => !tab.disabled) || null; } /** * Get the last non-disabled tab in the tab group. * * @returns {Nullable<Tab>} - The last tab in the tab group. */ #lastTab() { const tabs = this.#allTabs(); for (let i = tabs.length - 1; i >= 0; i--) { if (!tabs[i].disabled) { return tabs[i]; } } return null; } /** * Get the tab that comes before the currently selected one, wrapping around when reaching the first tab. * If the currently selected tab is disabled, the method will skip it. * * @returns {Nullable<Tab>} - The previous tab. */ #prevTab() { const tabs = this.#allTabs(); let newIdx = this.activation === ACTIVATION.MANUAL ? tabs.findIndex(tab => tab.matches(':focus')) - 1 : tabs.findIndex(tab => tab.selected) - 1; // Keep looping until we find a non-disabled tab. while (tabs[(newIdx + tabs.length) % tabs.length].disabled) { newIdx--; } // Stop cycling through tabs if we reach the beginning and tab cycling is disabled. if (this.noTabCycling && newIdx < 0) { return null; } // Add `tabs.length` to make sure the index is a positive number and get the modulus to wrap around if necessary. return tabs[(newIdx + tabs.length) % tabs.length]; } /** * Get the tab that comes after the currently selected one, wrapping around when reaching the last tab. * If the currently selected tab is disabled, the method will skip it. * * @returns {Nullable<Tab>} - The next tab. */ #nextTab() { const tabs = this.#allTabs(); let newIdx = this.activation === ACTIVATION.MANUAL ? tabs.findIndex(tab => tab.matches(':focus')) + 1 : tabs.findIndex(tab => tab.selected) + 1; // Keep looping until we find a non-disabled tab. while (tabs[newIdx % tabs.length].disabled) { newIdx++; } // Stop cycling through tabs if we reach the end and tab cycling is disabled. if (this.noTabCycling && newIdx >= tabs.length) { return null; } return tabs[newIdx % tabs.length]; } /** * Marks all tabs as unselected and hides all the panels. * This is called every time the user selects a new tab. */ #reset() { const tabs = this.#allTabs(); const panels = this.#allPanels(); tabs.forEach(tab => (tab.selected = false)); panels.forEach(panel => (panel.hidden = true)); } /** * Syncs the tab group navigation with the current state of the tab group. * * This is called every time the user: * - adds or removes a tab or panel * - changes the placement of the tabs * - enables or disables the scroll controls * * If the tabs container is scrollable and the scroll controls are enabled, * the scroll buttons are displayed and the resize observer is started, * otherwise they are hidden and the resize observer is stopped. */ #syncNav() { const navContainer = this.shadowRoot?.querySelector('.tab-group__nav'); const tabsContainer = this.shadowRoot?.querySelector('.tab-group__tabs'); /** @type {HTMLButtonElement[]} */ const scrollButtons = Array.from(this.shadowRoot?.querySelectorAll('.tab-group__scroll-button') || []); if (this.noScrollControls || this.placement === PLACEMENT.START || this.placement === PLACEMENT.END) { this.#stopResizeObserver(); scrollButtons.forEach(el => (el.hidden = true)); navContainer?.part.remove('nav--has-scroll-controls'); navContainer?.classList.remove('tab-group__nav--has-scroll-controls'); tabsContainer?.setAttribute('aria-orientation', 'vertical'); } else { this.#startResizeObserver(); scrollButtons.forEach(el => (el.hidden = false)); tabsContainer?.setAttribute('aria-orientation', 'horizontal'); } } /** * Sets the selected tab when the slot changes. * This is called every time the user adds or removes a tab or panel. * This is useful when the user closes the selected tab and we need to select a new one. */ #setSelectedTabOnSlotChange() { const tabs = this.#allTabs(); // Get the selected non-disabled tab, or the first non-disabled tab. const tab = tabs.find(tab => tab.selected && !tab.disabled) || tabs.find(tab => !tab.disabled); if (tab) { if (this.#hasTabSlotChangedOnce && !tab.selected) { this.dispatchEvent( new CustomEvent('a-tab-show', { bubbles: true, composed: true, detail: { tabId: tab.id } }) ); } this.#setSelectedTab(tab); } } /** * Sets the given tab as selected and shows the panel corresponding to the given tab. * Differentiates from `selectTab()` in that it does not emit any events or focus the tab. * Used internally to set the selected tab when a tab is selected by a side effect, * eg. when a tabs and panels are added or removeda and there is a need to select a new tab. * * @param {Tab} tab - The tab to be selected. */ #setSelectedTab(tab) { this.#reset(); if (tab) { tab.selected = true; } const panel = this.#panelForTab(tab); if (panel) { panel.hidden = false; } } /** * Handles the slotchange event on the tab group. * This is called every time the user adds or removes a tab or panel. * * @param {any} evt - The slotchange event. */ #handleSlotChange = evt => { this.#linkPanels(); this.#syncNav(); this.#setSelectedTabOnSlotChange(); if (evt.target.name === 'tab') { this.#hasTabSlotChangedOnce = true; } }; /** * Handles key events on the tab group. * * @param {any} evt - The keydown event. */ #handleKeyDown = evt => { if ( evt.target.tagName.toLowerCase() !== 'a-tab' || // Ignore any key presses that have a modifier. evt.altKey // Don’t handle modifier shortcuts typically used by assistive technology. ) { return; } const placement = validPlacements.includes(this.placement || '') ? this.placement : PLACEMENT.TOP; const orientation = [PLACEMENT.TOP, PLACEMENT.BOTTOM].includes(placement || '') ? 'horizontal' : 'vertical'; const directionality = this.#getDirectionality(); let tab = null; switch (evt.key) { case KEYCODE.LEFT: if (orientation === 'horizontal') { tab = directionality === DIR.LTR ? this.#prevTab() : this.#nextTab(); if (tab) { this.activation === ACTIVATION.MANUAL ? tab.focus() : this.selectTab(tab); } } break; case KEYCODE.RIGHT: if (orientation === 'horizontal') { tab = directionality === DIR.LTR ? this.#nextTab() : this.#prevTab(); if (tab) { this.activation === ACTIVATION.MANUAL ? tab.focus() : this.selectTab(tab); } } break; case KEYCODE.UP: if (orientation === 'vertical') { tab = this.#prevTab(); if (tab) { this.activation === ACTIVATION.MANUAL ? tab.focus() : this.selectTab(tab); } } break; case KEYCODE.DOWN: if (orientation === 'vertical') { tab = this.#nextTab(); if (tab) { this.activation === ACTIVATION.MANUAL ? tab.focus() : this.selectTab(tab); } } break; case KEYCODE.HOME: tab = this.#firstTab(); if (tab) { this.activation === ACTIVATION.MANUAL ? tab.focus() : this.selectTab(tab); } break; case KEYCODE.END: tab = this.#lastTab(); if (tab) { this.activation === ACTIVATION.MANUAL ? tab.focus() : this.selectTab(tab); } break; case KEYCODE.ENTER: case KEYCODE.SPACE: tab = evt.target; if (tab) { this.selectTab(tab); } break; default: // Any other key press is ignored and passed back to the browser. return; } // The browser might have some native functionality bound to the arrow keys, home or end. // `preventDefault()` is called to prevent the browser from taking any actions. evt.preventDefault(); }; /** * Handles click events on the tab group. * * @param {any} evt - The click event. */ #handleTabClick = evt => { const tab = evt.target.closest('a-tab'); if (tab) { this.selectTab(tab); } }; /** * Handles the scroll button click event. * * @param {any} evt - The click event. */ #handleScrollButtonClick = evt => { const scrollButton = evt.target.closest('.tab-group__scroll-button'); const tabsContainer = this.shadowRoot?.querySelector('.tab-group__tabs'); if (!scrollButton || !tabsContainer) { return; } const isStartButton = scrollButton.classList.contains('tab-group__scroll-button--start'); const isLTR = this.#getDirectionality() === DIR.LTR; const sign = isStartButton ? (isLTR ? -1 : 1) : isLTR ? 1 : -1; const offsetLeft = tabsContainer.scrollLeft; tabsContainer.scrollTo({ left: offsetLeft + sign * this.scrollDistance }); }; /** * Handles the tab close button click event. * * @param {any} evt - The click event. */ #handleTabClose = evt => { const tab = evt.target; const panel = this.#panelForTab(tab); if (tab) { tab.remove(); if (tab.selected) { this.dispatchEvent( new CustomEvent('a-tab-hide', { bubbles: true, composed: true, detail: { tabId: tab.id } }) ); } } if (panel && panel.tagName.toLowerCase() === 'a-tab-panel') { panel.remove(); } }; /** * Wrapper for the `upgradeProperty` function. * * @param {'placement' | 'noScrollControls' | 'scrollDistance' | 'activation' | 'noTabCycling'} prop - The property to upgrade. */ #upgradeProperty(prop) { return upgradeProperty(prop, this); } /** * Selects the tab at the given index. * If the tab at the given index is disabled or already selected, this method does nothing. * * @param {number} index - The index of the tab to be selected. */ selectTabByIndex(index) { const tabs = this.#allTabs(); const tab = tabs[index]; if (tab) { this.selectTab(tab); } } /** * Selects the tab with the given id. * If the tab with the given id is disabled or already selected, this method does nothing. * * @param {string} id - The id of the tab to be selected. */ selectTabById(id) { const tabs = this.#allTabs(); const tab = tabs.find(tab => tab.id === id); if (tab) { this.selectTab(tab); } } /** * Selects the given tab. * If the given tab is disabled or already selected, this method does nothing. * * @param {Tab} tab - The tab to be selected. */ selectTab(tab) { const oldTab = this.#allTabs().find(t => t.selected); if (!tab || tab.disabled || tab.selected || tab.tagName.toLowerCase() !== 'a-tab') { return; } this.#setSelectedTab(tab); // Queue a microtask to ensure that the tab is focused on the next tick. window.requestAnimationFrame(() => { tab.scrollIntoView({ inline: 'nearest', block: 'nearest' }); tab.focus(); }); if (oldTab) { this.dispatchEvent( new CustomEvent('a-tab-hide', { bubbles: true, composed: true, detail: { tabId: oldTab.id } }) ); } this.dispatchEvent( new CustomEvent('a-tab-show', { bubbles: true, composed: true, detail: { tabId: tab.id } }) ); } static defineCustomElement(elementName = 'a-tab-group') { if (typeof window !== 'undefined' && !window.customElements.get(elementName)) { window.customElements.define(elementName, ATabGroup); } } } ATabGroup.defineCustomElement(); export { ATabGroup };