import { LitElement, html, css } from 'lit'; import { property, state } from 'lit/decorators.js'; import { generateUniqueId } from '../../../utils/unique-id.js'; export type TabsActivation = 'manual' | 'automatic'; export type TabsOrientation = 'horizontal' | 'vertical'; /** * Event detail for tab-change event */ export interface TabChangeEventDetail { activeTab: number; previousTab: number; } /** * Custom event dispatched when the active tab changes */ export type TabChangeEvent = CustomEvent; /** * Event map for Tabs component */ export interface TabsEventMap { 'tab-change': TabChangeEvent; } // Child components defined inline export class Tab extends LitElement { @property({ type: String }) declare panel: string; @property({ type: Boolean, reflect: true }) declare disabled: boolean; constructor() { super(); this.panel = ''; this.disabled = false; } connectedCallback() { super.connectedCallback(); // Set host element role this.setAttribute('role', 'tab'); } static styles = css` :host { display: inline-flex; } .tab { display: inline-flex; align-items: center; justify-content: center; background: transparent; border: none; color: var(--ag-text-secondary); cursor: pointer; font: inherit; padding: var(--ag-space-2) var(--ag-space-4); border-radius: 0; border-bottom: 2px solid transparent; text-decoration: none; } :host([aria-selected="true"]) .tab { color: var(--ag-text-primary); border-bottom-color: var(--ag-primary); } .tab:focus-visible { outline: var(--ag-focus-width) solid rgba(var(--ag-focus), 0.5); outline-offset: var(--ag-focus-offset); } /* Disabled state styles */ :host([disabled]) .tab, :host([aria-disabled="true"]) .tab { opacity: var(--ag-opacity-disabled); cursor: not-allowed; pointer-events: none; } .tab:hover { background: var(--ag-background-secondary); color: var(--ag-text-primary); } :host([disabled]) .tab:hover, :host([aria-disabled="true"]) .tab:hover { background: transparent; color: var(--ag-text-secondary); } `; render() { return html`
`; } } export class TabPanel extends LitElement { constructor() { super(); } connectedCallback() { super.connectedCallback(); // Set host element role and default attributes this.setAttribute('role', 'tabpanel'); this.setAttribute('tabindex', '0'); } static styles = css` :host { display: block; } .tab-panel { display: block; padding: var(--ag-tabs-panel-padding, 1rem); } :host([hidden]) { display: none; } `; render() { return html`
`; } } /** * Props interface for Tabs component including event handlers * Use this type for framework integrations and Storybook */ export interface TabsProps { activation?: TabsActivation; activeTab?: number; orientation?: TabsOrientation; ariaLabel?: string; // Event callback prop onTabChange?: (event: TabChangeEvent) => void; } /** * Tabs component for organizing content into multiple panels * * @fires {TabChangeEvent} tab-change - Fired when the active tab changes * * @example * ```html * * Tab 1 * Tab 2 * Content 1 * Content 2 * * ``` */ export class Tabs extends LitElement implements TabsProps { @property({ type: String }) declare activation: TabsActivation; @property({ type: Number, attribute: 'active-tab' }) declare activeTab: number; @property({ type: String }) declare orientation: TabsOrientation; @property({ type: String, reflect: true, attribute: 'aria-label' }) declare ariaLabel: string; @property({ attribute: false }) declare onTabChange?: (event: TabChangeEvent) => void; @state() private declare _tabs: Tab[]; @state() private declare _panels: TabPanel[]; @state() private declare _focusedTab: number; constructor() { super(); this.activation = 'manual'; this.activeTab = 0; this.orientation = 'horizontal'; this.ariaLabel = ''; this._tabs = []; this._panels = []; this._focusedTab = 0; } firstUpdated() { // Use a microtask to ensure child elements are ready Promise.resolve().then(() => { this._updateTabsAndPanels(); }); } updated(changedProperties: Map) { if (changedProperties.has('activeTab')) { // Sync focused tab with active tab this._focusedTab = this.activeTab; // Use a microtask to ensure child elements are ready Promise.resolve().then(() => { this._updateTabsAndPanels(); }); } } connectedCallback() { super.connectedCallback(); // Listen for slot changes to update tabs when children are added this.addEventListener('slotchange', () => { Promise.resolve().then(() => { this._updateTabsAndPanels(); }); }); // Add keyboard navigation event listeners this.addEventListener('keydown', this._handleKeyDown.bind(this)); this.addEventListener('click', this._handleClick.bind(this)); } private _updateTabsAndPanels() { // Get all tabs and panels from slots this._tabs = Array.from(this.querySelectorAll('ag-tab')) as Tab[]; this._panels = Array.from(this.querySelectorAll('ag-tab-panel')) as TabPanel[]; // Set up IDs and relationships this._tabs.forEach((tab, index) => { const tabId = tab.id || `tab-${generateUniqueId()}`; const panelId = tab.panel || this._panels[index]?.id || `panel-${generateUniqueId()}`; // Set tab attributes directly on the host element tab.setAttribute('id', tabId); tab.setAttribute('aria-controls', panelId); tab.setAttribute('aria-selected', index === this.activeTab ? 'true' : 'false'); tab.setAttribute('tabindex', index === this._focusedTab ? '0' : '-1'); // Set corresponding panel attributes if it exists if (this._panels[index]) { this._panels[index].setAttribute('id', panelId); this._panels[index].setAttribute('aria-labelledby', tabId); if (index !== this.activeTab) { this._panels[index].setAttribute('hidden', ''); } else { this._panels[index].removeAttribute('hidden'); } } }); } private _handleKeyDown(event: KeyboardEvent) { if (!this._tabs.length) return; // Only handle keyboard events when the target is a tab element // This prevents interfering with content inside tab panels const target = event.target as Element; const isTargetTab = target && target.tagName === 'AG-TAB'; // Also check if the target is inside a tab panel - if so, don't handle it const isInsideTabPanel = target && target.closest('ag-tab-panel'); // Don't handle if target is not a tab OR if target is inside a tab panel if (!isTargetTab || isInsideTabPanel) { return; } const { key } = event; const isHorizontal = this.orientation === 'horizontal'; let newFocusedTab = this._focusedTab; let shouldActivate = false; switch (key) { case 'ArrowRight': if (isHorizontal) { newFocusedTab = this._findNextEnabledTab(this._focusedTab, 1); shouldActivate = this.activation === 'automatic'; event.preventDefault(); } break; case 'ArrowLeft': if (isHorizontal) { newFocusedTab = this._findNextEnabledTab(this._focusedTab, -1); shouldActivate = this.activation === 'automatic'; event.preventDefault(); } break; case 'ArrowDown': if (!isHorizontal) { newFocusedTab = this._findNextEnabledTab(this._focusedTab, 1); shouldActivate = this.activation === 'automatic'; event.preventDefault(); } break; case 'ArrowUp': if (!isHorizontal) { newFocusedTab = this._findNextEnabledTab(this._focusedTab, -1); shouldActivate = this.activation === 'automatic'; event.preventDefault(); } break; case 'Home': newFocusedTab = this._findFirstEnabledTab(true); shouldActivate = this.activation === 'automatic'; event.preventDefault(); break; case 'End': newFocusedTab = this._findFirstEnabledTab(false); shouldActivate = this.activation === 'automatic'; event.preventDefault(); break; case ' ': case 'Enter': if (this.activation === 'manual') { this._activateTab(this._focusedTab); event.preventDefault(); } break; } if (newFocusedTab !== this._focusedTab) { this._setFocusedTab(newFocusedTab); if (shouldActivate) { this._activateTab(newFocusedTab); } } } private _handleClick(event: Event) { // Find the actual ag-tab element, even if clicking on child elements let clickedTab = event.target as Element; // Traverse up the DOM tree to find the ag-tab element while (clickedTab && clickedTab.tagName !== 'AG-TAB') { clickedTab = clickedTab.parentElement as Element; // Safety check to avoid infinite loop if (clickedTab === this) break; } if (clickedTab && clickedTab.tagName === 'AG-TAB') { const tab = clickedTab as Tab; // Check if tab is disabled if (tab.hasAttribute('disabled') || tab.getAttribute('aria-disabled') === 'true') { return; } const tabIndex = this._tabs.indexOf(tab); if (tabIndex >= 0) { this._activateTab(tabIndex); } } } /** * Helper method to find the next non-disabled tab in a given direction * @param startIndex - The starting index * @param direction - 1 for forward, -1 for backward * @returns The index of the next non-disabled tab, or startIndex if none found */ private _findNextEnabledTab(startIndex: number, direction: 1 | -1): number { const length = this._tabs.length; if (length === 0) return startIndex; let index = startIndex; let attempts = 0; // Try to find a non-disabled tab, but don't loop forever while (attempts < length) { index = direction === 1 ? (index + 1) % length : (index === 0 ? length - 1 : index - 1); const tab = this._tabs[index]; const isDisabled = tab.hasAttribute('disabled') || tab.getAttribute('aria-disabled') === 'true'; if (!isDisabled) { return index; } attempts++; } // If all tabs are disabled, return the start index return startIndex; } /** * Helper method to find the first non-disabled tab from start * @param fromStart - If true, search from beginning; if false, search from end * @returns The index of the first non-disabled tab, or 0 if none found */ private _findFirstEnabledTab(fromStart: boolean): number { const length = this._tabs.length; if (length === 0) return 0; if (fromStart) { for (let i = 0; i < length; i++) { const tab = this._tabs[i]; const isDisabled = tab.hasAttribute('disabled') || tab.getAttribute('aria-disabled') === 'true'; if (!isDisabled) return i; } } else { for (let i = length - 1; i >= 0; i--) { const tab = this._tabs[i]; const isDisabled = tab.hasAttribute('disabled') || tab.getAttribute('aria-disabled') === 'true'; if (!isDisabled) return i; } } // If all tabs are disabled, return 0 return 0; } private _setFocusedTab(index: number) { if (index >= 0 && index < this._tabs.length) { this._focusedTab = index; this._updateTabsAndPanels(); this._tabs[index].focus(); } } private _activateTab(index: number) { if (index >= 0 && index < this._tabs.length) { const previousTab = this.activeTab; // Check if the tab is disabled const tab = this._tabs[index]; if (tab.hasAttribute('disabled') || tab.getAttribute('aria-disabled') === 'true') { return; } if (previousTab !== index) { // Set active tab - this will trigger updated() which handles the rest this.activeTab = index; // Focus the newly activated tab this._tabs[index].focus(); // Dual-dispatch: dispatchEvent + callback const tabChangeEvent = new CustomEvent('tab-change', { detail: { activeTab: index, previousTab: previousTab }, bubbles: true, composed: true, }); this.dispatchEvent(tabChangeEvent); // Invoke callback if provided if (this.onTabChange) { this.onTabChange(tabChangeEvent); } } else { // If clicking the same tab, just ensure it has focus this._tabs[index].focus(); } } } static styles = css` :host { display: block; } .tabs-container { display: flex; } .tabs-container[data-orientation="vertical"] { flex-direction: row; } .tabs-container[data-orientation="horizontal"] { flex-direction: column; } [role="tablist"] { display: flex; gap: var(--ag-space-2); } [role="tablist"][aria-orientation="horizontal"] { flex-direction: row; border-bottom: 1px solid var(--ag-border); } [role="tablist"][aria-orientation="vertical"] { flex-direction: column; border-inline-end: 1px solid var(--ag-border); min-width: 200px; } .tab-panels { flex: 1; } ::slotted(ag-tab-panel[hidden]) { display: none; } `; render() { return html`
`; } } declare global { interface HTMLElementTagNameMap { 'ag-tabs': Tabs; 'ag-tab': Tab; 'ag-tab-panel': TabPanel; } }