import { RovingTabindex } from "../../utils/keyboard"; interface TabsConfig { tabSelector: string; } export const defaultConfig: TabsConfig = { tabSelector: '[role="tab"]', }; export const configDocs = { tabSelector: "Tab element selector", }; export default class Tabs { private static readonly SCROLL_ALIGNMENT_TOLERANCE = 1; private static readonly SCROLL_EDGE_TOLERANCE = 1; private element: HTMLElement; private config: TabsConfig; private tabs: HTMLElement[]; private tabPanels: (HTMLElement | null)[]; private activeTabIndex: number | null; private rovingTabindex: RovingTabindex | null; private resizeObserver: ResizeObserver | null; private horizontalScrollHandler: () => void; private resizeHandler: () => void; private viewportElement: HTMLElement | null; constructor(element: HTMLElement, config?: Partial) { this.element = element; this.config = { ...defaultConfig, ...config }; this.tabs = []; this.tabPanels = []; this.activeTabIndex = null; this.rovingTabindex = null; this.resizeObserver = null; this.viewportElement = null; this.handleClick = this.handleClick.bind(this); this.handleTabFocus = this.handleTabFocus.bind(this); this.horizontalScrollHandler = this.updateHorizontalOverflowState.bind(this); this.resizeHandler = this.updateHorizontalOverflowState.bind(this); (this.element as any).ODS_Tabs = this; this.init(); return this; } static getInstance(el: HTMLElement): Tabs | null { return el && (el as any).ODS_Tabs ? (el as any).ODS_Tabs : null; } private init(): void { this.tabs = Array.from( this.element.querySelectorAll(this.config.tabSelector), ); this.tabs.map((tab, index) => { const controlId = tab.getAttribute("aria-controls"); this.tabPanels.push( controlId ? document.getElementById(controlId) : null, ); tab.addEventListener("click", this.handleClick); tab.addEventListener("focus", this.handleTabFocus); if (this.isSelected(tab)) { this.activeTabIndex = index; } return tab; }); if (!this.rovingTabindex) { this.rovingTabindex = new RovingTabindex(this.tabs, { direction: "any", }); } else { this.rovingTabindex.update(this.tabs); } if (this.activeTabIndex !== null) { this.setActiveTab(this.activeTabIndex); } else if (this.tabs.length > 0) { this.activateNthTab(0); } this.setupOverflowObserver(); } destroy(): void { this.teardownOverflowObserver(); this.tabs.map((tab) => { tab.removeEventListener("click", this.handleClick); tab.removeEventListener("focus", this.handleTabFocus); return tab; }); this.tabs = []; this.tabPanels = []; if (this.rovingTabindex) { this.rovingTabindex.destroy(); } (this.element as any).ODS_Tabs = null; } private setupOverflowObserver(): void { this.teardownOverflowObserver(); this.viewportElement = this.element.parentElement?.classList.contains( "tab-list__viewport", ) ? this.element.parentElement : null; this.element.addEventListener("scroll", this.horizontalScrollHandler, { passive: true, }); window.addEventListener("resize", this.resizeHandler); if (typeof ResizeObserver !== "undefined") { this.resizeObserver = new ResizeObserver(this.resizeHandler); this.resizeObserver.observe(this.element); this.tabs.forEach((tab) => this.resizeObserver?.observe(tab)); } this.updateHorizontalOverflowState(); } private teardownOverflowObserver(): void { this.element.removeEventListener("scroll", this.horizontalScrollHandler); window.removeEventListener("resize", this.resizeHandler); if (this.viewportElement) { this.viewportElement.classList.remove( "has-left-overflow", "has-right-overflow", ); this.viewportElement = null; } if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } } private updateHorizontalOverflowState(): void { if (!this.viewportElement) return; const maxScrollLeft = this.element.scrollWidth - this.element.clientWidth; const hasOverflow = maxScrollLeft > Tabs.SCROLL_EDGE_TOLERANCE; const atStart = this.element.scrollLeft <= Tabs.SCROLL_EDGE_TOLERANCE; const atEnd = this.element.scrollLeft >= maxScrollLeft - Tabs.SCROLL_EDGE_TOLERANCE; this.viewportElement.classList.toggle( "has-left-overflow", hasOverflow && !atStart, ); this.viewportElement.classList.toggle( "has-right-overflow", hasOverflow && !atEnd, ); } update(): void { this.destroy(); this.init(); } private isActive = (el: HTMLElement | null): boolean => el ? !el.hasAttribute("hidden") : false; private isSelected = (el: HTMLElement): boolean => el.getAttribute("aria-selected") === "true"; private setActiveTab(index: number, forceCenter: boolean = false): void { this.tabs.map((tab, i) => { if (i === index) { this.activeTabIndex = i; return this.toggleTab(tab, "on"); } return this.toggleTab(tab, "off"); }); this.tabPanels.map((tabPanel, i) => { if (i === index) { return this.toggleTabPanel(tabPanel, "on"); } return this.toggleTabPanel(tabPanel, "off"); }); const activeTab = this.tabs[index]; if (activeTab) { this.scrollTabIntoView(activeTab, forceCenter); } } private scrollTabIntoView( activeTab: HTMLElement, forceCenter: boolean = false, ): void { const maxScrollLeft = Math.max( 0, this.element.scrollWidth - this.element.clientWidth, ); const contentRect = this.element.getBoundingClientRect(); const itemRect = activeTab.getBoundingClientRect(); const itemCenterWithinContent = itemRect.left - contentRect.left + this.element.scrollLeft + itemRect.width / 2; const targetScrollLeft = itemCenterWithinContent - this.element.clientWidth / 2; const behavior = window.innerWidth < 768 ? "auto" : "smooth"; const nextScrollLeft = Math.min( maxScrollLeft, Math.max(0, targetScrollLeft), ); const isAlreadyAligned = Math.abs(this.element.scrollLeft - nextScrollLeft) <= Tabs.SCROLL_ALIGNMENT_TOLERANCE; if (!forceCenter && isAlreadyAligned) return; if (typeof this.element.scrollTo === "function") { this.element.scrollTo({ left: nextScrollLeft, behavior, }); return; } this.element.scrollLeft = nextScrollLeft; this.updateHorizontalOverflowState(); } private handleClick(e: MouseEvent): void { const clickedTab = e.currentTarget as HTMLElement; if (clickedTab.hasAttribute("aria-disabled")) { e.preventDefault(); return; } const clickedIndex = this.tabs.findIndex((tab) => tab === clickedTab); if (clickedIndex >= 0) { this.setActiveTab(clickedIndex, true); } } private toggleTab(el: HTMLElement, state: "on" | "off"): void { const isActive = this.isSelected(el); if (state === "on" && !isActive) { el.setAttribute("aria-selected", "true"); el.setAttribute("tabindex", "0"); } if (state === "off" && isActive) { el.setAttribute("aria-selected", "false"); el.setAttribute("tabindex", "-1"); } } private toggleTabPanel(el: HTMLElement | null, state: "on" | "off"): void { if (!el) return; const isActive = this.isActive(el); if (state === "on" && !isActive) { el.removeAttribute("hidden"); } if (state === "off" && isActive) { el.setAttribute("hidden", ""); } } activateNthTab(index: number): void { this.setActiveTab(index, true); } private handleTabFocus(event: FocusEvent): void { if (this.rovingTabindex && !this.rovingTabindex.isActive) { this.rovingTabindex.init(); } const focusedTab = event.currentTarget as HTMLElement | null; if (focusedTab && this.shouldCenterFocusedTab(focusedTab)) { this.scrollTabIntoView(focusedTab); } } private shouldCenterFocusedTab(focusedTab: HTMLElement): boolean { if (typeof focusedTab.matches !== "function") return true; try { return focusedTab.matches(":focus-visible"); } catch { return true; } } }