/** * Copyright Aquera Inc 2023 * * This source code is licensed under the BSD-3-Clause license found in the * LICENSE file in the root directory of this source tree. */ import {LitElement, html, CSSResultArray, TemplateResult, nothing} from 'lit'; import { customElement, property } from 'lit/decorators.js'; import {styles} from './nile-tab-group.css'; import '../nile-icon-button/nile-icon-button'; import { classMap } from 'lit/directives/class-map.js'; import { query, state } from 'lit/decorators.js'; import { scrollIntoView } from '../internal/scroll'; import { watch } from '../internal/watch'; import NileElement from '../internal/nile-element'; import type { CSSResultGroup, PropertyValueMap } from 'lit'; import type NileTab from '../nile-tab/nile-tab'; import type NileTabPanel from '../nile-tab-panel/nile-tab-panel'; /** * Nile icon component. * * @tag nile-tab-group * * @slot - Used for grouping tab panels in the tab group. Must be `` elements. * @slot nav - Used for grouping tabs in the tab group. Must be `` elements. * * @event {{ name: String }} nile-tab-show - Emitted when a tab is shown. * @event {{ name: String }} nile-tab-hide - Emitted when a tab is hidden. * * @csspart base - The component's base wrapper. * @csspart nav - The tab group's navigation container where tabs are slotted in. * @csspart tabs - The container that wraps the tabs. * @csspart active-tab-indicator - The line that highlights the currently selected tab. * @csspart body - The tab group's body where tab panels are slotted in. * @csspart scroll-button - The previous/next scroll buttons that show when tabs are scrollable, an ``. * @csspart scroll-button--start - The starting scroll button. * @csspart scroll-button--end - The ending scroll button. * @csspart scroll-button__base - The scroll button's exported `base` part. * * @cssproperty --indicator-color - The color of the active tab indicator. * @cssproperty --track-color - The color of the indicator's track (the line that separates tabs from panels). * @cssproperty --track-width - The width of the indicator's track (the line that separates tabs from panels). * */ @customElement('nile-tab-group') export class NileTabGroup extends NileElement { static styles: CSSResultGroup = styles; private activeTab?: NileTab; private mutationObserver: MutationObserver; private resizeObserver: ResizeObserver; private tabs: NileTab[] = []; private panels: NileTabPanel[] = []; @query('.tab-group') tabGroup: HTMLElement; @query('.tab-group__body') body: HTMLSlotElement; @query('.tab-group__nav') nav: HTMLElement; @query('.tab-group__indicator') indicator: HTMLElement; @property({ type: Boolean, reflect: true, attribute: true }) hasScrollControls = false; /** The placement of the tabs. */ @property() placement: 'top' | 'bottom' | 'start' | 'end' = 'top'; /** The placement of the tabs. */ @property({ reflect: true, attribute: 'value', type: String }) activeTabProp: string = ''; /** Track for showing Indicators and Background. */ @property({ type: Boolean, reflect: true, attribute:'no-track'}) noTrack = false; /** * When set to auto, navigating tabs with the arrow keys will instantly show the corresponding tab panel. When set to * manual, the tab will receive focus but will not show until the user presses spacebar or enter. */ @property() activation: 'auto' | 'manual' = 'auto'; /** Disables the scroll arrows that appear when tabs overflow. */ @property({ attribute: 'no-scroll-controls', type: Boolean }) noScrollControls = false; /** Controls whether tabs are centered and have equal width */ @property({ type: Boolean, reflect: true }) centered = false; @state() activeTabName: string = ''; connectedCallback() { const whenAllDefined = Promise.allSettled([ customElements.whenDefined('nile-tab'), customElements.whenDefined('nile-tab-panel') ]); super.connectedCallback(); this.resizeObserver = new ResizeObserver(() => { this.repositionIndicator(); // this.updateScrollControls(); }); this.mutationObserver = new MutationObserver(mutations => { // Update aria labels when the DOM changes if (mutations.some(m => !['aria-labelledby', 'aria-controls'].includes(m.attributeName!))) { setTimeout(() => this.setAriaLabels()); } // Sync tabs when disabled states change if (mutations.some(m => m.attributeName === 'disabled')) { this.syncTabsAndPanels(); } }); // After the first update... this.updateComplete.then(() => { this.syncTabsAndPanels(); if(this.activeTabProp) this.activeTabName=this.activeTabProp; else this.activeTabName=this.getActiveTab().panel; this.syncIndicator(); this.mutationObserver.observe(this, { attributes: true, childList: true, subtree: true }); this.resizeObserver.observe(this.nav); // Wait for tabs and tab panels to be registered whenAllDefined.then(() => { // Set initial tab state when the tabs become visible const intersectionObserver = new IntersectionObserver((entries, observer) => { if (entries[0].intersectionRatio > 0) { this.setAriaLabels(); observer.unobserve(entries[0].target); } }); intersectionObserver.observe(this.tabGroup); }); }); } disconnectedCallback() { this.mutationObserver.disconnect(); this.resizeObserver.unobserve(this.nav); } protected firstUpdated(_changedProperties: PropertyValueMap | Map): void { this.addEventListener('nile-toggle-change', this.handleToggleFromTab); } protected updated(_changedProperties: PropertyValueMap | Map): void { if(_changedProperties.has('activeTabName')){ const tab=this.getActiveTab() if(!tab) return; this.setActiveTab(tab, {scrollBehavior: 'smooth' }); } if(_changedProperties.has('activeTabProp')){ this.activeTabName=this.activeTabProp; } } // @watch('noScrollControls', { waitUntilFirstUpdate: true }) // updateScrollControls() { // this.setScrollControls() // } @watch('placement', { waitUntilFirstUpdate: true }) syncIndicator() { if(!this.indicator) return; const tab = this.getActiveTab(); if (tab) { this.indicator.style.display = 'block'; this.repositionIndicator(); } else { this.indicator.style.display = 'none'; } } render() { return html`
${this.hasScrollControls ? html` ` : ''}
${this.hasScrollControls ? html` ` : ''}
`; } private getAllTabs(options: { includeDisabled: boolean } = { includeDisabled: true }) { const slot = this.shadowRoot!.querySelector('slot[name="nav"]')!; return [...(slot.assignedElements() as NileTab[])].filter(el => { return options.includeDisabled ? el.tagName.toLowerCase() === 'nile-tab' : el.tagName.toLowerCase() === 'nile-tab' && !el.disabled; }); } private getAllPanels() { return [...this.body.assignedElements()].filter(el => el.tagName.toLowerCase() === 'nile-tab-panel') as [NileTabPanel]; } private getActiveTab() { return this.tabs.find(el => this.activeTabName?el.panel==this.activeTabName:el.active) ?? this.tabs[0]; } private handleClick(event: MouseEvent) { const target = event.target as HTMLElement; const tab = target.closest('nile-tab'); const tabGroup = tab?.closest('nile-tab-group'); // Ensure the target tab is in this tab group if (tabGroup !== this) { return; } if (tab !== null && !tab.disabled) { this.activeTabName=tab.panel } } setScrollControls(){ if (this.noScrollControls) { this.hasScrollControls = false; } else { this.hasScrollControls = ['top', 'bottom'].includes(this.placement) && this.nav.scrollWidth > this.nav.clientWidth; } this.hasScrollControls = false; } private handleKeyDown(event: KeyboardEvent) { const target = event.target as HTMLElement; const tab = target.closest('nile-tab'); const tabGroup = tab?.closest('nile-tab-group'); // Ensure the target tab is in this tab group if (tabGroup !== this) { return; } // Activate a tab if (['Enter', ' '].includes(event.key)) { if (tab !== null) { this.activeTabName=tab.panel event.preventDefault(); } } // Move focus left or right if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) { const activeEl = tab; const isRtl = false; if (activeEl?.tagName.toLowerCase() === 'nile-tab') { let index = this.tabs.indexOf(activeEl); if (event.key === 'Home') { index = 0; } else if (event.key === 'End') { index = this.tabs.length - 1; } else if ( (['top', 'bottom'].includes(this.placement) && event.key === (isRtl ? 'ArrowRight' : 'ArrowLeft')) || (['start', 'end'].includes(this.placement) && event.key === 'ArrowUp') ) { index--; } else if ( (['top', 'bottom'].includes(this.placement) && event.key === (isRtl ? 'ArrowLeft' : 'ArrowRight')) || (['start', 'end'].includes(this.placement) && event.key === 'ArrowDown') ) { index++; } if (index < 0) { index = this.tabs.length - 1; } if (index > this.tabs.length - 1) { index = 0; } this.tabs[index].focus({ preventScroll: true }); if (this.activation === 'auto') { this.activeTabName=this.tabs[index].panel } if (['top', 'bottom'].includes(this.placement)) { scrollIntoView(this.tabs[index], this.nav, 'horizontal'); } event.preventDefault(); } } } private handleScrollToStart() { this.nav.scroll({ left:-this.nav.scrollWidth, behavior: 'smooth' }); } private handleScrollToEnd() { this.nav.scroll({ left:this.nav.scrollWidth, behavior: 'smooth' }); } private setActiveTab(tab: NileTab, options?: { emitEvents?: boolean; scrollBehavior?: 'auto' | 'smooth' }) { options = { emitEvents: true, scrollBehavior: 'auto', ...options }; if (tab !== this.activeTab && !tab.disabled) { const previousTab = this.activeTab; this.activeTab = tab; // Sync active tab and panel this.tabs.map(el => (el.active = el === this.activeTab)); this.panels.map(el => (el.active = el.name === this.activeTab?.panel)); this.syncIndicator(); if (['top', 'bottom'].includes(this.placement)) { scrollIntoView(this.activeTab, this.nav, 'horizontal', options.scrollBehavior); } // Emit events if (options.emitEvents) { if (previousTab) { this.emit('nile-tab-hide', { value: previousTab.panel }); } this.emit('nile-tab-show', { value: this.activeTab.panel }); } } } private setAriaLabels() { // Link each tab with its corresponding panel this.tabs.forEach(tab => { const panel = this.panels.find(el => el.name === tab.panel); if (panel) { tab.setAttribute('aria-controls', panel.getAttribute('id')!); panel.setAttribute('aria-labelledby', tab.getAttribute('id')!); } }); } private repositionIndicator() { const currentTab = this.getActiveTab(); if (!currentTab || !this.indicator) { return; } const width = currentTab.clientWidth; const height = currentTab.clientHeight; const isRtl = false; // We can't used offsetLeft/offsetTop here due to a shadow parent issue where neither can getBoundingClientRect // because it provides invalid values for animating elements: https://bugs.chromium.org/p/chromium/issues/detail?id=920069 const allTabs = this.getAllTabs(); const precedingTabs = allTabs.slice(0, allTabs.indexOf(currentTab)); const offset = precedingTabs.reduce( (previous, current) => ({ left: previous.left + current.clientWidth + 12, top: previous.top + current.clientHeight + 12 }), { left: 0, top: 0 } ); switch (this.placement) { case 'top': case 'bottom': this.indicator.style.width = `${width - 5}px`; this.indicator.style.height = 'auto'; this.indicator.style.translate = `${offset.left + 2}px`; break; case 'start': case 'end': this.indicator.style.width = 'auto'; this.indicator.style.height = `${height}px`; this.indicator.style.translate = `0 ${offset.top}px`; break; } } // This stores tabs and panels so we can refer to a cache instead of calling querySelectorAll() multiple times. private syncTabsAndPanels() { this.tabs = this.getAllTabs({ includeDisabled: false }); this.panels = this.getAllPanels(); this.tabs.forEach((tab:any) => { tab.centered = this.centered; }); // After updating, show or hide scroll controls as needed // this.updateComplete.then(() => this.updateScrollControls()); } private handleToggleFromTab(e:CustomEvent){ e.stopPropagation(); this.activeTabName=e.detail.value; } } export default NileTabGroup; declare global { interface HTMLElementTagNameMap { 'nile-tab-group': NileTabGroup; } }