Files
dees-catalog/ts_web/elements/dees-appui-tabs.ts
Juergen Kunz 811737adcd update
2025-06-27 22:55:20 +00:00

451 lines
13 KiB
TypeScript

import * as interfaces from './interfaces/index.js';
import {
DeesElement,
type TemplateResult,
property,
customElement,
html,
css,
cssManager,
} from '@design.estate/dees-element';
import * as domtools from '@design.estate/dees-domtools';
@customElement('dees-appui-tabs')
export class DeesAppuiTabs extends DeesElement {
public static demo = () => {
const horizontalTabs: interfaces.ITab[] = [
{ key: 'Home', iconName: 'lucide:home', action: () => console.log('Home clicked') },
{ key: 'Analytics Dashboard', iconName: 'lucide:lineChart', action: () => console.log('Analytics clicked') },
{ key: 'Reports', iconName: 'lucide:fileText', action: () => console.log('Reports clicked') },
{ key: 'User Settings', iconName: 'lucide:settings', action: () => console.log('Settings clicked') },
{ key: 'Help', iconName: 'lucide:helpCircle', action: () => console.log('Help clicked') },
];
const verticalTabs: interfaces.ITab[] = [
{ key: 'Profile', iconName: 'lucide:user', action: () => console.log('Profile clicked') },
{ key: 'Security', iconName: 'lucide:shield', action: () => console.log('Security clicked') },
{ key: 'Notifications', iconName: 'lucide:bell', action: () => console.log('Notifications clicked') },
{ key: 'Integrations', iconName: 'lucide:link', action: () => console.log('Integrations clicked') },
{ key: 'Advanced', iconName: 'lucide:code', action: () => console.log('Advanced clicked') },
];
const noIndicatorTabs: interfaces.ITab[] = [
{ key: 'All', action: () => console.log('All clicked') },
{ key: 'Active', action: () => console.log('Active clicked') },
{ key: 'Completed', action: () => console.log('Completed clicked') },
{ key: 'Archived', action: () => console.log('Archived clicked') },
];
const demoContent = (text: string) => html`
<div style="padding: 24px; color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};">
${text}
</div>
`;
return html`
<style>
.demo-container {
display: flex;
flex-direction: column;
gap: 32px;
padding: 48px;
background: ${cssManager.bdTheme('#f8f9fa', '#0a0a0a')};
min-height: 100vh;
}
.section {
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 8px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.section-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.two-column {
display: grid;
grid-template-columns: 200px 1fr;
gap: 24px;
align-items: start;
}
</style>
<div class="demo-container">
<div class="section">
<div class="section-title">Horizontal Tabs with Animated Indicator</div>
<dees-appui-tabs .tabs=${horizontalTabs}>
${demoContent('Select a tab to see the smooth sliding animation of the indicator. The indicator automatically adjusts its width to match the tab content with minimal padding.')}
</dees-appui-tabs>
</div>
<div class="section">
<div class="section-title">Vertical Tabs Layout</div>
<div class="two-column">
<dees-appui-tabs .tabStyle=${'vertical'} .tabs=${verticalTabs}></dees-appui-tabs>
${demoContent('Vertical tabs work great for settings pages and navigation menus. The animated indicator smoothly transitions between selections.')}
</div>
</div>
<div class="section">
<div class="section-title">Without Indicator</div>
<dees-appui-tabs .showTabIndicator=${false} .tabs=${noIndicatorTabs}>
${demoContent('Tabs can also be used without the animated indicator by setting showTabIndicator to false.')}
</dees-appui-tabs>
</div>
</div>
`;
};
// INSTANCE
@property({
type: Array,
})
public tabs: interfaces.ITab[] = [];
@property({ type: Object })
public selectedTab: interfaces.ITab | null = null;
@property({ type: Boolean })
public showTabIndicator: boolean = true;
@property({ type: String })
public tabStyle: 'horizontal' | 'vertical' = 'horizontal';
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
position: relative;
width: 100%;
}
.tabs-wrapper {
position: relative;
}
.tabs-wrapper.horizontal-wrapper {
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
}
.tabsContainer {
position: relative;
user-select: none;
}
.tabsContainer.horizontal {
display: flex;
align-items: center;
font-size: 14px;
overflow-x: auto;
scrollbar-width: none;
height: 48px;
padding: 0 16px;
gap: 4px;
}
.tabsContainer.horizontal::-webkit-scrollbar {
display: none;
}
.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);
}
.content {
padding: 32px 24px;
}
`,
];
public render(): TemplateResult {
return html`
${this.renderTabsWrapper()}
<div class="content">
<slot></slot>
</div>
`;
}
private renderTabsWrapper(): TemplateResult {
const isHorizontal = this.tabStyle === 'horizontal';
const wrapperClass = isHorizontal ? 'tabs-wrapper horizontal-wrapper' : 'vertical-wrapper';
const containerClass = `tabsContainer ${this.tabStyle}`;
return html`
<div class="${wrapperClass}">
<div class="${containerClass}">
${this.tabs.map(tab => this.renderTab(tab, isHorizontal))}
</div>
${this.showTabIndicator ? html`<div class="tabIndicator"></div>` : ''}
</div>
`;
}
private renderTab(tab: interfaces.ITab, isHorizontal: boolean): TemplateResult {
const isSelected = tab === this.selectedTab;
const classes = `tab ${isSelected ? 'selectedTab' : ''}`;
const content = isHorizontal ? html`
<span class="tab-content">
${this.renderTabIcon(tab)}
${tab.key}
</span>
` : html`
${this.renderTabIcon(tab)}
${tab.key}
`;
return html`
<div
class="${classes}"
@click="${() => this.selectTab(tab)}"
>
${content}
</div>
`;
}
private renderTabIcon(tab: interfaces.ITab): TemplateResult | '' {
return tab.iconName ? html`<dees-icon .icon=${tab.iconName}></dees-icon>` : '';
}
private selectTab(tabArg: interfaces.ITab) {
this.selectedTab = tabArg;
tabArg.action();
// Emit tab-select event
this.dispatchEvent(new CustomEvent('tab-select', {
detail: { tab: tabArg },
bubbles: true,
composed: true
}));
}
firstUpdated() {
if (this.tabs && this.tabs.length > 0) {
this.selectTab(this.tabs[0]);
}
}
async updated(changedProperties: Map<string, any>) {
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();
});
}
}
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`;
}
}