import * as interfaces from '../../interfaces/index.js'; import { DeesElement, type TemplateResult, property, state, customElement, html, css, cssManager, } from '@design.estate/dees-element'; import * as domtools from '@design.estate/dees-domtools'; import { demoFunc } from './dees-appui-tabs.demo.js'; import { themeDefaultStyles } from '../../00theme.js'; @customElement('dees-appui-tabs') export class DeesAppuiTabs extends DeesElement { public static demo = demoFunc; public static demoGroup = 'App UI'; // INSTANCE @property({ type: Array, }) accessor tabs: interfaces.IMenuItem[] = []; @property({ type: Object }) accessor selectedTab: interfaces.IMenuItem | null = null; @property({ type: Boolean }) accessor showTabIndicator: boolean = true; @property({ type: String }) accessor tabStyle: 'horizontal' | 'vertical' = 'horizontal'; @property({ type: Boolean }) accessor autoHide: boolean = false; @property({ type: Number }) accessor autoHideThreshold: number = 0; // Scroll state for fade indicators @state() private accessor canScrollLeft: boolean = false; @state() private accessor canScrollRight: boolean = false; private resizeObserver: ResizeObserver | null = null; public static styles = [ themeDefaultStyles, cssManager.defaultStyles, css` /* TODO: Migrate hardcoded values to --dees-* CSS variables */ :host { display: block; position: relative; width: 100%; min-width: 0; overflow: hidden; } .tabs-wrapper { position: relative; min-width: 0; } .tabs-wrapper.horizontal-wrapper { height: 48px; border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')}; box-sizing: border-box; overflow: hidden; } /* Scroll fade indicators */ .scroll-fade { position: absolute; top: 0; bottom: 1px; width: 48px; pointer-events: none; opacity: 0; transition: opacity 0.2s ease; z-index: 10; } .scroll-fade-left { left: 0; background: linear-gradient(to right, ${cssManager.bdTheme('#ffffff', '#161616')} 0%, ${cssManager.bdTheme('rgba(255,255,255,0)', 'rgba(22,22,22,0)')} 100%); } .scroll-fade-right { right: 0; background: linear-gradient(to left, ${cssManager.bdTheme('#ffffff', '#161616')} 0%, ${cssManager.bdTheme('rgba(255,255,255,0)', 'rgba(22,22,22,0)')} 100%); } .scroll-fade.visible { opacity: 1; } .tabsContainer { position: relative; user-select: none; min-width: 0; } .tabsContainer.horizontal { display: flex; align-items: center; font-size: 14px; overflow-x: auto; overflow-y: hidden; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: transparent transparent; height: 100%; padding: 0 16px; gap: 4px; } /* Show scrollbar on hover */ .tabs-wrapper:hover .tabsContainer.horizontal { scrollbar-color: ${cssManager.bdTheme('rgba(0,0,0,0.2)', 'rgba(255,255,255,0.2)')} transparent; } .tabsContainer.horizontal::-webkit-scrollbar { height: 4px; } .tabsContainer.horizontal::-webkit-scrollbar-track { background: transparent; } .tabsContainer.horizontal::-webkit-scrollbar-thumb { background: transparent; border-radius: 2px; transition: background 0.2s ease; } .tabs-wrapper:hover .tabsContainer.horizontal::-webkit-scrollbar-thumb { background: ${cssManager.bdTheme('rgba(0,0,0,0.2)', 'rgba(255,255,255,0.2)')}; } .tabs-wrapper:hover .tabsContainer.horizontal::-webkit-scrollbar-thumb:hover { background: ${cssManager.bdTheme('rgba(0,0,0,0.35)', 'rgba(255,255,255,0.35)')}; } .tabsContainer.vertical { display: flex; flex-direction: column; padding: 8px; font-size: 14px; gap: 2px; position: relative; background: ${cssManager.bdTheme('#f9fafb', '#18181b')}; border-radius: 8px; } .tab { color: ${cssManager.bdTheme('#71717a', '#71717a')}; white-space: nowrap; cursor: pointer; transition: color 0.15s ease; font-weight: 500; position: relative; z-index: 2; } .horizontal .tab { padding: 0 16px; height: 100%; display: inline-flex; align-items: center; gap: 8px; position: relative; border-radius: 6px 6px 0 0; transition: background-color 0.15s ease; } .horizontal .tab:not(:last-child)::after { content: ''; position: absolute; right: -2px; top: 50%; transform: translateY(-50%); height: 20px; width: 1px; background: ${cssManager.bdTheme('#e5e7eb', '#27272a')}; opacity: 0.5; } .horizontal .tab .tab-content { display: inline-flex; align-items: center; gap: 8px; } .vertical .tab { padding: 10px 16px; border-radius: 6px; width: 100%; display: flex; align-items: center; gap: 8px; transition: all 0.15s ease; } .tab:hover { color: ${cssManager.bdTheme('#09090b', '#fafafa')}; } .horizontal .tab:hover { background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.03)')}; } .horizontal .tab:hover::after, .horizontal .tab:hover + .tab::after { opacity: 0; } .vertical .tab:hover { background: ${cssManager.bdTheme('rgba(244, 244, 245, 0.5)', 'rgba(39, 39, 42, 0.5)')}; } .horizontal .tab.selectedTab { color: ${cssManager.bdTheme('#09090b', '#fafafa')}; } .horizontal .tab.selectedTab::after, .horizontal .tab.selectedTab + .tab::after { opacity: 0; } .vertical .tab.selectedTab { color: ${cssManager.bdTheme('#09090b', '#fafafa')}; } .tab dees-icon { font-size: 16px; } .tabIndicator { position: absolute; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); opacity: 0; } .tabIndicator.no-transition { transition: none; } .tabs-wrapper .tabIndicator { height: 3px; bottom: 0; background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; border-radius: 3px 3px 0 0; z-index: 3; } .vertical-wrapper { position: relative; } .vertical-wrapper .tabIndicator { left: 8px; right: 8px; border-radius: 6px; background: ${cssManager.bdTheme('#ffffff', '#27272a')}; z-index: 1; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } /* Close button */ .tab-close { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 4px; margin-left: 8px; opacity: 0.4; transition: opacity 0.15s, background 0.15s; color: ${cssManager.bdTheme('#71717a', '#71717a')}; } .tab:hover .tab-close { opacity: 0.7; } .tab-close:hover { opacity: 1; background: ${cssManager.bdTheme('rgba(0,0,0,0.1)', 'rgba(255,255,255,0.1)')}; color: ${cssManager.bdTheme('#ef4444', '#f87171')}; } .tab.selectedTab .tab-close { opacity: 0.5; } .tab.selectedTab:hover .tab-close { opacity: 0.8; } .tab.selectedTab .tab-close:hover { opacity: 1; } `, ]; public render(): TemplateResult { // Auto-hide when enabled and tab count is at or below threshold if (this.autoHide && this.tabs.length <= this.autoHideThreshold) { return html``; } return html` ${this.renderTabsWrapper()} `; } private renderTabsWrapper(): TemplateResult { const isHorizontal = this.tabStyle === 'horizontal'; const wrapperClass = isHorizontal ? 'tabs-wrapper horizontal-wrapper' : 'vertical-wrapper'; const containerClass = `tabsContainer ${this.tabStyle}`; if (isHorizontal) { return html`
${this.tabs.map(tab => this.renderTab(tab, isHorizontal))}
${this.showTabIndicator ? html`
` : ''}
`; } return html`
${this.tabs.map(tab => this.renderTab(tab, isHorizontal))}
${this.showTabIndicator ? html`
` : ''}
`; } private renderTab(tab: interfaces.IMenuItem, isHorizontal: boolean): TemplateResult { const isSelected = tab === this.selectedTab; const classes = `tab ${isSelected ? 'selectedTab' : ''}`; const closeButton = tab.closeable ? html` ` : ''; const content = isHorizontal ? html` ${this.renderTabIcon(tab)} ${tab.key} ${closeButton} ` : html` ${this.renderTabIcon(tab)} ${tab.key} ${closeButton} `; return html`
${content}
`; } private renderTabIcon(tab: interfaces.IMenuItem): TemplateResult | '' { return tab.iconName ? html`` : ''; } private selectTab(tabArg: interfaces.IMenuItem) { this.selectedTab = tabArg; tabArg.action(); // Scroll selected tab into view requestAnimationFrame(() => { this.scrollTabIntoView(tabArg); }); // Emit tab-select event this.dispatchEvent(new CustomEvent('tab-select', { detail: { tab: tabArg }, bubbles: true, composed: true })); } private closeTab(e: Event, tab: interfaces.IMenuItem) { e.stopPropagation(); // Don't select tab when closing // Call the tab's onClose callback if defined if (tab.onClose) { tab.onClose(); } // Also emit event for parent components this.dispatchEvent(new CustomEvent('tab-close', { detail: { tab }, bubbles: true, composed: true })); } firstUpdated() { if (this.tabs && this.tabs.length > 0) { this.selectTab(this.tabs[0]); } // Set up ResizeObserver for scroll state updates this.setupResizeObserver(); // Initial scroll state check requestAnimationFrame(() => { this.updateScrollState(); }); } async disconnectedCallback() { await super.disconnectedCallback(); if (this.resizeObserver) { this.resizeObserver.disconnect(); this.resizeObserver = null; } } private setupResizeObserver() { if (this.tabStyle !== 'horizontal') return; this.resizeObserver = new ResizeObserver(() => { this.updateScrollState(); }); const container = this.shadowRoot?.querySelector('.tabsContainer.horizontal'); if (container) { this.resizeObserver.observe(container); } } private handleScroll = () => { this.updateScrollState(); }; private updateScrollState() { const container = this.shadowRoot?.querySelector('.tabsContainer.horizontal') as HTMLElement; if (!container) return; const scrollLeft = container.scrollLeft; const scrollWidth = container.scrollWidth; const clientWidth = container.clientWidth; // Small threshold to account for rounding const threshold = 2; this.canScrollLeft = scrollLeft > threshold; this.canScrollRight = scrollLeft < scrollWidth - clientWidth - threshold; } private scrollTabIntoView(tab: interfaces.IMenuItem) { if (this.tabStyle !== 'horizontal') return; const tabIndex = this.tabs.indexOf(tab); if (tabIndex === -1) return; const container = this.shadowRoot?.querySelector('.tabsContainer.horizontal') as HTMLElement; const tabElement = container?.querySelector(`.tab:nth-child(${tabIndex + 1})`) as HTMLElement; if (tabElement && container) { const containerRect = container.getBoundingClientRect(); const tabRect = tabElement.getBoundingClientRect(); // Check if tab is fully visible const isFullyVisible = tabRect.left >= containerRect.left && tabRect.right <= containerRect.right; if (!isFullyVisible) { tabElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); } } } async updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('tabs') && this.tabs && this.tabs.length > 0 && !this.selectedTab) { this.selectTab(this.tabs[0]); } if (changedProperties.has('selectedTab') || changedProperties.has('tabs')) { await this.updateComplete; // Wait for fonts to load on first update if (!this.indicatorInitialized && document.fonts) { await document.fonts.ready; } requestAnimationFrame(() => { this.updateTabIndicator(); this.updateScrollState(); }); } } private indicatorInitialized = false; private updateTabIndicator() { if (!this.shouldShowIndicator()) return; const selectedTabElement = this.getSelectedTabElement(); if (!selectedTabElement) return; const indicator = this.getIndicatorElement(); if (!indicator) return; this.handleInitialTransition(indicator); if (this.tabStyle === 'horizontal') { this.updateHorizontalIndicator(indicator, selectedTabElement); } else { this.updateVerticalIndicator(indicator, selectedTabElement); } indicator.style.opacity = '1'; } private shouldShowIndicator(): boolean { return this.selectedTab && this.showTabIndicator && this.tabs.includes(this.selectedTab); } private getSelectedTabElement(): HTMLElement | null { const selectedIndex = this.tabs.indexOf(this.selectedTab); const isHorizontal = this.tabStyle === 'horizontal'; const selector = isHorizontal ? `.tabs-wrapper .tabsContainer .tab:nth-child(${selectedIndex + 1})` : `.vertical-wrapper .tabsContainer .tab:nth-child(${selectedIndex + 1})`; return this.shadowRoot.querySelector(selector); } private getIndicatorElement(): HTMLElement | null { return this.shadowRoot.querySelector('.tabIndicator'); } private handleInitialTransition(indicator: HTMLElement): void { if (!this.indicatorInitialized) { indicator.classList.add('no-transition'); this.indicatorInitialized = true; setTimeout(() => { indicator.classList.remove('no-transition'); }, 50); } } private updateHorizontalIndicator(indicator: HTMLElement, tabElement: HTMLElement): void { const tabContent = tabElement.querySelector('.tab-content') as HTMLElement; if (!tabContent) return; const wrapperRect = indicator.parentElement.getBoundingClientRect(); const contentRect = tabContent.getBoundingClientRect(); const contentLeft = contentRect.left - wrapperRect.left; const indicatorWidth = contentRect.width + 8; const indicatorLeft = contentLeft - 4; indicator.style.width = `${indicatorWidth}px`; indicator.style.left = `${indicatorLeft}px`; } private updateVerticalIndicator(indicator: HTMLElement, tabElement: HTMLElement): void { const tabsContainer = this.shadowRoot.querySelector('.vertical-wrapper .tabsContainer') as HTMLElement; if (!tabsContainer) return; indicator.style.top = `${tabElement.offsetTop + tabsContainer.offsetTop}px`; indicator.style.height = `${tabElement.clientHeight}px`; } }