/** * 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 { html, nothing } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.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 NileNavTab from '../nile-nav-tab/nile-nav-tab'; import type NileNavTabPanel from '../nile-nav-tab-panel/nile-nav-tab-panel'; import { styles } from './nile-nav-tab-group.css'; import { MouseKey } from '../internal/enum'; import '../nile-icon-button/nile-icon-button'; /** * Tab group navigation component. * * @tag nile-nav-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. * * @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 toggle-frame - Toggle variant: absolutely positioned ring that draws the group chrome (tabs paint above it). * @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). * @cssproperty --show-indicator-on-hover - Whether to show the indicator on hover. * * @event nile-close - Bubbled from a closable ``; re-emitted so parents can listen on this element. */ @customElement('nile-nav-tab-group') export class NileNavTabGroup extends NileElement { static styles: CSSResultGroup = styles; /** * Selection model (for reviewers): * - `activeTabName` is the internal source of truth; clicks and keyboard update it. * - `activeTabProp` / attribute `value` is the host-controlled API; changes in `updated()` copy into `activeTabName`. * - `setActiveTab()` syncs each tab’s `active` flag, each panel’s `active`, `activeTabProp` / `value`, indicator/pill geometry, and optionally emits `nile-tab-change`. * - `this.tabs` only includes non-disabled tabs (see `syncTabsAndPanels`); keyboard roving focus uses that list. */ private activeTab?: NileNavTab; private mutationObserver: MutationObserver; private resizeObserver: ResizeObserver; private visibilityObserver: IntersectionObserver; private pillRepositionTimer: ReturnType | null = null; private pillTransitionClassTimer: ReturnType | null = null; private pillReady = false; private observedTabs = new Set(); private tabs: NileNavTab[] = []; private panels: NileNavTabPanel[] = []; /** Tabs emit `nile-close` on the host; bubble is stopped so we re-emit from the group with `{ panel }` for consumers. */ private readonly handleCloseEvent = (e: Event) => { if (e.target === this) return; const tab = e.target as NileNavTab | null; const panel = tab?.getAttribute('panel'); if (!panel) return; // Other listeners on this host must not see the child event (no `detail`) after we synthesize one. e.stopImmediatePropagation(); e.stopPropagation(); this.syncIndicator(); this.emit('nile-close', { panel }); }; /** Tracks visibility for snapping the indicator when a hidden parent (e.g. dropdown) is reopened. */ private tabGroupWasIntersecting = false; /** Last measured nav width; used to detect layout resume after display:none / collapse. */ private lastNavClientWidth = 0; @query('.nav-tab-group') tabGroup: HTMLElement; @query('.nav-tab-group__body') body: HTMLSlotElement; @query('.nav-tab-group__nav') nav: HTMLElement; @query('.nav-tab-group__indicator') indicator: HTMLElement; @query('.nav-tab-group__hover-indicator') hoverIndicator: HTMLElement; @property({ type: Boolean, reflect: true, attribute: true }) hasScrollControls = false; /** The placement of the tabs. */ @property() placement: 'top' | 'bottom' | 'start' | 'end' = 'top'; /** The currently active tab value. */ @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; /** 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; @property({ type: String, reflect: true }) variant: 'underline' | 'filled' | 'toggle' | 'neutral-filled' | 'toggle-button' = 'underline'; @property({ type: String, reflect: true }) indicatorPlacement = ''; @property({ type: Boolean, reflect: true, attribute: true }) fullWidth = false; @property({ type: String, reflect: true }) width = ''; @property({ type: Boolean, reflect: true, attribute: true }) showIndicatorOnHover = false; @state() activeTabName: string = ''; private get showScrollControlButtons(): boolean { return !this.noScrollControls && this.hasScrollControls; } connectedCallback() { super.connectedCallback(); this.resizeObserver = new ResizeObserver(() => { this.setScrollControls(); const nw = this.nav?.clientWidth ?? 0; const layoutResumed = this.lastNavClientWidth === 0 && nw > 0; this.lastNavClientWidth = nw; this.repositionIndicator({ skipTransition: layoutResumed }); this.debouncedPositionPill(); }); this.mutationObserver = new MutationObserver(mutations => { if (mutations.some(m => !['aria-labelledby', 'aria-controls'].includes(m.attributeName!))) { setTimeout(() => this.setAriaLabels()); } if (mutations.some(m => m.attributeName === 'disabled')) { this.syncTabsAndPanels(); } if (mutations.some(m => m.attributeName === 'active')) { const activeTab = this.getAllTabs({ includeDisabled: false }).find(t => t.active); if (activeTab && activeTab.panel !== this.activeTabName) { this.activeTabName = activeTab.panel; } } if (mutations.some(m => m.type === 'childList' || m.type === 'characterData')) { this.repositionIndicator(); this.debouncedPositionPill(); requestAnimationFrame(() => this.setScrollControls()); } }); this.updateComplete.then(async () => { this.setScrollControls(); await Promise.all(this.getAllTabs({ includeDisabled: true }).map(tab => tab.updateComplete)); await Promise.all(this.getAllPanels().map(panel => panel.updateComplete)); this.syncTabsAndPanels(); if (this.activeTabProp) { this.activeTabName = this.activeTabProp; } else { const activeTab = this.getActiveTab(); this.activeTabName = activeTab ? activeTab.panel : ''; } // First paint: disable indicator transition so it jumps to the correct tab without sliding from (0,0). requestAnimationFrame(() => { if (this.indicator) { this.indicator.style.transition = 'none'; } this.syncIndicator(); this.positionPill(); requestAnimationFrame(() => { if (this.indicator) { this.indicator.style.transition = ''; } }); }); this.mutationObserver.observe(this, { attributes: true, childList: true, subtree: true, characterData: true }); this.resizeObserver.observe(this.nav); this.visibilityObserver = new IntersectionObserver((entries) => { const entry = entries[0]; const nowVisible = entry.isIntersecting && entry.intersectionRatio > 0; if (nowVisible) { this.setAriaLabels(); this.debouncedPositionPill(); const becameVisible = !this.tabGroupWasIntersecting; this.tabGroupWasIntersecting = true; this.repositionIndicator({ skipTransition: becameVisible }); } else { this.tabGroupWasIntersecting = false; } }); this.visibilityObserver.observe(this.tabGroup); }); this.addEventListener('nile-close', this.handleCloseEvent); this.addEventListener('click', this.handleNavClick); this.addEventListener('keydown', this.handleKeyDown); } disconnectedCallback() { this.mutationObserver.disconnect(); this.resizeObserver.disconnect(); this.observedTabs.clear(); this.visibilityObserver?.disconnect(); this.removeEventListener('nile-close', this.handleCloseEvent); this.removeEventListener('click', this.handleNavClick); this.removeEventListener('keydown', this.handleKeyDown); } protected updated(_changedProperties: PropertyValueMap | Map): void { let nameChanged = _changedProperties.has('activeTabName'); if (_changedProperties.has('activeTabProp') && this.activeTabName !== this.activeTabProp) { this.activeTabName = this.activeTabProp; nameChanged = true; } if (nameChanged) { const tab = this.getActiveTab(); if (tab) { this.setActiveTab(tab, { scrollBehavior: 'smooth' }); } } if (_changedProperties.has('noScrollControls')) { requestAnimationFrame(() => this.setScrollControls()); } } @watch('placement', { waitUntilFirstUpdate: true }) syncIndicator() { requestAnimationFrame(() => this.setScrollControls()); if (!this.indicator) return; const tab = this.getActiveTab(); if (tab && !tab.disabled) { this.indicator.style.display = 'block'; this.repositionIndicator(); } else { this.indicator.style.display = 'none'; } } private handleNavClick(event: MouseEvent) { const tab = event.composedPath().find( (el): el is NileNavTab => el instanceof HTMLElement && el.tagName.toLowerCase() === 'nile-nav-tab' ); if (!tab) return; const tabsNow = this.getAllTabs({ includeDisabled: true }); if (!tabsNow.includes(tab)) return; if (tab.hasAttribute('disabled') || tab.disabled) return; const isModifiedClick = Object.values(MouseKey).some(key => event[key as keyof MouseEvent]) || event.button !== 0; if (isModifiedClick) return; this.activeTabProp = tab.panel; this.activeTabName = tab.panel; tab.focus(); } private positionPill(animate = false) { const pill = this.shadowRoot?.querySelector('.nav-tab-group__pill'); if (!pill) return; if (this.pillTransitionClassTimer) { clearTimeout(this.pillTransitionClassTimer); this.pillTransitionClassTimer = null; } const tab = this.getActiveTab(); if (!tab || tab.disabled) { pill.classList.add('nav-tab-group__pill--inactive'); pill.classList.remove('nav-tab-group__pill--transitioning'); return; } pill.classList.remove('nav-tab-group__pill--inactive'); const target = tab.shadowRoot?.querySelector('.nav-tab-container'); if (!target) return; const container = this.shadowRoot?.querySelector('.nav-tab-group__tabs'); if (!container) return; if (target.offsetWidth === 0 || target.offsetHeight === 0) return; const box = this.getLayoutBoxRelativeToContainer(target, container); if (!box) return; const x = box.x; const y = box.y; const w = box.width; const h = box.height; const cw = container.clientWidth; const rightInset = Math.max(0, cw - x - w); if (animate) { const ease = 'cubic-bezier(0.4, 0, 0.2, 1)'; pill.style.transition = `left 400ms ${ease}, right 400ms ${ease}, top 400ms ${ease}, height 400ms ${ease}`; if (this.variant === 'toggle') { pill.classList.add('nav-tab-group__pill--transitioning'); } } else { pill.style.transition = ''; if (this.variant === 'toggle') { pill.classList.remove('nav-tab-group__pill--transitioning'); } } pill.style.transform = ''; pill.style.left = `${x}px`; pill.style.right = `${rightInset}px`; pill.style.width = 'auto'; pill.style.top = `${y}px`; pill.style.height = `${h}px`; pill.style.opacity = '1'; if (!this.pillReady) { this.pillReady = true; } if (animate) { const onEnd = () => { pill.style.transition = ''; pill.removeEventListener('transitionend', onEnd); }; pill.addEventListener('transitionend', onEnd); if (this.variant === 'toggle') { this.pillTransitionClassTimer = setTimeout(() => { this.pillTransitionClassTimer = null; pill.classList.remove('nav-tab-group__pill--transitioning'); }, 420); } } } private debouncedPositionPill() { if (this.pillRepositionTimer) { clearTimeout(this.pillRepositionTimer); } this.pillRepositionTimer = setTimeout(() => { this.pillRepositionTimer = null; this.positionPill(); }, 150); } private handleTabHover(event: MouseEvent) { if (!this.showIndicatorOnHover) return; const tab = event.composedPath().find( (el): el is NileNavTab => el instanceof HTMLElement && el.tagName.toLowerCase() === 'nile-nav-tab' ); if (!tab || tab.disabled || tab.hasAttribute('disabled')) return; const tabsNow = this.getAllTabs({ includeDisabled: true }); if (!tabsNow.includes(tab)) return; this.positionHoverIndicator(tab); } private handleTabHoverLeave() { if (!this.showIndicatorOnHover || !this.hoverIndicator) return; this.hoverIndicator.style.opacity = '0'; } private positionHoverIndicator(tab: NileNavTab) { if (!this.hoverIndicator) return; const container = this.shadowRoot?.querySelector('.nav-tab-group__tabs'); if (!container) return; const target = tab.shadowRoot?.querySelector('.nav-tab-container'); if (!target) return; const layoutBox = this.getLayoutBoxRelativeToContainer(target, container); if (!layoutBox) return; this.hoverIndicator.style.transition = 'none'; switch (this.placement) { case 'top': case 'bottom': this.hoverIndicator.style.width = `${target.offsetWidth}px`; this.hoverIndicator.style.height = 'auto'; this.hoverIndicator.style.translate = `${layoutBox.x}px 0px`; break; case 'start': case 'end': this.hoverIndicator.style.height = `${target.offsetHeight}px`; this.hoverIndicator.style.width = 'auto'; this.hoverIndicator.style.translate = `0px ${layoutBox.y}px`; break; } this.hoverIndicator.style.opacity = '1'; } render() { const usesPill = this.variant === 'filled' || this.variant === 'neutral-filled' || this.variant === 'toggle' || this.variant === 'toggle-button'; const showsIndicator = this.variant !== 'filled' && this.variant !== 'neutral-filled' && this.variant !== 'toggle-button' && this.variant !== 'toggle'; const toggleVariant = this.variant === 'toggle'; return html`
`; } private getAllTabs(options: { includeDisabled: boolean } = { includeDisabled: true }) { const slot = this.shadowRoot!.querySelector('slot[name="nav"]')!; const navTabs = slot.assignedElements().filter( (el): el is NileNavTab => el.tagName.toLowerCase() === 'nile-nav-tab' ); return options.includeDisabled ? navTabs : navTabs.filter(tab => !tab.disabled); } private getAllPanels() { return [...this.body.assignedElements()].filter( el => el.tagName.toLowerCase() === 'nile-nav-tab-panel' ) as NileNavTabPanel[]; } /** * Resolves the tab to treat as selected for indicator/pill wiring. * Prefer `activeTabName` match; if empty (before init), fall back to `.active` or first usable tab. */ private getActiveTab() { return this.tabs.find(el => this.activeTabName ? el.panel === this.activeTabName : el.active ) ?? this.tabs.find(el => !el.disabled) ?? this.tabs[0]; } /** Reflects horizontal overflow into `hasScrollControls` (top/bottom only; vertical stacks do not scroll this strip). */ setScrollControls() { if (!this.nav) return; if (this.noScrollControls) { this.hasScrollControls = false; return; } this.hasScrollControls = ['top', 'bottom'].includes(this.placement) && this.nav.scrollWidth > this.nav.clientWidth; } private handleKeyDown(event: KeyboardEvent) { const tab = event.composedPath().find( (el): el is NileNavTab => el instanceof HTMLElement && el.tagName.toLowerCase() === 'nile-nav-tab' ); if (tab?.disabled) { event.preventDefault(); return; } if (!tab || !this.tabs.includes(tab)) { return; } if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); this.activeTabProp = tab.panel; this.activeTabName = tab.panel; return; } const navKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End']; if (!navKeys.includes(event.key)) return; let index = this.tabs.indexOf(tab); const isHorizontal = ['top', 'bottom'].includes(this.placement); const isRtl = getComputedStyle(this).direction === 'rtl'; if (event.key === 'Home') { index = 0; } else if (event.key === 'End') { index = this.tabs.length - 1; } else if ( (isHorizontal && ((isRtl && event.key === 'ArrowRight') || (!isRtl && event.key === 'ArrowLeft'))) || (['start', 'end'].includes(this.placement) && event.key === 'ArrowUp') ) { index--; } else if ( (isHorizontal && ((isRtl && event.key === 'ArrowLeft') || (!isRtl && event.key === '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 (isHorizontal) { 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: NileNavTab, 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; if (this.activeTabProp !== this.activeTab.panel) { this.activeTabProp = this.activeTab.panel; } this.tabs.forEach(el => (el.active = el === this.activeTab)); this.panels.forEach(el => (el.active = el.name === this.activeTab?.panel)); this.syncIndicator(); this.positionPill(this.pillReady); if (['top', 'bottom'].includes(this.placement)) { scrollIntoView(this.activeTab, this.nav, 'horizontal', options.scrollBehavior); } if (options.emitEvents) { this.emit('nile-tab-change', { value: this.activeTab.panel, previousValue: previousTab?.panel ?? null, index: this.tabs.indexOf(this.activeTab), previousIndex: previousTab ? this.tabs.indexOf(previousTab) : -1, link: this.activeTab.link }); } } } private setAriaLabels() { const allTabs = this.getAllTabs({ includeDisabled: true }); for (const tab of allTabs) { const panel = this.panels.find(el => el.name === tab.panel); if (tab.disabled) { tab.removeAttribute('aria-controls'); if (panel) panel.removeAttribute('aria-labelledby'); continue; } if (!panel) continue; const panelId = panel.getAttribute('id'); const tabId = tab.getAttribute('id'); if (!panelId || !tabId) continue; tab.setAttribute('aria-controls', panelId); panel.setAttribute('aria-labelledby', tabId); } } private getRelativeOffsetToAncestor(element: HTMLElement, ancestor: HTMLElement): { x: number; y: number } | null { let x = 0; let y = 0; let el: HTMLElement | null = element; while (el && el !== ancestor) { x += el.offsetLeft; y += el.offsetTop; el = el.offsetParent as HTMLElement | null; } return el === ancestor ? { x, y } : null; } private getLayoutBoxRelativeToContainer( target: HTMLElement, container: HTMLElement ): { x: number; y: number; width: number; height: number } | null { const c = container.getBoundingClientRect(); const r = target.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return null; const scaleX = container.offsetWidth > 0 ? c.width / container.offsetWidth : 1; const scaleY = container.offsetHeight > 0 ? c.height / container.offsetHeight : 1; if (scaleX > 0 && scaleY > 0 && Number.isFinite(scaleX) && Number.isFinite(scaleY)) { const scaleIsUnity = Math.abs(scaleX - 1) < 0.001 && Math.abs(scaleY - 1) < 0.001; if (scaleIsUnity) { const off = this.getRelativeOffsetToAncestor(target, container); if (off) { return { x: off.x, y: off.y, width: target.offsetWidth, height: target.offsetHeight, }; } } const x = (r.left - c.left) / scaleX; const y = (r.top - c.top) / scaleY; let width = r.width / scaleX; let height = r.height / scaleY; const ow = target.offsetWidth; const oh = target.offsetHeight; if (ow > 0) width = Math.min(width, ow); if (oh > 0) height = Math.min(height, oh); return { x, y, width, height }; } const offset = this.getRelativeOffsetToAncestor(target, container); if (!offset) return null; return { x: offset.x, y: offset.y, width: target.offsetWidth, height: target.offsetHeight, }; } private repositionIndicator(options?: { skipTransition?: boolean }) { const currentTab = this.getActiveTab(); if (!currentTab || !this.indicator) return; const container = this.shadowRoot?.querySelector('.nav-tab-group__tabs'); if (!container) return; const target = currentTab.shadowRoot?.querySelector('.nav-tab-container'); if (!target) return; const skipTransition = options?.skipTransition === true; if (skipTransition) { this.indicator.style.transition = 'none'; } const layoutBox = this.getLayoutBoxRelativeToContainer(target, container); if (!layoutBox) { if (skipTransition) { requestAnimationFrame(() => { if (this.indicator) this.indicator.style.transition = ''; }); } return; } const x = layoutBox.x; const y = layoutBox.y; switch (this.placement) { case 'top': case 'bottom': this.indicator.style.width = `${layoutBox.width}px`; this.indicator.style.height = 'auto'; this.indicator.style.translate = `${x}px 0px`; break; case 'start': case 'end': this.indicator.style.width = 'auto'; this.indicator.style.height = `${layoutBox.height}px`; this.indicator.style.translate = `0px ${y}px`; break; } if (skipTransition) { requestAnimationFrame(() => { requestAnimationFrame(() => { if (this.indicator) this.indicator.style.transition = ''; }); }); } } private syncTabsAndPanels() { this.tabs = this.getAllTabs({ includeDisabled: false }); this.panels = this.getAllPanels(); const allTabs = this.getAllTabs({ includeDisabled: true }); const enabledTabs = this.getAllTabs({ includeDisabled: false }); const size = enabledTabs.length; const currentTabs = new Set(allTabs); for (const tab of this.observedTabs) { if (!currentTabs.has(tab)) { this.resizeObserver.unobserve(tab); this.observedTabs.delete(tab); } } allTabs.forEach(tab => { tab.centered = this.centered; if (tab.disabled) { tab.removeAttribute('aria-posinset'); tab.removeAttribute('aria-setsize'); } else { const i = enabledTabs.indexOf(tab); tab.setAttribute('aria-posinset', String(i + 1)); tab.setAttribute('aria-setsize', String(size)); } if (!this.observedTabs.has(tab)) { this.resizeObserver.observe(tab); this.observedTabs.add(tab); } }); this.setScrollControls(); this.setAriaLabels(); } } export default NileNavTabGroup; declare global { interface HTMLElementTagNameMap { 'nile-nav-tab-group': NileNavTabGroup; } }